Browse Source

Update tests

Decouple tests from data source.
master
TheoryOfNekomata 4 weeks ago
parent
commit
467021b57a
12 changed files with 437 additions and 491 deletions
  1. +16
    -0
      README.md
  2. +53
    -11
      src/backend/servers/http/core.ts
  3. +20
    -4
      src/backend/servers/http/handlers/default.ts
  4. +87
    -15
      src/backend/servers/http/handlers/resource.ts
  5. +3
    -1
      src/backend/servers/http/utils.ts
  6. +11
    -1
      src/common/app.ts
  7. +2
    -0
      src/common/language.ts
  8. +7
    -0
      src/common/media-type.ts
  9. +42
    -7
      src/common/resource.ts
  10. +117
    -86
      test/e2e/http/default.test.ts
  11. +25
    -361
      test/e2e/http/error-handling.test.ts
  12. +54
    -5
      test/utils.ts

+ 16
- 0
README.md View File

@@ -47,3 +47,19 @@ See [docs folder](./docs) for more details.
- RFC 9288 - Web Linking

https://httpwg.org/specs/rfc8288.html

- JSON Patch and JSON Merge Patch

https://erosb.github.io/post/json-patch-vs-merge-patch/

- Patch vs merge-patch which is appropriate?

https://stackoverflow.com/questions/56030328/patch-vs-merge-patch-which-is-appropriate

- PATCH Method for HTTP

https://www.rfc-editor.org/rfc/rfc5789

- JavaScript Object Notation (JSON) Patch

https://www.rfc-editor.org/rfc/rfc6902

+ 53
- 11
src/backend/servers/http/core.ts View File

@@ -9,7 +9,7 @@ import {
RequestContext, RequestDecorator,
Response,
} from '../../common';
import {Resource} from '../../../common';
import {CanPatchSpec, Resource} from '../../../common';
import {
handleGetRoot, handleOptions,
} from './handlers/default';
@@ -78,15 +78,57 @@ const constructPutSchema = <T extends v.BaseSchema>(resource: Resource<T>, mainR

const constructPatchSchema = <T extends v.BaseSchema>(resource: Resource<T>) => {
const schema = resource.schema.type === 'object' ? resource.schema as unknown as v.ObjectSchema<any> : resource.schema;
return (
schema.type === 'object'
? v.partial(
schema as v.ObjectSchema<any>,
(schema as v.ObjectSchema<any>).rest,
(schema as v.ObjectSchema<any>).pipe
)
: schema
);

if (schema.type !== 'object') {
return schema;
}

const schemaChoices = {
merge: v.partial(
schema as v.ObjectSchema<any>,
(schema as v.ObjectSchema<any>).rest,
(schema as v.ObjectSchema<any>).pipe
),
delta: v.array(
v.union([
v.object({
op: v.literal('add'),
path: v.string(), // todo validate if valid path?
value: v.any() // todo validate if valid value?
}),
v.object({
op: v.literal('remove'),
path: v.string(),
}),
v.object({
op: v.literal('replace'),
path: v.string(), // todo validate if valid path?
value: v.any() // todo validate if valid value?
}),
v.object({
op: v.literal('move'),
path: v.string(),
from: v.string(),
}),
v.object({
op: v.literal('copy'),
path: v.string(),
from: v.string(),
}),
v.object({
op: v.literal('test'),
path: v.string(), // todo validate if valid path?
value: v.any() // todo validate if valid value?
}),
])
),
}

const selectedSchemaChoices = Object.entries(schemaChoices)
.filter(([key]) => resource.state.canPatch[key as CanPatchSpec])
.map(([, value]) => value);

return v.union(selectedSchemaChoices);
};
// TODO add a way to define custom middlewares
const defaultCollectionMiddlewares: AllowedMiddlewareSpecification[] = [
@@ -209,7 +251,7 @@ export const createServer = (backendState: BackendState, serverParams = {} as Cr
)
? charsetRaw.slice(1, -1).trim()
: charsetRaw.trim()
)
) ?? (isTextMediaType(mediaType) ? 'utf-8' : 'binary')
const theBodyBuffer = await getBody(req);
const encodingPair = req.backend.app.charsets.get(charset);
if (typeof encodingPair === 'undefined') {


+ 20
- 4
src/backend/servers/http/handlers/default.ts View File

@@ -2,6 +2,7 @@ import {constants} from 'http2';
import {AllowedMiddlewareSpecification, Middleware} from '../../../common';
import {LinkMap} from '../utils';
import {PlainResponse, ErrorPlainResponse} from '../response';
import {PATCH_CONTENT_MAP_TYPE} from '../../../../common';

export const handleGetRoot: Middleware = (req, res) => {
const { backend, basePath } = req;
@@ -40,12 +41,27 @@ export const handleGetRoot: Middleware = (req, res) => {
});
};

