Browse Source

Implement more tests

Add tests related to error handling.
master
TheoryOfNekomata 8 months ago
parent
commit
770b62c6dd
11 changed files with 1439 additions and 1113 deletions
  1. +1
    -0
      TODO.md
  2. +2
    -2
      src/backend/core.ts
  3. +12
    -7
      src/backend/servers/http/core.ts
  4. +23
    -13
      src/backend/servers/http/handlers/resource.ts
  5. +1
    -1
      src/common/app.ts
  6. +7
    -1
      src/common/language.ts
  7. +8
    -0
      src/common/resource.ts
  8. +0
    -1089
      test/e2e/http.test.ts
  9. +478
    -0
      test/e2e/http/default.test.ts
  10. +764
    -0
      test/e2e/http/error-handling.test.ts
  11. +143
    -0
      test/utils.ts

+ 1
- 0
TODO.md View File

@@ -16,6 +16,7 @@
- [X] Date/Datetime handling (endpoints should be able to accept timestamps and ISO date/datetime strings)
- [ ] Querying items in collections
- [ ] Better URL parsing for determining target resource/resource IDs (e.g. `/api/users/3/posts/5`, `/users/3` is a query, `posts` is the target resource, `5` is the target resource ID. Different case with `/api/users/3/posts/5/attachments`)
- [ ] Declare relationship (e.g. `/users/3/posts`)
- [ ] Tests
- [X] Happy path
- [ ] Error handling


+ 2
- 2
src/backend/core.ts View File

@@ -12,9 +12,9 @@ export interface Backend<T extends DataSource = DataSource> {
dataSource?: (resource: Resource) => T;
}

