import { beforeAll, afterAll, afterEach, beforeEach, describe, expect, it, vi, } from 'vitest'; import {constants} from 'http2'; import {Backend, DataSource} from '@modal-sh/yasumi/backend'; import {application, resource, validation as v, Resource, Application, Delta} from '@modal-sh/yasumi'; import { createTestClient, TestClient, DummyDataSource, DummyError, TEST_LANGUAGE, dummyGenerationStrategy, } from '../utils'; import {httpExtender, HttpServer} from '../../src'; 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; const prepareStatusMessage = (s: string) => s.replace(/\$RESOURCE/g, 'Piano'); describe('error handling', () => { let Piano: Resource; let app: Application; let dataSource: DataSource; let backend: Backend; let server: HttpServer; let client: TestClient; beforeAll(() => { Piano = resource(v.object( { brand: v.string() }, v.never() )) .name('Piano' as const) .route('pianos' as const) .id('id' as const, { generationStrategy: dummyGenerationStrategy, serialize: (id) => id?.toString() ?? '0', deserialize: (id) => Number.isFinite(Number(id)) ? Number(id) : 0, schema: v.number(), }); app = application({ name: 'piano-service', }) .language(TEST_LANGUAGE) .resource(Piano); dataSource = new DummyDataSource(); backend = app .createBackend({ dataSource, }) .use(httpExtender); server = backend.createServer('http', { basePath: BASE_PATH }); client = createTestClient({ host: HOST, port: PORT, }) .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); }); server.on('listening', () => { resolve(); }); server.listen({ port: PORT }); }); }); afterAll(() => new Promise((resolve, reject) => { server.close((err) => { if (err) { reject(err); } resolve(); }); })); describe('serving collections', () => { beforeEach(() => { Piano.canFetchCollection(); }); afterEach(() => { Piano.canFetchCollection(false); }); it('throws on query', async () => { vi .spyOn(DummyDataSource.prototype, 'getMultiple') .mockImplementationOnce(() => { throw new DummyError() }); const [res] = await client({ method: 'GET', path: `${BASE_PATH}/pianos`, }); expect(res).toHaveProperty('statusCode', constants.HTTP_STATUS_INTERNAL_SERVER_ERROR); expect(res).toHaveProperty('statusMessage', prepareStatusMessage(TEST_LANGUAGE.statusMessages.unableToFetchResourceCollection)); }); it('throws on HEAD method', async () => { vi .spyOn(DummyDataSource.prototype, 'getMultiple') .mockImplementationOnce(() => { throw new DummyError() }); const [res] = await client({ method: 'HEAD', path: `${BASE_PATH}/pianos`, }); expect(res).toHaveProperty('statusCode', constants.HTTP_STATUS_INTERNAL_SERVER_ERROR); expect(res).toHaveProperty('statusMessage', prepareStatusMessage(TEST_LANGUAGE.statusMessages.unableToFetchResourceCollection)); }); }); describe('serving items', () => { beforeEach(() => { Piano.canFetchItem(); }); afterEach(() => { Piano.canFetchItem(false); }); it('throws on query', async () => { vi .spyOn(DummyDataSource.prototype, 'getById') .mockImplementationOnce(() => { throw new DummyError() }); 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', prepareStatusMessage(TEST_LANGUAGE.statusMessages.unableToFetchResource)); }); it('throws on HEAD method', async () => { vi .spyOn(DummyDataSource.prototype, 'getById') .mockImplementationOnce(() => { throw new DummyError() }); 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', prepareStatusMessage(TEST_LANGUAGE.statusMessages.unableToFetchResource)); }); it('throws on item not found', async () => { vi .spyOn(DummyDataSource.prototype, '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 newData = { brand: 'K. Kawai' }; const existingResource = { ...newData, id: 1, }; beforeEach(() => { Piano.canCreate(); }); afterEach(() => { Piano.canCreate(false); }); it('throws on error assigning ID', async () => { vi .spyOn(DummyDataSource.prototype, 'newId') .mockImplementationOnce(() => { throw new DummyError() }); 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', prepareStatusMessage(TEST_LANGUAGE.statusMessages.unableToAssignIdFromResourceDataSource)); }); it('throws on error creating resource', async () => { vi .spyOn(DummyDataSource.prototype, 'newId') .mockResolvedValueOnce(existingResource.id as never); vi .spyOn(DummyDataSource.prototype, 'create') .mockImplementationOnce(() => { throw new DummyError() }); 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', prepareStatusMessage(TEST_LANGUAGE.statusMessages.unableToCreateResource)); }); }); describe('patching items', () => { const existingResource = { id: 1, brand: 'Yamaha' }; const newData = { brand: 'K. Kawai' }; // TODO add more tests it('throws on unable to fetch existing item', async () => { Piano.canPatch(); vi .spyOn(DummyDataSource.prototype, 'getById') .mockImplementationOnce(() => { throw new DummyError() }); const [res] = await client({ method: 'PATCH', path: `${BASE_PATH}/pianos/${existingResource.id}`, body: newData, headers: { 'content-type': 'application/merge-patch+json', }, }); expect(res).toHaveProperty('statusCode', constants.HTTP_STATUS_INTERNAL_SERVER_ERROR); expect(res).toHaveProperty('statusMessage', prepareStatusMessage(TEST_LANGUAGE.statusMessages.unableToFetchResource)); Piano.canPatch(false); }); it('throws on item to patch not found', async () => { Piano.canPatch(); 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', }, }); expect(res).toHaveProperty('statusCode', constants.HTTP_STATUS_NOT_FOUND); expect(res).toHaveProperty('statusMessage', prepareStatusMessage(TEST_LANGUAGE.statusMessages.patchNonExistingResource)); Piano.canPatch(false); }); describe('on merge patch', () => { const newMergeData = { brand: 'K. Kawai' }; beforeEach(() => { Piano.canPatch(['merge']); }); afterEach(() => { Piano.canPatch(false); }); it('throws on attempting to request a delta patch', async () => { const [res] = await client({ method: 'PATCH', path: `${BASE_PATH}/pianos/${existingResource.id}`, body: newMergeData, headers: { 'content-type': 'application/json-patch+json', }, }); expect(res).toHaveProperty('statusCode', constants.HTTP_STATUS_UNSUPPORTED_MEDIA_TYPE); expect(res).toHaveProperty('statusMessage', prepareStatusMessage(TEST_LANGUAGE.statusMessages.invalidResourcePatchType)); }); }); describe('on delta patch', () => { beforeEach(() => { Piano.canPatch(['delta']); }); afterEach(() => { Piano.canPatch(false); }); it('throws on attempting to request a merge patch', async () => { const [res] = await client({ method: 'PATCH', path: `${BASE_PATH}/pianos/${existingResource.id}`, body: { brand: 'Hello' }, headers: { 'content-type': 'application/merge-patch+json', }, }); expect(res).toHaveProperty('statusCode', constants.HTTP_STATUS_UNSUPPORTED_MEDIA_TYPE); expect(res).toHaveProperty('statusMessage', prepareStatusMessage(TEST_LANGUAGE.statusMessages.invalidResourcePatchType)); }); it('throws on operating with a delta to an attribute outside the schema', async () => { const [res] = await client({ method: 'PATCH', path: `${BASE_PATH}/pianos/${existingResource.id}`, body: [ { op: 'replace', path: 'brandUnknown', value: 'K. Kawai', }, ] satisfies Delta[], headers: { 'content-type': 'application/json-patch+json', }, }); expect(res).toHaveProperty('statusCode', constants.HTTP_STATUS_UNPROCESSABLE_ENTITY); expect(res).toHaveProperty('statusMessage', prepareStatusMessage(TEST_LANGUAGE.statusMessages.invalidResourcePatch)); }); it('throws on operating a delta with mismatched value type', async () => { const [res] = await client({ method: 'PATCH', path: `${BASE_PATH}/pianos/${existingResource.id}`, body: [ { op: 'replace', path: 'brand', value: 5, }, ] satisfies Delta[], headers: { 'content-type': 'application/json-patch+json', }, }); expect(res).toHaveProperty('statusCode', constants.HTTP_STATUS_UNPROCESSABLE_ENTITY); expect(res).toHaveProperty('statusMessage', prepareStatusMessage(TEST_LANGUAGE.statusMessages.invalidResourcePatch)); }); it('throws on performing an invalid delta', async () => { const [res] = await client({ method: 'PATCH', path: `${BASE_PATH}/pianos/${existingResource.id}`, body: [ { op: 'add', path: 'brand', value: 5, }, ] satisfies Delta[], headers: { 'content-type': 'application/json-patch+json', }, }); expect(res).toHaveProperty('statusCode', constants.HTTP_STATUS_UNPROCESSABLE_ENTITY); expect(res).toHaveProperty('statusMessage', prepareStatusMessage(TEST_LANGUAGE.statusMessages.invalidResourcePatch)); }); }); }); describe('emplacing items', () => { const existingResource = { id: 1, brand: 'Yamaha' }; beforeEach(() => { vi .spyOn(DummyDataSource.prototype, 'getById') .mockResolvedValueOnce(existingResource as never); }); const newData = { id: 1, brand: 'K. Kawai' }; beforeEach(() => { Piano.canEmplace(); }); afterEach(() => { Piano.canEmplace(false); }); it('throws on unable to emplace', async () => { vi .spyOn(DummyDataSource.prototype, 'emplace') .mockImplementationOnce(() => { throw new DummyError() }); const [res] = await client({ method: 'PUT', path: `${BASE_PATH}/pianos/${existingResource.id}`, body: newData, }); expect(res).toHaveProperty('statusCode', constants.HTTP_STATUS_INTERNAL_SERVER_ERROR); expect(res).toHaveProperty('statusMessage', prepareStatusMessage(TEST_LANGUAGE.statusMessages.unableToEmplaceResource)); }); }); describe('deleting items', () => { const existingResource = { id: 1, brand: 'Yamaha' }; beforeEach(() => { Piano.canDelete(); backend.throwsErrorOnDeletingNotFound(); }); afterEach(() => { Piano.canDelete(false); backend.throwsErrorOnDeletingNotFound(false); }); it('throws on unable to check if item exists', async () => { vi .spyOn(DummyDataSource.prototype, 'getById') .mockImplementationOnce(() => { throw new DummyError() }); const [res] = await client({ method: 'DELETE', path: `${BASE_PATH}/pianos/2`, }); expect(res).toHaveProperty('statusCode', constants.HTTP_STATUS_INTERNAL_SERVER_ERROR); expect(res).toHaveProperty('statusMessage', prepareStatusMessage(TEST_LANGUAGE.statusMessages.unableToFetchResource)); }); it('throws on item not found', async () => { vi .spyOn(DummyDataSource.prototype, 'getById') .mockResolvedValueOnce(null as never); const [res] = await client({ method: 'DELETE', path: `${BASE_PATH}/pianos/${existingResource.id}`, }); expect(res).toHaveProperty('statusCode', constants.HTTP_STATUS_NOT_FOUND); expect(res).toHaveProperty('statusMessage', prepareStatusMessage(TEST_LANGUAGE.statusMessages.deleteNonExistingResource)); }); it('throws on unable to delete item', async () => { vi .spyOn(DummyDataSource.prototype, 'getById') .mockResolvedValueOnce(existingResource as never); 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', prepareStatusMessage(TEST_LANGUAGE.statusMessages.unableToDeleteResource)); }); }); });