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 { throw new Error(); } delete(): Promise { throw new Error(); } emplace(): Promise { throw new Error(); } getById(): Promise { throw new Error(); } newId(): Promise { throw new Error(); } getMultiple(): Promise { throw new Error(); } getSingle(): Promise { throw new Error(); } getTotalCount(): Promise { throw new Error(); } async initialize(): Promise {} patch(): Promise { 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; 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((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((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; 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((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((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(); }); }); }); }); });