export interface CreateBackendParams {
export interface CreateBackendParams<T extends DataSource = DataSource> {
app: ApplicationState;
dataSource: DataSource;
dataSource: T;
}

export const createBackend = (params: CreateBackendParams) => {


+ 12
- 7
src/backend/servers/http/core.ts View File

@@ -279,7 +279,7 @@ export const createServer = (backendState: BackendState, serverParams = {} as Cr
}

if (typeof resource.dataSource === 'undefined') {
throw new ErrorPlainResponse('unableToInitializeResourceDataSource', {
throw new ErrorPlainResponse('unableToBindResourceDataSource', {
statusCode: constants.HTTP_STATUS_INTERNAL_SERVER_ERROR,
res: theRes,
});
@@ -362,16 +362,21 @@ export const createServer = (backendState: BackendState, serverParams = {} as Cr
resourceReq.resource!.state.itemName) ?? '';
res.writeHead(constants.HTTP_STATUS_INTERNAL_SERVER_ERROR);
res.end();
return;
}

headers['Content-Type'] = [
resourceReq.backend.cn.mediaType.name,
`charset=${resourceReq.backend.cn.charset.name}`,
].join('; ');

const statusMessageKey = finalErr.statusMessage ? resourceReq.backend.cn.language.statusMessages[finalErr.statusMessage] : undefined;
res.statusMessage = statusMessageKey?.replace(/\$RESOURCE/g, resourceReq.resource!.state.itemName) ?? '';
res.writeHead(finalErr.statusCode, headers);
typeof serialized !== 'undefined' ? `charset=${resourceReq.backend.cn.charset.name}` : '',
]
.filter((s) => s.length > 0)
.join('; ');

res.statusMessage = resourceReq.backend.cn.language.statusMessages[
finalErr.statusMessage ?? 'internalServerError'
]?.replace(/\$RESOURCE/g,
resourceReq.resource!.state.itemName);
res.writeHead(finalErr.statusCode ?? constants.HTTP_STATUS_INTERNAL_SERVER_ERROR, headers);
if (typeof encoded !== 'undefined') {
res.end(encoded);
return;


+ 23
- 13
src/backend/servers/http/handlers/resource.ts View File

@@ -2,6 +2,7 @@ import { constants } from 'http2';
import * as v from 'valibot';
import {Middleware} from '../../../common';
import {ErrorPlainResponse, PlainResponse} from '../response';
import assert from 'assert';

export const handleGetCollection: Middleware = async (req, res) => {
const { query, resource, backend } = req;
@@ -39,18 +40,22 @@ export const handleGetCollection: Middleware = async (req, res) => {
});
};

const isResourceIdDefined = (resourceId?: string): resourceId is string => !(
typeof resourceId === 'undefined' || resourceId.trim().length < 1
);

export const handleGetItem: Middleware = async (req, res) => {
const { resource, resourceId } = req;

if (typeof resourceId === 'undefined' || resourceId.trim().length < 1) {
throw new ErrorPlainResponse(
assert(
isResourceIdDefined(resourceId),
new ErrorPlainResponse(
'resourceIdNotGiven',
{
statusCode: constants.HTTP_STATUS_BAD_REQUEST,
res,
}
);
}
)
);

let data: v.Output<typeof resource.schema> | null = null;
try {
@@ -184,17 +189,21 @@ export const handlePatchItem: Middleware = async (req, res) => {
});
};

const isIdAttributeDefined = (idAttr?: unknown): idAttr is string => (
typeof idAttr !== 'undefined'
);

export const handleCreateItem: Middleware = async (req, res) => {
const { resource, body, backend, basePath } = req;

const idAttrRaw = resource.state.shared.get('idAttr');
if (typeof idAttrRaw === 'undefined') {
throw new ErrorPlainResponse('unableToGenerateIdFromResourceDataSource', {
const idAttr = resource.state.shared.get('idAttr');
assert(
isIdAttributeDefined(idAttr),
new ErrorPlainResponse('unableToGenerateIdFromResourceDataSource', {
statusCode: constants.HTTP_STATUS_INTERNAL_SERVER_ERROR,
res,
});
}
const idAttr = idAttrRaw as string;
})
);

let newId;
let params: v.Output<typeof resource.schema>;
@@ -203,7 +212,7 @@ export const handleCreateItem: Middleware = async (req, res) => {
params = { ...body as Record<string, unknown> };
params[idAttr] = newId;
} catch (cause) {
throw new ErrorPlainResponse('unableToGenerateIdFromResourceDataSource', {
throw new ErrorPlainResponse('unableToAssignIdFromResourceDataSource', {
cause,
statusCode: constants.HTTP_STATUS_INTERNAL_SERVER_ERROR,
res,
@@ -220,10 +229,11 @@ export const handleCreateItem: Middleware = async (req, res) => {
let totalItemCount: number | undefined;

try {
newObject = await resource.dataSource.create(params);
if (backend!.showTotalItemCountOnCreateItem && typeof resource.dataSource.getTotalCount === 'function') {
totalItemCount = await resource.dataSource.getTotalCount();
totalItemCount += 1;
}
newObject = await resource.dataSource.create(params);
} catch (cause) {
throw new ErrorPlainResponse('unableToCreateResource', {
cause,


+ 1
- 1
src/common/app.ts View File

@@ -49,7 +49,7 @@ export interface Application<
export const application = (appParams: ApplicationParams): Application => {
const appState: ApplicationState = {
name: appParams.name,
resources: new Set<Resource<any>>(),
resources: new Set<Resource>(),
languages: new Map<Language['name'], Language>([
[FALLBACK_LANGUAGE.name, FALLBACK_LANGUAGE],
]),


+ 7
- 1
src/common/language.ts View File

@@ -17,7 +17,9 @@ export const LANGUAGE_DEFAULT_STATUS_MESSAGE_KEYS = [
'resourceNotFound',
'deleteNonExistingResource',
'unableToCreateResource',
'unableToBindResourceDataSource',
'unableToGenerateIdFromResourceDataSource',
'unableToAssignIdFromResourceDataSource',
'unableToEmplaceResource',
'unableToSerializeResponse',
'unableToEncodeResponse',
@@ -35,6 +37,7 @@ export const LANGUAGE_DEFAULT_STATUS_MESSAGE_KEYS = [
'resourceReplaced',
'notImplemented',
'provideOptions',
'internalServerError',
] as const;

export type LanguageDefaultStatusMessageKey = typeof LANGUAGE_DEFAULT_STATUS_MESSAGE_KEYS[number];
@@ -62,6 +65,7 @@ export const FALLBACK_LANGUAGE = {
statusMessages: {
unableToSerializeResponse: 'Unable To Serialize Response',
unableToEncodeResponse: 'Unable To Encode Response',
unableToBindResourceDataSource: 'Unable To Bind $RESOURCE Data Source',
unableToInitializeResourceDataSource: 'Unable To Initialize $RESOURCE Data Source',
unableToFetchResourceCollection: 'Unable To Fetch $RESOURCE Collection',
unableToFetchResource: 'Unable To Fetch $RESOURCE',
@@ -90,10 +94,12 @@ export const FALLBACK_LANGUAGE = {
resourceCreated: '$RESOURCE Created',
resourceReplaced: '$RESOURCE Replaced',
unableToGenerateIdFromResourceDataSource: 'Unable To Generate ID From $RESOURCE Data Source',
unableToAssignIdFromResourceDataSource: 'Unable To Assign ID From $RESOURCE Data Source',
unableToEmplaceResource: 'Unable To Emplace $RESOURCE',
resourceIdNotGiven: '$RESOURCE ID Not Given',
unableToCreateResource: 'Unable To Create $RESOURCE',
notImplemented: 'Not Implemented'
notImplemented: 'Not Implemented',
internalServerError: 'Internal Server Error',
},
bodies: {
languageNotAcceptable: [],


+ 8
- 0
src/common/resource.ts View File

@@ -1,10 +1,12 @@
import * as v from 'valibot';
import {BaseSchema} from 'valibot';

export interface ResourceState<
ItemName extends string = string,
RouteName extends string = string
> {
shared: Map<string, unknown>;
relationships: Set<Resource>;
itemName: ItemName;
routeName: RouteName;
canCreate: boolean;
@@ -30,6 +32,7 @@ export interface Resource<
canPatch(b?: boolean): this;
canEmplace(b?: boolean): this;
canDelete(b?: boolean): this;
relatedTo<RelatedSchema extends v.BaseSchema>(resource: Resource<RelatedSchema>): this;
}

export const resource = <
@@ -39,6 +42,7 @@ export const resource = <
>(schema: Schema): Resource<Schema, CurrentName, CurrentRouteName> => {
const resourceState = {
shared: new Map(),
relationships: new Set<Resource>(),
canCreate: false,
canFetchCollection: false,
canFetchItem: false,
@@ -105,6 +109,10 @@ export const resource = <
get schema() {
return schema;
},
relatedTo<RelatedSchema extends BaseSchema>(resource: Resource<RelatedSchema>) {
resourceState.relationships.add(resource);
return this;
},
} as Resource<Schema, CurrentName, CurrentRouteName>;
};



+ 0
- 1089
test/e2e/http.test.ts
File diff suppressed because it is too large
View File


+ 478
- 0
test/e2e/http/default.test.ts View File

@@ -0,0 +1,478 @@
import {
beforeAll,
afterAll,
afterEach,
beforeEach,
describe,
expect,
it,
} from 'vitest';
import {
tmpdir
} from 'os';
import {
mkdtemp,
rm,
writeFile,
} from 'fs/promises';
import {
join
} from 'path';
import {constants} from 'http2';
import {Backend, dataSources} from '../../../src/backend';
import { application, resource, validation as v, Resource } from '../../../src/common';
import { autoIncrement } from '../../fixtures';
import {createTestClient, TestClient} from '../../utils';

const PORT = 3000;
const HOST = '127.0.0.1';
const BASE_PATH = '/api';
const ACCEPT = 'application/json';
const ACCEPT_LANGUAGE = 'en';
const ACCEPT_CHARSET = 'utf-8';
const CONTENT_TYPE_CHARSET = 'utf-8';
const CONTENT_TYPE = ACCEPT;

describe('happy path', () => {
let client: TestClient;
beforeEach(() => {
client = createTestClient({
host: HOST,
port: PORT,
})
.acceptMediaType(ACCEPT)
.acceptLanguage(ACCEPT_LANGUAGE)
.acceptCharset(ACCEPT_CHARSET)
.contentType(CONTENT_TYPE)
.contentCharset(CONTENT_TYPE_CHARSET);
});

let baseDir: string;
beforeAll(async () => {
try {
baseDir = await mkdtemp(join(tmpdir(), 'yasumi-'));
} catch {
// noop
}
});
afterAll(async () => {
try {
await rm(baseDir, {
recursive: true,
});
} catch {
// noop
}
});

let Piano: Resource;
beforeEach(() => {
Piano = resource(v.object(
{
brand: v.string()
},
v.never()
))
.name('Piano' as const)
.route('pianos' as const)
.id('id' as const, {
generationStrategy: autoIncrement,
serialize: (id) => id?.toString() ?? '0',
deserialize: (id) => Number.isFinite(Number(id)) ? Number(id) : 0,
schema: v.number(),
});
});

let backend: Backend;
let server: ReturnType<Backend['createHttpServer']>;
beforeEach(() => {
const app = application({
name: 'piano-service',
})
.resource(Piano);

backend = app.createBackend({
dataSource: new dataSources.jsonlFile.DataSource(baseDir),
});

server = backend.createHttpServer({
basePath: BASE_PATH
});

return new Promise((resolve, reject) => {
server.on('error', (err) => {
reject(err);
});

server.on('listening', () => {
resolve();
});

server.listen({
port: PORT
});
});
});

afterEach(() => new Promise((resolve, reject) => {
server.close((err) => {
if (err) {
reject(err);
}

resolve();
});
}));

describe('serving collections', () => {
beforeEach(() => {
Piano.canFetchCollection();
return new Promise((resolve) => {
setTimeout(() => {
resolve();
});
});
});

afterEach(() => {
Piano.canFetchCollection(false);
});

it('returns data', async () => {
const [res, resData] = await client({
method: 'GET',
path: `${BASE_PATH}/pianos`,
});

expect(res).toHaveProperty('statusCode', constants.HTTP_STATUS_OK);
// TODO test status messages
expect(res.headers).toHaveProperty('content-type', expect.stringContaining(ACCEPT));

if (typeof resData === 'undefined') {
expect.fail('Response body must be defined.');
return;
}

expect(resData).toEqual([]);
});

it('returns data on HEAD method', async () => {
const [res] = await client({
method: 'HEAD',
path: `${BASE_PATH}/pianos`,
});

expect(res).toHaveProperty('statusCode', constants.HTTP_STATUS_OK);
});

it('returns options', async () => {
const [res] = await client({
method: 'OPTIONS',
path: `${BASE_PATH}/pianos`,
});

expect(res).toHaveProperty('statusCode', constants.HTTP_STATUS_NO_CONTENT);
const allowedMethods = res.headers.allow?.split(',').map((s) => s.trim()) ?? [];
expect(allowedMethods).toContain('GET');
expect(allowedMethods).toContain('HEAD');
});
});

describe('serving items', () => {
const existingResource = {
id: 1,
brand: 'Yamaha'
};

beforeEach(async () => {
const resourcePath = join(baseDir, 'pianos.jsonl');
await writeFile(resourcePath, JSON.stringify(existingResource));
});

beforeEach(() => {
Piano.canFetchItem();
return new Promise((resolve) => {
setTimeout(() => {
resolve();
});
});
});

afterEach(() => {
Piano.canFetchItem(false);
});

it('returns data', async () => {
const [res, resData] = await client({
method: 'GET',
path: `${BASE_PATH}/pianos/${existingResource.id}`,
});

expect(res).toHaveProperty('statusCode', constants.HTTP_STATUS_OK);
expect(res.headers).toHaveProperty('content-type', expect.stringContaining(ACCEPT));
if (typeof resData === 'undefined') {
expect.fail('Response body must be defined.');
return;
}

expect(resData).toEqual(existingResource);
});

it('returns data on HEAD method', async () => {
const [res] = await client({
method: 'HEAD',
path: `${BASE_PATH}/pianos/${existingResource.id}`,
});

expect(res).toHaveProperty('statusCode', constants.HTTP_STATUS_OK);
});

it('returns options', async () => {
const [res] = await client({
method: 'OPTIONS',
path: `${BASE_PATH}/pianos/${existingResource.id}`,
});

expect(res).toHaveProperty('statusCode', constants.HTTP_STATUS_NO_CONTENT);
const allowedMethods = res.headers.allow?.split(',').map((s) => s.trim()) ?? [];
expect(allowedMethods).toContain('GET');
expect(allowedMethods).toContain('HEAD');
});
});

describe('creating items', () => {
const existingResource = {
id: 1,
brand: 'Yamaha'
};

const newResourceData = {
brand: 'K. Kawai'
};

beforeEach(async () => {
const resourcePath = join(baseDir, 'pianos.jsonl');
await writeFile(resourcePath, JSON.stringify(existingResource));
});

beforeEach(() => {
Piano.canCreate();
});

afterEach(() => {
Piano.canCreate(false);
});

it('returns data', async () => {
const [res, resData] = await client({
path: `${BASE_PATH}/pianos`,
method: 'POST',
body: newResourceData,
});

expect(res).toHaveProperty('statusCode', constants.HTTP_STATUS_CREATED);
expect(res.headers).toHaveProperty('content-type', expect.stringContaining(ACCEPT));
expect(res.headers).toHaveProperty('location', `${BASE_PATH}/pianos/2`);

if (typeof resData === 'undefined') {
expect.fail('Response body must be defined.');
return;
}

expect(resData).toEqual({
...newResourceData,
id: 2
});
});

it('returns options', async () => {
const [res] = await client({
method: 'OPTIONS',
path: `${BASE_PATH}/pianos`,
});

expect(res).toHaveProperty('statusCode', constants.HTTP_STATUS_NO_CONTENT);
const allowedMethods = res.headers.allow?.split(',').map((s) => s.trim()) ?? [];
expect(allowedMethods).toContain('POST');
});
});

describe('patching items', () => {
const existingResource = {
id: 1,
brand: 'Yamaha'
};

const patchData = {
brand: 'K. Kawai'
};

beforeEach(async () => {
const resourcePath = join(baseDir, 'pianos.jsonl');
await writeFile(resourcePath, JSON.stringify(existingResource));
});

beforeEach(() => {
Piano.canPatch();
});

afterEach(() => {
Piano.canPatch(false);
});

it('returns data', async () => {
const [res, resData] = await client({
method: 'PATCH',
path: `${BASE_PATH}/pianos/${existingResource.id}`,
body: patchData,
});

expect(res).toHaveProperty('statusCode', constants.HTTP_STATUS_OK);
expect(res.headers).toHaveProperty('content-type', expect.stringContaining(ACCEPT));

if (typeof resData === 'undefined') {
expect.fail('Response body must be defined.');
return;
}

expect(resData).toEqual({
...existingResource,
...patchData,
});
});

it('returns options', async () => {
const [res] = await client({
method: 'OPTIONS',
path: `${BASE_PATH}/pianos/${existingResource.id}`,
});

expect(res).toHaveProperty('statusCode', constants.HTTP_STATUS_NO_CONTENT);
const allowedMethods = res.headers.allow?.split(',').map((s) => s.trim()) ?? [];
expect(allowedMethods).toContain('PATCH');
});
});

describe('emplacing items', () => {
const existingResource = {
id: 1,
brand: 'Yamaha'
};

const emplaceResourceData = {
id: 1,
brand: 'K. Kawai'
};

beforeEach(async () => {
const resourcePath = join(baseDir, 'pianos.jsonl');
await writeFile(resourcePath, JSON.stringify(existingResource));
});

beforeEach(() => {
Piano.canEmplace();
});

afterEach(() => {
Piano.canEmplace(false);
});

it('returns data for replacement', async () => {
const [res, resData] = await client({
method: 'PUT',
path: `${BASE_PATH}/pianos/${emplaceResourceData.id}`,
body: emplaceResourceData,
});

expect(res).toHaveProperty('statusCode', constants.HTTP_STATUS_OK);
expect(res.headers).toHaveProperty('content-type', expect.stringContaining(ACCEPT));

if (typeof resData === 'undefined') {
expect.fail('Response body must be defined.');
return;
}

expect(resData).toEqual(emplaceResourceData);
});

it('returns data for creation', async () => {
const newId = 2;

const [res, resData] = await client({
method: 'PUT',
path: `${BASE_PATH}/pianos/${newId}`,
body: {
...emplaceResourceData,
id: newId,
},
});

expect(res).toHaveProperty('statusCode', constants.HTTP_STATUS_CREATED);
expect(res.headers).toHaveProperty('content-type', expect.stringContaining(ACCEPT));
expect(res.headers).toHaveProperty('location', `${BASE_PATH}/pianos/${newId}`);

if (typeof resData === 'undefined') {
expect.fail('Response body must be defined.');
return;
}

expect(resData).toEqual({
...emplaceResourceData,
id: newId,
});
});

it('returns options', async () => {
const [res] = await client({
method: 'OPTIONS',
path: `${BASE_PATH}/pianos/${existingResource.id}`,
});

expect(res).toHaveProperty('statusCode', constants.HTTP_STATUS_NO_CONTENT);
const allowedMethods = res.headers.allow?.split(',').map((s) => s.trim()) ?? [];
expect(allowedMethods).toContain('PUT');
});
});

describe('deleting items', () => {
const existingResource = {
id: 1,
brand: 'Yamaha'
};

beforeEach(async () => {
const resourcePath = join(baseDir, 'pianos.jsonl');
await writeFile(resourcePath, JSON.stringify(existingResource));
});

beforeEach(() => {
Piano.canDelete();
});

afterEach(() => {
Piano.canDelete(false);
});

it('responds', async () => {
const [res, resData] = await client({
method: 'DELETE',
path: `${BASE_PATH}/pianos/${existingResource.id}`,
});

expect(res).toHaveProperty('statusCode', constants.HTTP_STATUS_NO_CONTENT);
expect(res.headers).not.toHaveProperty('content-type');
expect(resData).toBeUndefined();
});

it('returns options', async () => {
const [res] = await client({
method: 'OPTIONS',
path: `${BASE_PATH}/pianos/${existingResource.id}`,
});

expect(res).toHaveProperty('statusCode', constants.HTTP_STATUS_NO_CONTENT);
const allowedMethods = res.headers.allow?.split(',').map((s) => s.trim()) ?? [];
expect(allowedMethods).toContain('DELETE');
});
});
});

+ 764
- 0
test/e2e/http/error-handling.test.ts View File

@@ -0,0 +1,764 @@
import {
beforeAll,
afterAll,
afterEach,
beforeEach,
describe,
expect,
it, vi,
} from 'vitest';
import {
tmpdir
} from 'os';
import {
mkdtemp,
rm,
writeFile,
} from 'fs/promises';
import {
join
} from 'path';
import {request} from 'http';
import {constants} from 'http2';
import {Backend, dataSources} from '../../../src/backend';
import { application, resource, validation as v, Resource } from '../../../src/common';
import { autoIncrement } from '../../fixtures';
import {createTestClient, TestClient} from '../../utils';
import {DataSource} from '../../../src/backend/data-source';

const PORT = 3001;
const HOST = '127.0.0.1';
const BASE_PATH = '/api';
const ACCEPT = 'application/json';
const ACCEPT_LANGUAGE = 'en';
const ACCEPT_CHARSET = 'utf-8';
const CONTENT_TYPE_CHARSET = 'utf-8';
const CONTENT_TYPE = ACCEPT;

class DummyDataSource implements DataSource {
private resource?: { dataSource?: unknown };

create(): Promise<never> {
throw new Error();
}

delete(): Promise<never> {
throw new Error();
}

emplace(): Promise<never> {
throw new Error();
}

getById(): Promise<never> {
throw new Error();
}

newId(): Promise<never> {
throw new Error();
}

getMultiple(): Promise<never> {
throw new Error();
}

getSingle(): Promise<never> {
throw new Error();
}

getTotalCount(): Promise<never> {
throw new Error();
}

async initialize(): Promise<void> {}

patch(): Promise<never> {
throw new Error();
}

prepareResource(rr: unknown) {
this.resource = rr as unknown as { dataSource: DummyDataSource };
this.resource.dataSource = this;
}
}

describe('error handling', () => {
let client: TestClient;
beforeEach(() => {
client = createTestClient({
host: HOST,
port: PORT,
})
.acceptMediaType(ACCEPT)
.acceptLanguage(ACCEPT_LANGUAGE)
.acceptCharset(ACCEPT_CHARSET)
.contentType(CONTENT_TYPE)
.contentCharset(CONTENT_TYPE_CHARSET);
});

describe('on internal errors', () => {
let baseDir: string;
beforeAll(async () => {
try {
baseDir = await mkdtemp(join(tmpdir(), 'yasumi-'));
} catch {
// noop
}
});
afterAll(async () => {
try {
await rm(baseDir, {
recursive: true,
});
} catch {
// noop
}
});

let Piano: Resource;
beforeEach(() => {
Piano = resource(v.object(
{
brand: v.string()
},
v.never()
))
.name('Piano' as const)
.route('pianos' as const)
.id('id' as const, {
generationStrategy: autoIncrement,
serialize: (id) => id?.toString() ?? '0',
deserialize: (id) => Number.isFinite(Number(id)) ? Number(id) : 0,
schema: v.number(),
});
});

let dataSource: DataSource;
let backend: Backend;
let server: ReturnType<Backend['createHttpServer']>;
beforeEach(() => {
const app = application({
name: 'piano-service',
})
.resource(Piano);

dataSource = new DummyDataSource();

backend = app.createBackend({
dataSource,
});

server = backend.createHttpServer({
basePath: BASE_PATH
});

return new Promise((resolve, reject) => {
server.on('error', (err) => {
reject(err);
});

server.on('listening', () => {
resolve();
});

server.listen({
port: PORT
});
});
});

afterEach(() => new Promise((resolve, reject) => {
server.close((err) => {
if (err) {
reject(err);
}

resolve();
});
}));

describe.skip('serving collections', () => {
beforeEach(() => {
Piano.canFetchCollection();
return new Promise((resolve) => {
setTimeout(() => {
resolve();
});
});
});

afterEach(() => {
Piano.canFetchCollection(false);
});

it('throws on query', async () => {
const [res] = await client({
method: 'GET',
path: `${BASE_PATH}/pianos`,
});

expect(res).toHaveProperty('statusCode', constants.HTTP_STATUS_INTERNAL_SERVER_ERROR);
expect(res).toHaveProperty('statusMessage', 'Unable To Fetch Piano Collection');
});

it('throws on HEAD method', async () => {
const [res] = await client({
method: 'HEAD',
path: `${BASE_PATH}/pianos`,
});

expect(res).toHaveProperty('statusCode', constants.HTTP_STATUS_INTERNAL_SERVER_ERROR);
expect(res).toHaveProperty('statusMessage', 'Unable To Fetch Piano Collection');
});
});

describe('serving items', () => {
const data = {
id: 1,
brand: 'Yamaha'
};

beforeEach(async () => {
const resourcePath = join(baseDir, 'pianos.jsonl');
await writeFile(resourcePath, JSON.stringify(data));
});

beforeEach(() => {
Piano.canFetchItem();
return new Promise((resolve) => {
setTimeout(() => {
resolve();
});
});
});

afterEach(() => {
Piano.canFetchItem(false);
});

it('throws on query', async () => {
const [res] = await client({
method: 'GET',
path: `${BASE_PATH}/pianos/2`,
});

expect(res).toHaveProperty('statusCode', constants.HTTP_STATUS_INTERNAL_SERVER_ERROR);
expect(res).toHaveProperty('statusMessage', 'Unable To Fetch Piano');
});

it('throws on HEAD method', async () => {
const [res] = await client({
method: 'HEAD',
path: `${BASE_PATH}/pianos/2`,
});

expect(res).toHaveProperty('statusCode', constants.HTTP_STATUS_INTERNAL_SERVER_ERROR);
expect(res).toHaveProperty('statusMessage', 'Unable To Fetch Piano');
});

it('throws on item not found', async () => {
const getById = vi.spyOn(DummyDataSource.prototype, 'getById');
getById.mockResolvedValueOnce(null as never);

const [res] = await client({
method: 'GET',
path: `${BASE_PATH}/pianos/2`,
});

expect(res).toHaveProperty('statusCode', constants.HTTP_STATUS_NOT_FOUND);
});

it('throws on item not found on HEAD method', async () => {
const getById = vi.spyOn(DummyDataSource.prototype, 'getById');
getById.mockResolvedValueOnce(null as never);

const [res] = await client({
method: 'HEAD',
path: `${BASE_PATH}/pianos/2`,
});

expect(res).toHaveProperty('statusCode', constants.HTTP_STATUS_NOT_FOUND);
});
});

describe('creating items', () => {
const data = {
id: 1,
brand: 'Yamaha'
};

const newData = {
brand: 'K. Kawai'
};

beforeEach(async () => {
const resourcePath = join(baseDir, 'pianos.jsonl');
await writeFile(resourcePath, JSON.stringify(data));
});

beforeEach(() => {
Piano.canCreate();
});

afterEach(() => {
Piano.canCreate(false);
});

it('throws on error assigning ID', async () => {
const [res] = await client({
method: 'POST',
path: `${BASE_PATH}/pianos`,
body: newData,
});

expect(res).toHaveProperty('statusCode', constants.HTTP_STATUS_INTERNAL_SERVER_ERROR);
expect(res).toHaveProperty('statusMessage', 'Unable To Assign ID From Piano Data Source');
});

it('throws on error creating resource', async () => {
const getById = vi.spyOn(DummyDataSource.prototype, 'newId');
getById.mockResolvedValueOnce(data.id as never);

const [res] = await client({
method: 'POST',
path: `${BASE_PATH}/pianos`,
body: newData,
});

expect(res).toHaveProperty('statusCode', constants.HTTP_STATUS_INTERNAL_SERVER_ERROR);
expect(res).toHaveProperty('statusMessage', 'Unable To Create Piano');
});
});

describe.skip('patching items', () => {
const data = {
id: 1,
brand: 'Yamaha'
};

const newData = {
brand: 'K. Kawai'
};

beforeEach(async () => {
const resourcePath = join(baseDir, 'pianos.jsonl');
await writeFile(resourcePath, JSON.stringify(data));
});

beforeEach(() => {
Piano.canPatch();
});

afterEach(() => {
Piano.canPatch(false);
});

it('throws on item to patch not found', () => {
return new Promise<void>((resolve, reject) => {
const req = request(
{
host: HOST,
port: PORT,
path: `${BASE_PATH}/pianos/2`,
method: 'PATCH',
headers: {
'Accept': ACCEPT,
'Accept-Language': ACCEPT_LANGUAGE,
'Content-Type': `${CONTENT_TYPE}; charset="${CONTENT_TYPE_CHARSET}"`,
},
},
(res) => {
res.on('error', (err) => {
reject(err);
});

expect(res).toHaveProperty('statusCode', constants.HTTP_STATUS_NOT_FOUND);
resolve();
},
);

req.on('error', (err) => {
reject(err);
});

req.write(JSON.stringify(newData));
req.end();
});
});
});

describe.skip('emplacing items', () => {
const data = {
id: 1,
brand: 'Yamaha'
};

const newData = {
id: 1,
brand: 'K. Kawai'
};

beforeEach(async () => {
const resourcePath = join(baseDir, 'pianos.jsonl');
await writeFile(resourcePath, JSON.stringify(data));
});

beforeEach(() => {
Piano.canEmplace();
});

afterEach(() => {
Piano.canEmplace(false);
});
});

describe.skip('deleting items', () => {
const data = {
id: 1,
brand: 'Yamaha'
};

beforeEach(async () => {
const resourcePath = join(baseDir, 'pianos.jsonl');
await writeFile(resourcePath, JSON.stringify(data));
});

beforeEach(() => {
Piano.canDelete();
backend.throwsErrorOnDeletingNotFound();
});

afterEach(() => {
Piano.canDelete(false);
backend.throwsErrorOnDeletingNotFound(false);
});

it('throws on item not found', () => {
return new Promise<void>((resolve, reject) => {
const req = request(
{
host: HOST,
port: PORT,
path: `${BASE_PATH}/pianos/2`,
method: 'DELETE',
headers: {
'Accept': ACCEPT,
'Accept-Language': ACCEPT_LANGUAGE,
},
},
(res) => {
res.on('error', (err) => {
reject(err);
});

expect(res).toHaveProperty('statusCode', constants.HTTP_STATUS_NOT_FOUND);
resolve();
},
);

req.on('error', (err) => {
reject(err);
});

req.end();
});
});
});
});

describe('on data source errors', () => {
let baseDir: string;
beforeAll(async () => {
try {
baseDir = await mkdtemp(join(tmpdir(), 'yasumi-'));
} catch {
// noop
}
});
afterAll(async () => {
try {
await rm(baseDir, {
recursive: true,
});
} catch {
// noop
}
});

let Piano: Resource;
beforeEach(() => {
Piano = resource(v.object(
{
brand: v.string()
},
v.never()
))
.name('Piano' as const)
.route('pianos' as const)
.id('id' as const, {
generationStrategy: autoIncrement,
serialize: (id) => id?.toString() ?? '0',
deserialize: (id) => Number.isFinite(Number(id)) ? Number(id) : 0,
schema: v.number(),
});
});

let backend: Backend;
let server: ReturnType<Backend['createHttpServer']>;
beforeEach(() => {
const app = application({
name: 'piano-service',
})
.resource(Piano);

backend = app.createBackend({
dataSource: new dataSources.jsonlFile.DataSource(baseDir),
});

server = backend.createHttpServer({
basePath: BASE_PATH
});

return new Promise((resolve, reject) => {
server.on('error', (err) => {
reject(err);
});

server.on('listening', () => {
resolve();
});

server.listen({
port: PORT
});
});
});

afterEach(() => new Promise((resolve, reject) => {
server.close((err) => {
if (err) {
reject(err);
}

resolve();
});
}));

describe.skip('serving collections', () => {
beforeEach(() => {
Piano.canFetchCollection();
return new Promise((resolve) => {
setTimeout(() => {
resolve();
});
});
});

afterEach(() => {
Piano.canFetchCollection(false);
});
});

describe('serving items', () => {
const data = {
id: 1,
brand: 'Yamaha'
};

beforeEach(async () => {
const resourcePath = join(baseDir, 'pianos.jsonl');
await writeFile(resourcePath, JSON.stringify(data));
});

beforeEach(() => {
Piano.canFetchItem();
return new Promise((resolve) => {
setTimeout(() => {
resolve();
});
});
});

afterEach(() => {
Piano.canFetchItem(false);
});

it('throws on item not found', async () => {
const [res] = await client({
method: 'GET',
path: `${BASE_PATH}/pianos/2`,
});

expect(res).toHaveProperty('statusCode', constants.HTTP_STATUS_NOT_FOUND);
});

it('throws on item not found on HEAD method', async () => {
const [res] = await client({
method: 'HEAD',
path: `${BASE_PATH}/pianos/2`,
});

expect(res).toHaveProperty('statusCode', constants.HTTP_STATUS_NOT_FOUND);
});
});

describe.skip('creating items', () => {
const data = {
id: 1,
brand: 'Yamaha'
};

const newData = {
brand: 'K. Kawai'
};

beforeEach(async () => {
const resourcePath = join(baseDir, 'pianos.jsonl');
await writeFile(resourcePath, JSON.stringify(data));
});

beforeEach(() => {
Piano.canCreate();
});

afterEach(() => {
Piano.canCreate(false);
});
});

describe.skip('patching items', () => {
const data = {
id: 1,
brand: 'Yamaha'
};

const newData = {
brand: 'K. Kawai'
};

beforeEach(async () => {
const resourcePath = join(baseDir, 'pianos.jsonl');
await writeFile(resourcePath, JSON.stringify(data));
});

beforeEach(() => {
Piano.canPatch();
});

afterEach(() => {
Piano.canPatch(false);
});

it('throws on item to patch not found', () => {
return new Promise<void>((resolve, reject) => {
const req = request(
{
host: HOST,
port: PORT,
path: `${BASE_PATH}/pianos/2`,
method: 'PATCH',
headers: {
'Accept': ACCEPT,
'Accept-Language': ACCEPT_LANGUAGE,
'Content-Type': `${CONTENT_TYPE}; charset="${CONTENT_TYPE_CHARSET}"`,
},
},
(res) => {
res.on('error', (err) => {
reject(err);
});

expect(res).toHaveProperty('statusCode', constants.HTTP_STATUS_NOT_FOUND);
resolve();
},
);

req.on('error', (err) => {
reject(err);
});

req.write(JSON.stringify(newData));
req.end();
});
});
});

describe.skip('emplacing items', () => {
const data = {
id: 1,
brand: 'Yamaha'
};

const newData = {
id: 1,
brand: 'K. Kawai'
};

beforeEach(async () => {
const resourcePath = join(baseDir, 'pianos.jsonl');
await writeFile(resourcePath, JSON.stringify(data));
});

beforeEach(() => {
Piano.canEmplace();
});

afterEach(() => {
Piano.canEmplace(false);
});
});

describe.skip('deleting items', () => {
const data = {
id: 1,
brand: 'Yamaha'
};

beforeEach(async () => {
const resourcePath = join(baseDir, 'pianos.jsonl');
await writeFile(resourcePath, JSON.stringify(data));
});

beforeEach(() => {
Piano.canDelete();
backend.throwsErrorOnDeletingNotFound();
});

afterEach(() => {
Piano.canDelete(false);
backend.throwsErrorOnDeletingNotFound(false);
});

it('throws on item not found', () => {
return new Promise<void>((resolve, reject) => {
const req = request(
{
host: HOST,
port: PORT,
path: `${BASE_PATH}/pianos/2`,
method: 'DELETE',
headers: {
'Accept': ACCEPT,
'Accept-Language': ACCEPT_LANGUAGE,
},
},
(res) => {
res.on('error', (err) => {
reject(err);
});

expect(res).toHaveProperty('statusCode', constants.HTTP_STATUS_NOT_FOUND);
resolve();
},
);

req.on('error', (err) => {
reject(err);
});

req.end();
});
});
});
});
});

+ 143
- 0
test/utils.ts View File

@@ -0,0 +1,143 @@
import {IncomingMessage, OutgoingHttpHeaders, request, RequestOptions} from 'http';
import {Method} from '../src/backend/common';

interface ClientParams {
method: Method;
path: string;
headers?: OutgoingHttpHeaders;
body?: unknown;
}

type ResponseBody = Buffer | string | object;

export interface TestClient {
(params: ClientParams): Promise<[IncomingMessage, ResponseBody?]>;
acceptMediaType(mediaType: string): this;
acceptLanguage(language: string): this;
acceptCharset(charset: string): this;
contentType(mediaType: string): this;
contentCharset(charset: string): this;
}

export const createTestClient = (options: Omit<RequestOptions, 'method' | 'path'>): TestClient => {
const additionalHeaders: OutgoingHttpHeaders = {};
const client = (params: ClientParams) => new Promise<[IncomingMessage, ResponseBody?]>((resolve, reject) => {
const {
'content-type': contentTypeHeader,
...etcAdditionalHeaders
} = additionalHeaders;

const headers: OutgoingHttpHeaders = {
...(options.headers ?? {}),
...(params.headers ?? {}),
...etcAdditionalHeaders,
};

if (typeof params.body !== 'undefined') {
headers['content-type'] = contentTypeHeader;
}

const req = request({
...options,
method: params.method,
path: params.path,
headers,
});

req.on('response', (res) => {
res.on('error', (err) => {
reject(err);
});

let resBuffer: Buffer | undefined;
res.on('data', (c) => {
resBuffer = (
typeof resBuffer === 'undefined'
? Buffer.from(c)
: Buffer.concat([resBuffer, c])
);
});

res.on('close', () => {
const acceptHeader = Array.isArray(headers['accept']) ? headers['accept'].join('; ') : headers['accept'];
const contentTypeBase = acceptHeader ?? 'application/octet-stream';
const [type, subtype] = contentTypeBase.split('/');
const allSubtypes = subtype.split('+');
if (typeof resBuffer !== 'undefined') {
if (allSubtypes.includes('json')) {
const acceptCharset = (
Array.isArray(headers['accept-charset'])
? headers['accept-charset'].join('; ')
: headers['accept-charset']
) as BufferEncoding | undefined;
resolve([res, JSON.parse(resBuffer.toString(acceptCharset ?? 'utf-8'))]);
return;
}

if (type === 'text') {
const acceptCharset = (
Array.isArray(headers['accept-charset'])
? headers['accept-charset'].join('; ')
: headers['accept-charset']
) as BufferEncoding | undefined;
resolve([res, resBuffer.toString(acceptCharset ?? 'utf-8')]);
return;
}

resolve([res, resBuffer]);
return;
}

resolve([res]);
});
});

req.on('error', (err) => {
reject(err);
})

if (typeof params.body !== 'undefined') {
const theContentTypeHeader = Array.isArray(contentTypeHeader) ? contentTypeHeader.join('; ') : contentTypeHeader?.toString();
const contentTypeAll = theContentTypeHeader ?? 'application/octet-stream';
const [contentTypeBase, ...contentTypeParams] = contentTypeAll.split(';').map((s) => s.replace(/\s+/g, '').trim());
const charsetParam = contentTypeParams.find((s) => s.startsWith('charset='));
const charset = charsetParam?.split('=')?.[1] as BufferEncoding | undefined;
const [, subtype] = contentTypeBase.split('/');
const allSubtypes = subtype.split('+');
req.write(
allSubtypes.includes('json')
? JSON.stringify(params.body)
: Buffer.from(params.body?.toString() ?? '', contentTypeBase === 'text' ? charset : undefined)
);
}

req.end();
});

client.acceptMediaType = function acceptMediaType(mediaType: string) {
additionalHeaders['accept'] = mediaType;
return this;
};

client.acceptLanguage = function acceptLanguage(language: string) {
additionalHeaders['accept-language'] = language;
return this;
};

client.acceptCharset = function acceptCharset(charset: string) {
additionalHeaders['accept-charset'] = charset;
return this;
};

client.contentType = function contentType(mediaType: string) {
additionalHeaders['content-type'] = mediaType;
return this;
};

client.contentCharset = function contentCharset(charset: string) {
additionalHeaders['content-type'] = `${additionalHeaders['content-type']}; charset="${charset}"`;
return this;
};

return client;
};

Loading…
Cancel
Save