export const handleOptions = (middlewares: AllowedMiddlewareSpecification[]): Middleware => (_req, res) => {
export const handleOptions = (middlewares: AllowedMiddlewareSpecification[]): Middleware => (req, res) => {
if (middlewares.length > 0) {
const allowedMethods = middlewares.flatMap((m) => m.method === 'GET' ? [m.method, 'HEAD'] : [m.method]);
const headers: Record<string, string> = {
'Allow': allowedMethods.join(', '),
};
if (allowedMethods.includes('PATCH')) {
const validPatchTypes = Object.entries(req.resource.state.canPatch)
.filter(([, allowed]) => allowed)
.map(([patchType]) => patchType);

const validPatchContentTypes = Object.entries(PATCH_CONTENT_MAP_TYPE)
.filter(([, patchType]) => validPatchTypes.includes(patchType))
.map(([contentType ]) => contentType);

if (validPatchContentTypes.length > 0) {
headers['Accept-Patch'] = validPatchContentTypes.join(', ');
}
}
return new PlainResponse({
headers: {
'Allow': middlewares.flatMap((m) => m.method === 'GET' ? [m.method, 'HEAD'] : [m.method]).join(', '),
},
headers,
statusMessage: 'provideOptions',
statusCode: constants.HTTP_STATUS_NO_CONTENT,
res,


+ 87
- 15
src/backend/servers/http/handlers/resource.ts View File

@@ -3,6 +3,12 @@ import * as v from 'valibot';
import {Middleware} from '../../../common';
import {ErrorPlainResponse, PlainResponse} from '../response';
import assert from 'assert';
import {
CanPatchSpec,
PATCH_CONTENT_MAP_TYPE,
PATCH_CONTENT_TYPES,
PatchContentType,
} from '../../../../common';

export const handleGetCollection: Middleware = async (req, res) => {
const { query, resource, backend } = req;
@@ -46,6 +52,7 @@ const isResourceIdDefined = (resourceId?: string): resourceId is string => !(

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

assert(
isResourceIdDefined(resourceId),
new ErrorPlainResponse(
@@ -92,15 +99,16 @@ export const handleGetItem: Middleware = async (req, res) => {
export const handleDeleteItem: Middleware = async (req, res) => {
const { resource, resourceId, backend } = 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 existing: unknown | null;
try {
@@ -122,6 +130,7 @@ export const handleDeleteItem: Middleware = async (req, res) => {

try {
if (existing) {
// TODO should we still deal with the delete return?
await resource.dataSource.delete(resourceId);
}
} catch (cause) {
@@ -139,19 +148,71 @@ export const handleDeleteItem: Middleware = async (req, res) => {
});
};

const isValidPatch = (s: string): s is PatchContentType => {
return PATCH_CONTENT_TYPES.includes(s as PatchContentType);
};

export const handlePatchItem: Middleware = async (req, res) => {
const { resource, resourceId, body } = req;
const {
resource,
resourceId,
body,
headers,
} = 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,
}
);
)
);

const validPatchTypes = Object.entries(resource.state.canPatch)
.filter(([, allowed]) => allowed)
.map(([patchType]) => patchType);

const validPatchContentTypes = Object.entries(PATCH_CONTENT_MAP_TYPE)
.filter(([ patchType]) => validPatchTypes.includes(patchType))
.map(([contentType ]) => contentType);

const requestContentType = headers['content-type'];
if (typeof requestContentType !== 'string') {
throw new ErrorPlainResponse('invalidResourcePatchType', {
statusCode: constants.HTTP_STATUS_BAD_REQUEST,
res,
headers: {
'Accept-Patch': validPatchContentTypes.join(', '),
},
});
}

const [patchMimeTypeAll, ...patchParams] = requestContentType.replace(/\s+/g, '').split(';') as [PatchContentType, ...string[]];
assert(isValidPatch(patchMimeTypeAll), new ErrorPlainResponse('invalidResourcePatchType', {
statusCode: constants.HTTP_STATUS_BAD_REQUEST,
res,
headers: {
'Accept-Patch': validPatchContentTypes.join(', '),
},
}));

const isPatchEnabled = resource.state.canPatch[PATCH_CONTENT_MAP_TYPE[patchMimeTypeAll]];
if (!isPatchEnabled) {
throw new ErrorPlainResponse('invalidResourcePatchType', {
statusCode: constants.HTTP_STATUS_BAD_REQUEST,
res,
headers: {
'Accept-Patch': validPatchContentTypes.join(', '),
},
});
}

const charsetParam = (patchParams.find((s) => s.startsWith('charset=')) ?? 'charset=utf-8');
const [, charset] = charsetParam.split('=') as [never, BufferEncoding];

let existing: unknown | null;
try {
existing = await resource.dataSource.getById(resourceId!);
@@ -269,21 +330,32 @@ export const handleCreateItem: Middleware = async (req, res) => {
export const handleEmplaceItem: Middleware = async (req, res) => {
const { resource, resourceId, basePath, body, backend } = req;

const idAttrRaw = resource.state.shared.get('idAttr');
if (typeof idAttrRaw === 'undefined') {
throw new ErrorPlainResponse('unableToGenerateIdFromResourceDataSource', {
assert(
isResourceIdDefined(resourceId),
new ErrorPlainResponse(
'resourceIdNotGiven',
{
statusCode: constants.HTTP_STATUS_BAD_REQUEST,
res,
}
)
);

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 newObject: v.Output<typeof resource.schema>;
let isCreated: boolean;
try {
const params = { ...body as Record<string, unknown> };
params[idAttr] = resourceId;
[newObject, isCreated] = await resource.dataSource.emplace(resourceId!, params);
[newObject, isCreated] = await resource.dataSource.emplace(resourceId, params);
} catch (cause) {
throw new ErrorPlainResponse('unableToEmplaceResource', {
cause,


+ 3
- 1
src/backend/servers/http/utils.ts View File

@@ -1,10 +1,12 @@
import {IncomingMessage} from 'http';
import {PATCH_CONTENT_TYPES} from '../../../common';

export const isTextMediaType = (mediaType: string) => (
mediaType.startsWith('text/')
|| [
'application/json',
'application/xml'
'application/xml',
...PATCH_CONTENT_TYPES,
].includes(mediaType)
);



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

@@ -1,6 +1,6 @@
import {Resource} from './resource';
import {FALLBACK_LANGUAGE, Language} from './language';
import {FALLBACK_MEDIA_TYPE, MediaType} from './media-type';
import {FALLBACK_MEDIA_TYPE, MediaType, PATCH_CONTENT_TYPES} from './media-type';
import {Charset, FALLBACK_CHARSET} from './charset';
import * as v from 'valibot';
import {Backend, createBackend, CreateBackendParams} from '../backend';
@@ -55,6 +55,16 @@ export const application = (appParams: ApplicationParams): Application => {
]),
mediaTypes: new Map<MediaType['name'], MediaType>([
[FALLBACK_MEDIA_TYPE.name, FALLBACK_MEDIA_TYPE],
...(
PATCH_CONTENT_TYPES.map((name) => [
name as MediaType['name'],
{
serialize: (s: unknown) => JSON.stringify(s),
deserialize: (s: string) => JSON.parse(s),
name: name as MediaType['name'],
} satisfies MediaType
] as [MediaType['name'], MediaType])
),
]),
charsets: new Map<Charset['name'], Charset>([
[FALLBACK_CHARSET.name, FALLBACK_CHARSET],


+ 2
- 0
src/common/language.ts View File

@@ -31,6 +31,7 @@ export const LANGUAGE_DEFAULT_STATUS_MESSAGE_KEYS = [
'patchNonExistingResource',
'unableToPatchResource',
'invalidResourcePatch',
'invalidResourcePatchType',
'invalidResource',
'resourcePatched',
'resourceCreated',
@@ -89,6 +90,7 @@ export const FALLBACK_LANGUAGE = {
patchNonExistingResource: 'Patch Non-Existing $RESOURCE',
unableToPatchResource: 'Unable To Patch $RESOURCE',
invalidResourcePatch: 'Invalid $RESOURCE Patch',
invalidResourcePatchType: 'Invalid $RESOURCE Patch Type',
invalidResource: 'Invalid $RESOURCE',
resourcePatched: '$RESOURCE Patched',
resourceCreated: '$RESOURCE Created',


+ 7
- 0
src/common/media-type.ts View File

@@ -9,3 +9,10 @@ export const FALLBACK_MEDIA_TYPE = {
deserialize: (str: string) => JSON.parse(str),
name: 'application/json' as const,
} satisfies MediaType;

export const PATCH_CONTENT_TYPES = [
'application/merge-patch+json',
'application/json-patch+json',
] as const;

export type PatchContentType = typeof PATCH_CONTENT_TYPES[number];

+ 42
- 7
src/common/resource.ts View File

@@ -1,5 +1,16 @@
import * as v from 'valibot';
import {BaseSchema} from 'valibot';
import {PatchContentType} from './media-type';

export const CAN_PATCH_VALID_VALUES = ['merge', 'delta'] as const;

export type CanPatchSpec = typeof CAN_PATCH_VALID_VALUES[number];

export const PATCH_CONTENT_MAP_TYPE: Record<PatchContentType, CanPatchSpec> = {
'application/merge-patch+json': 'merge',
'application/json-patch+json': 'delta',
};

type CanPatchObject = Record<CanPatchSpec, boolean>;

export interface ResourceState<
ItemName extends string = string,
@@ -12,11 +23,13 @@ export interface ResourceState<
canCreate: boolean;
canFetchCollection: boolean;
canFetchItem: boolean;
canPatch: boolean;
canPatch: CanPatchObject;
canEmplace: boolean;
canDelete: boolean;
}

type CanPatch = boolean | CanPatchObject | CanPatchSpec[];

export interface Resource<
Schema extends v.BaseSchema = v.BaseSchema,
CurrentName extends string = string,
@@ -29,7 +42,7 @@ export interface Resource<
canFetchCollection(b?: boolean): this;
canFetchItem(b?: boolean): this;
canCreate(b?: boolean): this;
canPatch(b?: boolean): this;
canPatch(b?: CanPatch): this;
canEmplace(b?: boolean): this;
canDelete(b?: boolean): this;
relatedTo<RelatedSchema extends v.BaseSchema>(resource: Resource<RelatedSchema>): this;
@@ -46,7 +59,10 @@ export const resource = <
canCreate: false,
canFetchCollection: false,
canFetchItem: false,
canPatch: false,
canPatch: {
merge: false,
delta: false,
},
canEmplace: false,
canDelete: false,
} as ResourceState<CurrentName, CurrentRouteName>;
@@ -69,8 +85,27 @@ export const resource = <
resourceState.canCreate = b;
return this;
},
canPatch(b = true) {
resourceState.canPatch = b;
canPatch(b = true as CanPatch) {
if (typeof b === 'boolean') {
resourceState.canPatch.merge = b;
resourceState.canPatch.delta = b;
return this;
}

if (typeof b === 'object') {
if (Array.isArray(b)) {
CAN_PATCH_VALID_VALUES.forEach((p) => {
resourceState.canPatch[p] = b.includes(p);
});
return this;
}
if (b !== null) {
CAN_PATCH_VALID_VALUES.forEach((p) => {
resourceState.canPatch[p] = b[p];
});
}
}

return this;
},
canEmplace(b = true) {
@@ -109,7 +144,7 @@ export const resource = <
get schema() {
return schema;
},
relatedTo<RelatedSchema extends BaseSchema>(resource: Resource<RelatedSchema>) {
relatedTo<RelatedSchema extends v.BaseSchema>(resource: Resource<RelatedSchema>) {
resourceState.relationships.add(resource);
return this;
},


+ 117
- 86
test/e2e/http/default.test.ts View File

@@ -6,23 +6,14 @@ import {
describe,
expect,
it,
vi,
} 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 {Backend} from '../../../src/backend';
import {application, resource, validation as v, Resource, Application} from '../../../src/common';
import { autoIncrement } from '../../fixtures';
import {createTestClient, TestClient} from '../../utils';
import {createTestClient, DummyDataSource, TestClient} from '../../utils';
import {DataSource} from '../../../src/backend/data-source';

const PORT = 3000;
const HOST = '127.0.0.1';
@@ -33,40 +24,17 @@ 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 portCounter = 0;

describe.only('happy path', () => {
let Piano: Resource;
beforeEach(() => {
let app: Application;
let dataSource: DataSource;
let backend: Backend;
let server: ReturnType<Backend['createHttpServer']>;
let client: TestClient;

beforeAll(() => {
Piano = resource(v.object(
{
brand: v.string()
@@ -81,24 +49,32 @@ describe('happy path', () => {
deserialize: (id) => Number.isFinite(Number(id)) ? Number(id) : 0,
schema: v.number(),
});
});

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

dataSource = new DummyDataSource();

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

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

client = createTestClient({
host: HOST,
port: PORT + portCounter,
})
.acceptMediaType(ACCEPT)
.acceptLanguage(ACCEPT_LANGUAGE)
.acceptCharset(ACCEPT_CHARSET)
.contentType(CONTENT_TYPE)
.contentCharset(CONTENT_TYPE_CHARSET);

return new Promise((resolve, reject) => {
server.on('error', (err) => {
reject(err);
@@ -114,7 +90,7 @@ describe('happy path', () => {
});
});

afterEach(() => new Promise((resolve, reject) => {
afterAll(() => new Promise((resolve, reject) => {
server.close((err) => {
if (err) {
reject(err);
@@ -124,14 +100,19 @@ describe('happy path', () => {
});
}));

afterAll(() => {
portCounter = 0;
});

describe('serving collections', () => {
beforeEach(() => {
vi
.spyOn(DummyDataSource.prototype, 'getMultiple')
.mockResolvedValueOnce([] as never);
});

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

afterEach(() => {
@@ -145,7 +126,7 @@ describe('happy path', () => {
});

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

if (typeof resData === 'undefined') {
@@ -163,6 +144,7 @@ describe('happy path', () => {
});

expect(res).toHaveProperty('statusCode', constants.HTTP_STATUS_OK);
expect(res).toHaveProperty('statusMessage', 'Piano Collection Fetched');
});

it('returns options', async () => {
@@ -172,6 +154,7 @@ describe('happy path', () => {
});

expect(res).toHaveProperty('statusCode', constants.HTTP_STATUS_NO_CONTENT);
expect(res).toHaveProperty('statusMessage', 'Provide Options');
const allowedMethods = res.headers.allow?.split(',').map((s) => s.trim()) ?? [];
expect(allowedMethods).toContain('GET');
expect(allowedMethods).toContain('HEAD');
@@ -184,18 +167,14 @@ describe('happy path', () => {
brand: 'Yamaha'
};

beforeEach(async () => {
const resourcePath = join(baseDir, 'pianos.jsonl');
await writeFile(resourcePath, JSON.stringify(existingResource));
beforeEach(() => {
vi
.spyOn(DummyDataSource.prototype, 'getById')
.mockResolvedValueOnce(existingResource as never);
});

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

afterEach(() => {
@@ -209,6 +188,7 @@ describe('happy path', () => {
});

expect(res).toHaveProperty('statusCode', constants.HTTP_STATUS_OK);
expect(res).toHaveProperty('statusMessage', 'Piano Fetched');
expect(res.headers).toHaveProperty('content-type', expect.stringContaining(ACCEPT));
if (typeof resData === 'undefined') {
expect.fail('Response body must be defined.');
@@ -225,6 +205,7 @@ describe('happy path', () => {
});

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

it('returns options', async () => {
@@ -234,6 +215,7 @@ describe('happy path', () => {
});

expect(res).toHaveProperty('statusCode', constants.HTTP_STATUS_NO_CONTENT);
expect(res).toHaveProperty('statusMessage', 'Provide Options');
const allowedMethods = res.headers.allow?.split(',').map((s) => s.trim()) ?? [];
expect(allowedMethods).toContain('GET');
expect(allowedMethods).toContain('HEAD');
@@ -241,18 +223,25 @@ describe('happy path', () => {
});

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));
const responseData = {
id: 2,
...newResourceData,
};

beforeEach(() => {
vi
.spyOn(DummyDataSource.prototype, 'newId')
.mockResolvedValueOnce(responseData.id as never);
});

beforeEach(() => {
vi
.spyOn(DummyDataSource.prototype, 'create')
.mockResolvedValueOnce(responseData as never);
});

beforeEach(() => {
@@ -271,6 +260,7 @@ describe('happy path', () => {
});

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

@@ -292,6 +282,7 @@ describe('happy path', () => {
});

expect(res).toHaveProperty('statusCode', constants.HTTP_STATUS_NO_CONTENT);
expect(res).toHaveProperty('statusMessage', 'Provide Options');
const allowedMethods = res.headers.allow?.split(',').map((s) => s.trim()) ?? [];
expect(allowedMethods).toContain('POST');
});
@@ -307,9 +298,19 @@ describe('happy path', () => {
brand: 'K. Kawai'
};

beforeEach(async () => {
const resourcePath = join(baseDir, 'pianos.jsonl');
await writeFile(resourcePath, JSON.stringify(existingResource));
beforeEach(() => {
vi
.spyOn(DummyDataSource.prototype, 'getById')
.mockResolvedValueOnce(existingResource as never);
});

beforeEach(() => {
vi
.spyOn(DummyDataSource.prototype, 'patch')
.mockResolvedValueOnce({
...existingResource,
...patchData,
} as never);
});

beforeEach(() => {
@@ -325,9 +326,13 @@ describe('happy path', () => {
method: 'PATCH',
path: `${BASE_PATH}/pianos/${existingResource.id}`,
body: patchData,
headers: {
'content-type': 'application/merge-patch+json',
},
});

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

if (typeof resData === 'undefined') {
@@ -348,8 +353,12 @@ describe('happy path', () => {
});

expect(res).toHaveProperty('statusCode', constants.HTTP_STATUS_NO_CONTENT);
expect(res).toHaveProperty('statusMessage', 'Provide Options');
const allowedMethods = res.headers.allow?.split(',').map((s) => s.trim()) ?? [];
expect(allowedMethods).toContain('PATCH');
const acceptPatch = res.headers['accept-patch']?.split(',').map((s) => s.trim()) ?? [];
expect(acceptPatch).toContain('application/json-patch+json');
expect(acceptPatch).toContain('application/merge-patch+json');
});
});

@@ -364,11 +373,6 @@ describe('happy path', () => {
brand: 'K. Kawai'
};

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

beforeEach(() => {
Piano.canEmplace();
});
@@ -378,6 +382,13 @@ describe('happy path', () => {
});

it('returns data for replacement', async () => {
vi
.spyOn(DummyDataSource.prototype, 'emplace')
.mockResolvedValueOnce([{
...existingResource,
...emplaceResourceData,
}, false] as never);

const [res, resData] = await client({
method: 'PUT',
path: `${BASE_PATH}/pianos/${emplaceResourceData.id}`,
@@ -385,6 +396,7 @@ describe('happy path', () => {
});

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

if (typeof resData === 'undefined') {
@@ -398,6 +410,14 @@ describe('happy path', () => {
it('returns data for creation', async () => {
const newId = 2;

vi
.spyOn(DummyDataSource.prototype, 'emplace')
.mockResolvedValueOnce([{
...existingResource,
...emplaceResourceData,
id: newId
}, true] as never);

const [res, resData] = await client({
method: 'PUT',
path: `${BASE_PATH}/pianos/${newId}`,
@@ -408,6 +428,7 @@ describe('happy path', () => {
});

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

@@ -429,6 +450,7 @@ describe('happy path', () => {
});

expect(res).toHaveProperty('statusCode', constants.HTTP_STATUS_NO_CONTENT);
expect(res).toHaveProperty('statusMessage', 'Provide Options');
const allowedMethods = res.headers.allow?.split(',').map((s) => s.trim()) ?? [];
expect(allowedMethods).toContain('PUT');
});
@@ -440,9 +462,16 @@ describe('happy path', () => {
brand: 'Yamaha'
};

beforeEach(async () => {
const resourcePath = join(baseDir, 'pianos.jsonl');
await writeFile(resourcePath, JSON.stringify(existingResource));
beforeEach(() => {
vi
.spyOn(DummyDataSource.prototype, 'getById')
.mockResolvedValueOnce(existingResource as never);
});

beforeEach(() => {
vi
.spyOn(DummyDataSource.prototype, 'delete')
.mockReturnValueOnce(Promise.resolve() as never);
});

beforeEach(() => {
@@ -460,6 +489,7 @@ describe('happy path', () => {
});

expect(res).toHaveProperty('statusCode', constants.HTTP_STATUS_NO_CONTENT);
expect(res).toHaveProperty('statusMessage', 'Piano Deleted');
expect(res.headers).not.toHaveProperty('content-type');
expect(resData).toBeUndefined();
});
@@ -471,6 +501,7 @@ describe('happy path', () => {
});

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


+ 25
- 361
test/e2e/http/error-handling.test.ts View File

@@ -20,13 +20,13 @@ import {
} from 'path';
import {request} from 'http';
import {constants} from 'http2';
import {Backend, dataSources} from '../../../src/backend';
import {Backend} from '../../../src/backend';
import { application, resource, validation as v, Resource } from '../../../src/common';
import { autoIncrement } from '../../fixtures';
import {createTestClient, TestClient} from '../../utils';
import { createTestClient, TestClient, DummyDataSource } from '../../utils';
import {DataSource} from '../../../src/backend/data-source';

const PORT = 3001;
const PORT = 4001;
const HOST = '127.0.0.1';
const BASE_PATH = '/api';
const ACCEPT = 'application/json';
@@ -35,53 +35,6 @@ 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(() => {
@@ -116,7 +69,7 @@ describe('error handling', () => {
});

let Piano: Resource;
beforeEach(() => {
beforeAll(() => {
Piano = resource(v.object(
{
brand: v.string()
@@ -412,7 +365,7 @@ describe('error handling', () => {
});
});

describe.skip('deleting items', () => {
describe('deleting items', () => {
const data = {
id: 1,
brand: 'Yamaha'
@@ -433,331 +386,42 @@ describe('error handling', () => {
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 () => {
it('throws on unable to check if item exists', async () => {
const [res] = await client({
method: 'GET',
method: 'DELETE',
path: `${BASE_PATH}/pianos/2`,
});

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

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

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

expect(res).toHaveProperty('statusCode', constants.HTTP_STATUS_NOT_FOUND);
expect(res).toHaveProperty('statusMessage', 'Delete Non-Existing Piano');
});
});

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);
});
it('throws on unable to delete item', async () => {
const getById = vi.spyOn(DummyDataSource.prototype, 'getById');
getById.mockResolvedValueOnce({
id: 2
} as never);

req.write(JSON.stringify(newData));
req.end();
const [res] = await client({
method: 'DELETE',
path: `${BASE_PATH}/pianos/2`,
});
});
});

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();
});
expect(res).toHaveProperty('statusCode', constants.HTTP_STATUS_INTERNAL_SERVER_ERROR);
expect(res).toHaveProperty('statusMessage', 'Unable To Delete Piano');
});
});
});


+ 54
- 5
test/utils.ts View File

@@ -1,10 +1,11 @@
import {IncomingMessage, OutgoingHttpHeaders, request, RequestOptions} from 'http';
import {IncomingHttpHeaders, IncomingMessage, OutgoingHttpHeaders, request, RequestOptions} from 'http';
import {Method} from '../src/backend/common';
import {DataSource} from '../src/backend/data-source';

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

@@ -23,18 +24,17 @@ export const createTestClient = (options: Omit<RequestOptions, 'method' | 'path'
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,
};

let contentTypeHeader: string | undefined;
if (typeof params.body !== 'undefined') {
headers['content-type'] = contentTypeHeader;
contentTypeHeader = headers['content-type'] = params.headers?.['content-type'] ?? 'application/json';
}

const req = request({
@@ -141,3 +141,52 @@ export const createTestClient = (options: Omit<RequestOptions, 'method' | 'path'

return client;
};

export class DummyError extends Error {}

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

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

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

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

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

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

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

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

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

async initialize(): Promise<void> {}

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

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

Loading…
Cancel
Save