From bc27b28e7ac9e6963d1bab2787941254f20fdaa7 Mon Sep 17 00:00:00 2001 From: TheoryOfNekomata Date: Sun, 14 Apr 2024 21:33:50 +0800 Subject: [PATCH] Add tests Decouple tests from data source. --- test/e2e/features.test.ts | 2 +- test/e2e/http/default.test.ts | 10 +- test/e2e/http/error-handling.test.ts | 592 +++++++++++++-------------- test/utils.ts | 36 +- 4 files changed, 302 insertions(+), 338 deletions(-) diff --git a/test/e2e/features.test.ts b/test/e2e/features.test.ts index fd20545..0194924 100644 --- a/test/e2e/features.test.ts +++ b/test/e2e/features.test.ts @@ -84,7 +84,7 @@ describe('decorators', () => { }); }); - afterEach(() => new Promise((resolve, reject) => { + afterEach(() => new Promise((resolve, reject) => { server.close((err) => { if (err) { reject(err); diff --git a/test/e2e/http/default.test.ts b/test/e2e/http/default.test.ts index 8e6101a..6b32e0e 100644 --- a/test/e2e/http/default.test.ts +++ b/test/e2e/http/default.test.ts @@ -24,8 +24,6 @@ const ACCEPT_CHARSET = 'utf-8'; const CONTENT_TYPE_CHARSET = 'utf-8'; const CONTENT_TYPE = ACCEPT; -let portCounter = 0; - describe.only('happy path', () => { let Piano: Resource; let app: Application; @@ -67,7 +65,7 @@ describe.only('happy path', () => { client = createTestClient({ host: HOST, - port: PORT + portCounter, + port: PORT, }) .acceptMediaType(ACCEPT) .acceptLanguage(ACCEPT_LANGUAGE) @@ -90,7 +88,7 @@ describe.only('happy path', () => { }); }); - afterAll(() => new Promise((resolve, reject) => { + afterAll(() => new Promise((resolve, reject) => { server.close((err) => { if (err) { reject(err); @@ -100,10 +98,6 @@ describe.only('happy path', () => { }); })); - afterAll(() => { - portCounter = 0; - }); - describe('serving collections', () => { beforeEach(() => { vi diff --git a/test/e2e/http/error-handling.test.ts b/test/e2e/http/error-handling.test.ts index 519214a..c6a4e64 100644 --- a/test/e2e/http/error-handling.test.ts +++ b/test/e2e/http/error-handling.test.ts @@ -7,23 +7,12 @@ import { 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} from '../../../src/backend'; -import { application, resource, validation as v, Resource } from '../../../src/common'; +import {application, resource, validation as v, Resource, Application} from '../../../src/common'; import { autoIncrement } from '../../fixtures'; -import { createTestClient, TestClient, DummyDataSource } from '../../utils'; +import {createTestClient, TestClient, DummyDataSource, DummyError} from '../../utils'; import {DataSource} from '../../../src/backend/data-source'; const PORT = 4001; @@ -36,8 +25,44 @@ const CONTENT_TYPE_CHARSET = 'utf-8'; const CONTENT_TYPE = ACCEPT; describe('error handling', () => { + let Piano: Resource; + let app: Application; + let dataSource: DataSource; + let backend: Backend; + let server: ReturnType; let client: TestClient; - beforeEach(() => { + + beforeAll(() => { + 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(), + }); + + app = application({ + name: 'piano-service', + }) + .resource(Piano); + + dataSource = new DummyDataSource(); + + backend = app.createBackend({ + dataSource, + }); + + server = backend.createHttpServer({ + basePath: BASE_PATH + }); + client = createTestClient({ host: HOST, port: PORT, @@ -47,382 +72,329 @@ describe('error handling', () => { .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; - beforeAll(() => { - 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; - beforeEach(() => { - const app = application({ - name: 'piano-service', - }) - .resource(Piano); - dataSource = new DummyDataSource(); + return new Promise((resolve, reject) => { + server.on('error', (err) => { + reject(err); + }); - backend = app.createBackend({ - dataSource, + server.on('listening', () => { + resolve(); }); - server = backend.createHttpServer({ - basePath: BASE_PATH + server.listen({ + port: PORT }); + }); + }); - return new Promise((resolve, reject) => { - server.on('error', (err) => { - reject(err); - }); + afterAll(() => new Promise((resolve, reject) => { + server.close((err) => { + if (err) { + reject(err); + } - server.on('listening', () => { - resolve(); - }); + resolve(); + }); + })); - server.listen({ - port: PORT - }); - }); + describe('serving collections', () => { + beforeEach(() => { + Piano.canFetchCollection(); }); - afterEach(() => new Promise((resolve, reject) => { - server.close((err) => { - if (err) { - reject(err); - } + afterEach(() => { + Piano.canFetchCollection(false); + }); - resolve(); - }); - })); - - describe.skip('serving collections', () => { - beforeEach(() => { - Piano.canFetchCollection(); - return new Promise((resolve) => { - setTimeout(() => { - resolve(); - }); - }); - }); + it('throws on query', async () => { + vi + .spyOn(DummyDataSource.prototype, 'getMultiple') + .mockImplementationOnce(() => { throw new DummyError() }); - afterEach(() => { - Piano.canFetchCollection(false); + const [res] = await client({ + method: 'GET', + path: `${BASE_PATH}/pianos`, }); - 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 () => { + vi + .spyOn(DummyDataSource.prototype, 'getMultiple') + .mockImplementationOnce(() => { throw new DummyError() }); - expect(res).toHaveProperty('statusCode', constants.HTTP_STATUS_INTERNAL_SERVER_ERROR); - expect(res).toHaveProperty('statusMessage', 'Unable To Fetch Piano Collection'); + const [res] = await client({ + method: 'HEAD', + path: `${BASE_PATH}/pianos`, }); - 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'); + }); + }); - expect(res).toHaveProperty('statusCode', constants.HTTP_STATUS_INTERNAL_SERVER_ERROR); - expect(res).toHaveProperty('statusMessage', 'Unable To Fetch Piano Collection'); - }); + describe('serving items', () => { + beforeEach(() => { + Piano.canFetchItem(); }); - describe('serving items', () => { - const data = { - id: 1, - brand: 'Yamaha' - }; + afterEach(() => { + Piano.canFetchItem(false); + }); - beforeEach(async () => { - const resourcePath = join(baseDir, 'pianos.jsonl'); - await writeFile(resourcePath, JSON.stringify(data)); - }); + it('throws on query', async () => { + vi + .spyOn(DummyDataSource.prototype, 'getById') + .mockImplementationOnce(() => { throw new DummyError() }); - beforeEach(() => { - Piano.canFetchItem(); - return new Promise((resolve) => { - setTimeout(() => { - resolve(); - }); - }); + const [res] = await client({ + method: 'GET', + path: `${BASE_PATH}/pianos/2`, }); - afterEach(() => { - Piano.canFetchItem(false); - }); + expect(res).toHaveProperty('statusCode', constants.HTTP_STATUS_INTERNAL_SERVER_ERROR); + expect(res).toHaveProperty('statusMessage', 'Unable To Fetch Piano'); + }); - it('throws on query', async () => { - const [res] = await client({ - method: 'GET', - path: `${BASE_PATH}/pianos/2`, - }); + it('throws on HEAD method', async () => { + vi + .spyOn(DummyDataSource.prototype, 'getById') + .mockImplementationOnce(() => { throw new DummyError() }); - expect(res).toHaveProperty('statusCode', constants.HTTP_STATUS_INTERNAL_SERVER_ERROR); - expect(res).toHaveProperty('statusMessage', 'Unable To Fetch Piano'); + const [res] = await client({ + method: 'HEAD', + path: `${BASE_PATH}/pianos/2`, }); - 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 () => { + vi + .spyOn(DummyDataSource.prototype, 'getById') + .mockResolvedValueOnce(null as never); - expect(res).toHaveProperty('statusCode', constants.HTTP_STATUS_INTERNAL_SERVER_ERROR); - expect(res).toHaveProperty('statusMessage', 'Unable To Fetch Piano'); + const [res] = await client({ + method: 'GET', + path: `${BASE_PATH}/pianos/2`, }); - it('throws on item not found', async () => { - const getById = vi.spyOn(DummyDataSource.prototype, 'getById'); - getById.mockResolvedValueOnce(null as never); + expect(res).toHaveProperty('statusCode', constants.HTTP_STATUS_NOT_FOUND); + }); - const [res] = await client({ - method: 'GET', - path: `${BASE_PATH}/pianos/2`, - }); + it('throws on item not found on HEAD method', async () => { + const getById = vi.spyOn(DummyDataSource.prototype, 'getById'); + getById.mockResolvedValueOnce(null as never); - expect(res).toHaveProperty('statusCode', constants.HTTP_STATUS_NOT_FOUND); + const [res] = await client({ + method: 'HEAD', + path: `${BASE_PATH}/pianos/2`, }); - it('throws on item not found on HEAD method', async () => { - const getById = vi.spyOn(DummyDataSource.prototype, 'getById'); - getById.mockResolvedValueOnce(null as never); + expect(res).toHaveProperty('statusCode', constants.HTTP_STATUS_NOT_FOUND); + }); + }); - const [res] = await client({ - method: 'HEAD', - path: `${BASE_PATH}/pianos/2`, - }); + describe('creating items', () => { + const newData = { + brand: 'K. Kawai' + }; - expect(res).toHaveProperty('statusCode', constants.HTTP_STATUS_NOT_FOUND); - }); + const existingResource = { + ...newData, + id: 1, + }; + + beforeEach(() => { + Piano.canCreate(); }); - describe('creating items', () => { - const data = { - id: 1, - brand: 'Yamaha' - }; + afterEach(() => { + Piano.canCreate(false); + }); - const newData = { - brand: 'K. Kawai' - }; + it('throws on error assigning ID', async () => { + vi + .spyOn(DummyDataSource.prototype, 'newId') + .mockImplementationOnce(() => { throw new DummyError() }); - beforeEach(async () => { - const resourcePath = join(baseDir, 'pianos.jsonl'); - await writeFile(resourcePath, JSON.stringify(data)); + const [res] = await client({ + method: 'POST', + path: `${BASE_PATH}/pianos`, + body: newData, }); - beforeEach(() => { - Piano.canCreate(); - }); + expect(res).toHaveProperty('statusCode', constants.HTTP_STATUS_INTERNAL_SERVER_ERROR); + expect(res).toHaveProperty('statusMessage', 'Unable To Assign ID From Piano Data Source'); + }); - afterEach(() => { - Piano.canCreate(false); - }); + it('throws on error creating resource', async () => { + vi + .spyOn(DummyDataSource.prototype, 'newId') + .mockResolvedValueOnce(existingResource.id as never); - it('throws on error assigning ID', async () => { - const [res] = await client({ - method: 'POST', - path: `${BASE_PATH}/pianos`, - body: newData, - }); + vi + .spyOn(DummyDataSource.prototype, 'create') + .mockImplementationOnce(() => { throw new DummyError() }); - expect(res).toHaveProperty('statusCode', constants.HTTP_STATUS_INTERNAL_SERVER_ERROR); - expect(res).toHaveProperty('statusMessage', 'Unable To Assign ID From Piano Data Source'); + const [res] = await client({ + method: 'POST', + path: `${BASE_PATH}/pianos`, + body: newData, }); - it('throws on error creating resource', async () => { - const getById = vi.spyOn(DummyDataSource.prototype, 'newId'); - getById.mockResolvedValueOnce(data.id as never); + expect(res).toHaveProperty('statusCode', constants.HTTP_STATUS_INTERNAL_SERVER_ERROR); + expect(res).toHaveProperty('statusMessage', 'Unable To Create Piano'); + }); + }); + + describe('patching items', () => { + const existingResource = { + id: 1, + brand: 'Yamaha' + }; - const [res] = await client({ - method: 'POST', - path: `${BASE_PATH}/pianos`, - body: newData, - }); + const newData = { + brand: 'K. Kawai' + }; - expect(res).toHaveProperty('statusCode', constants.HTTP_STATUS_INTERNAL_SERVER_ERROR); - expect(res).toHaveProperty('statusMessage', 'Unable To Create Piano'); - }); + beforeEach(() => { + Piano.canPatch(); }); - describe.skip('patching items', () => { - const data = { - id: 1, - brand: 'Yamaha' - }; + afterEach(() => { + Piano.canPatch(false); + }); - const newData = { - brand: 'K. Kawai' - }; + // TODO add more tests - beforeEach(async () => { - const resourcePath = join(baseDir, 'pianos.jsonl'); - await writeFile(resourcePath, JSON.stringify(data)); - }); + it('throws on unable to fetch existing item', async () => { + vi + .spyOn(DummyDataSource.prototype, 'getById') + .mockImplementationOnce(() => { throw new DummyError() }); - beforeEach(() => { - Piano.canPatch(); + const [res] = await client({ + method: 'PATCH', + path: `${BASE_PATH}/pianos/${existingResource.id}`, + body: newData, + headers: { + 'content-type': 'application/merge-patch+json', + }, }); - afterEach(() => { - Piano.canPatch(false); - }); + expect(res).toHaveProperty('statusCode', constants.HTTP_STATUS_INTERNAL_SERVER_ERROR); + expect(res).toHaveProperty('statusMessage', 'Unable To Fetch Piano'); + }); - it('throws on item to patch not found', () => { - return new Promise((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(); - }); + it('throws on item to patch not found', async () => { + vi + .spyOn(DummyDataSource.prototype, 'getById') + .mockResolvedValueOnce(null as never); + + const [res] = await client({ + method: 'PATCH', + path: `${BASE_PATH}/pianos/${existingResource.id}`, + body: newData, + headers: { + 'content-type': 'application/merge-patch+json', + }, }); - }); - describe.skip('emplacing items', () => { - const data = { - id: 1, - brand: 'Yamaha' - }; + expect(res).toHaveProperty('statusCode', constants.HTTP_STATUS_NOT_FOUND); + expect(res).toHaveProperty('statusMessage', 'Patch Non-Existing Piano'); + }); + }); - const newData = { - id: 1, - brand: 'K. Kawai' - }; + describe.skip('emplacing items', () => { + const existingResource = { + id: 1, + brand: 'Yamaha' + }; - beforeEach(async () => { - const resourcePath = join(baseDir, 'pianos.jsonl'); - await writeFile(resourcePath, JSON.stringify(data)); - }); + beforeEach(() => { + vi + .spyOn(DummyDataSource.prototype, 'getById') + .mockResolvedValueOnce(existingResource as never); + }); - beforeEach(() => { - Piano.canEmplace(); - }); + const newData = { + id: 1, + brand: 'K. Kawai' + }; - afterEach(() => { - Piano.canEmplace(false); - }); + beforeEach(() => { + Piano.canEmplace(); }); - describe('deleting items', () => { - const data = { - id: 1, - brand: 'Yamaha' - }; + afterEach(() => { + Piano.canEmplace(false); + }); + }); - beforeEach(async () => { - const resourcePath = join(baseDir, 'pianos.jsonl'); - await writeFile(resourcePath, JSON.stringify(data)); - }); + describe('deleting items', () => { + const existingResource = { + id: 1, + brand: 'Yamaha' + }; - beforeEach(() => { - Piano.canDelete(); - backend.throwsErrorOnDeletingNotFound(); - }); + beforeEach(() => { + Piano.canDelete(); + backend.throwsErrorOnDeletingNotFound(); + }); - afterEach(() => { - Piano.canDelete(false); - backend.throwsErrorOnDeletingNotFound(false); - }); + afterEach(() => { + Piano.canDelete(false); + backend.throwsErrorOnDeletingNotFound(false); + }); - it('throws on unable to check if item exists', async () => { - const [res] = await client({ - method: 'DELETE', - path: `${BASE_PATH}/pianos/2`, - }); + it('throws on unable to check if item exists', async () => { + vi + .spyOn(DummyDataSource.prototype, 'getById') + .mockImplementationOnce(() => { throw new DummyError() }); - expect(res).toHaveProperty('statusCode', constants.HTTP_STATUS_INTERNAL_SERVER_ERROR); - expect(res).toHaveProperty('statusMessage', 'Unable To Fetch Piano'); + const [res] = await client({ + method: 'DELETE', + path: `${BASE_PATH}/pianos/2`, }); - it('throws on item not found', async () => { - const getById = vi.spyOn(DummyDataSource.prototype, 'getById'); - getById.mockResolvedValueOnce(null as never); + expect(res).toHaveProperty('statusCode', constants.HTTP_STATUS_INTERNAL_SERVER_ERROR); + expect(res).toHaveProperty('statusMessage', 'Unable To Fetch Piano'); + }); - const [res] = await client({ - method: 'DELETE', - path: `${BASE_PATH}/pianos/2`, - }); + it('throws on item not found', async () => { + vi + .spyOn(DummyDataSource.prototype, 'getById') + .mockResolvedValueOnce(null as never); - expect(res).toHaveProperty('statusCode', constants.HTTP_STATUS_NOT_FOUND); - expect(res).toHaveProperty('statusMessage', 'Delete Non-Existing Piano'); + const [res] = await client({ + method: 'DELETE', + path: `${BASE_PATH}/pianos/${existingResource.id}`, }); - it('throws on unable to delete item', async () => { - const getById = vi.spyOn(DummyDataSource.prototype, 'getById'); - getById.mockResolvedValueOnce({ - id: 2 - } as never); + expect(res).toHaveProperty('statusCode', constants.HTTP_STATUS_NOT_FOUND); + expect(res).toHaveProperty('statusMessage', 'Delete Non-Existing Piano'); + }); - const [res] = await client({ - method: 'DELETE', - path: `${BASE_PATH}/pianos/2`, - }); + it('throws on unable to delete item', async () => { + vi + .spyOn(DummyDataSource.prototype, 'getById') + .mockResolvedValueOnce(existingResource as never); - expect(res).toHaveProperty('statusCode', constants.HTTP_STATUS_INTERNAL_SERVER_ERROR); - expect(res).toHaveProperty('statusMessage', 'Unable To Delete Piano'); + vi + .spyOn(DummyDataSource.prototype, 'delete') + .mockImplementationOnce(() => { throw new DummyError() }); + + const [res] = await client({ + method: 'DELETE', + path: `${BASE_PATH}/pianos/${existingResource.id}`, }); + + expect(res).toHaveProperty('statusCode', constants.HTTP_STATUS_INTERNAL_SERVER_ERROR); + expect(res).toHaveProperty('statusMessage', 'Unable To Delete Piano'); }); }); }); diff --git a/test/utils.ts b/test/utils.ts index 15ea0b2..7b669ef 100644 --- a/test/utils.ts +++ b/test/utils.ts @@ -147,42 +147,40 @@ export class DummyError extends Error {} export class DummyDataSource implements DataSource { private resource?: { dataSource?: unknown }; - create(): Promise { - throw new DummyError(); + async create(): Promise { + return {}; } - delete(): Promise { - throw new DummyError(); - } + async delete(): Promise {} - emplace(): Promise { - throw new DummyError(); + async emplace(): Promise<[object, boolean]> { + return [{}, false]; } - getById(): Promise { - throw new DummyError(); + async getById(): Promise { + return {}; } - newId(): Promise { - throw new DummyError(); + async newId(): Promise { + return ''; } - getMultiple(): Promise { - throw new DummyError(); + async getMultiple(): Promise { + return []; } - getSingle(): Promise { - throw new DummyError(); + async getSingle(): Promise { + return {}; } - getTotalCount(): Promise { - throw new DummyError(); + async getTotalCount(): Promise { + return 0; } async initialize(): Promise {} - patch(): Promise { - throw new DummyError(); + async patch(): Promise { + return {}; } prepareResource(rr: unknown) {