From 6e7230adc419f3a52e5441cadbc013b7351aeefa Mon Sep 17 00:00:00 2001 From: TheoryOfNekomata Date: Thu, 14 Mar 2024 10:28:14 +0800 Subject: [PATCH] Add tests Ensure requests are properly handled. --- src/data-sources/file-jsonl.ts | 14 +- src/handlers.ts | 4 +- test/e2e/default.test.ts | 661 +++++++++++++++++++++++++++++++++ 3 files changed, 674 insertions(+), 5 deletions(-) create mode 100644 test/e2e/default.test.ts diff --git a/src/data-sources/file-jsonl.ts b/src/data-sources/file-jsonl.ts index 8db1c87..4683bde 100644 --- a/src/data-sources/file-jsonl.ts +++ b/src/data-sources/file-jsonl.ts @@ -38,12 +38,20 @@ export class DataSource> implements DataSourceI } async create(data: Partial) { - const newData = [ + const newData = { + ...data + } as Record; + + if (this.resource.idAttr in newData) { + newData[this.resource.idAttr] = this.resource.idDeserializer(newData[this.resource.idAttr] as string); + } + + const newCollection = [ ...this.data, - data + newData ]; - await writeFile(this.path, newData.map((d) => JSON.stringify(d)).join('\n')); + await writeFile(this.path, newCollection.map((d) => JSON.stringify(d)).join('\n')); return data as T; } diff --git a/src/handlers.ts b/src/handlers.ts index 6718e05..affe3b8 100644 --- a/src/handlers.ts +++ b/src/handlers.ts @@ -148,7 +148,7 @@ export const handleGetItem: Middleware = ({ return { handled: true }; - } catch { + } catch (err) { res.statusCode = constants.HTTP_STATUS_INTERNAL_SERVER_ERROR; res.end(); return { @@ -316,7 +316,7 @@ export const handleCreateItem: Middleware = ({ params[resource.idAttr] = newId; const newObject = await resource.dataSource.create(params); const theFormatted = responseBodySerializerPair.serialize(newObject); - res.writeHead(constants.HTTP_STATUS_OK, { + res.writeHead(constants.HTTP_STATUS_CREATED, { 'Content-Type': responseMediaType, 'Location': `${serverParams.baseUrl}/${resource.routeName}/${newId}` }); diff --git a/test/e2e/default.test.ts b/test/e2e/default.test.ts new file mode 100644 index 0000000..de44975 --- /dev/null +++ b/test/e2e/default.test.ts @@ -0,0 +1,661 @@ +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 { + application, + DataSource, + dataSources, + encodings, + Resource, + resource, + serializers, + valibot as v, +} from '../../src'; +import {request, Server} from 'http'; +import {constants} from 'http2'; + +const PORT = 3000; +const HOST = 'localhost'; +const ACCEPT_ENCODING = 'utf-8'; +const ACCEPT = 'application/json'; + +const autoIncrement = async (dataSource: DataSource) => { + const data = await dataSource.getMultiple() as Record[]; + + const highestId = data.reduce( + (highestId, d) => (Number(d.id) > highestId ? Number(d.id) : highestId), + -Infinity + ); + + if (Number.isFinite(highestId)) { + return (highestId + 1); + } + + return 1; +}; + +describe('yasumi', () => { + 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') + .id('id', { + generationStrategy: autoIncrement, + serialize: (id) => id?.toString() ?? '0', + deserialize: (id) => Number.isFinite(Number(id)) ? Number(id) : 0, + }) + }); + + let server: Server; + beforeEach(() => { + const app = application({ + name: 'piano-service', + dataSource: (resource) => new dataSources.jsonlFile.DataSource(resource, baseDir), + }) + .contentType(ACCEPT, serializers.applicationJson) + .encoding(ACCEPT_ENCODING, encodings.utf8) + .resource(Piano); + + server = app.createServer({ + baseUrl: '/api' + }); + + 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', () => { + it('returns data', () => { + return new Promise((resolve, reject) => { + Piano.allowFetchCollection(); + + const req = request( + { + host: HOST, + port: PORT, + path: '/api/pianos', + method: 'GET', + headers: { + 'Accept': ACCEPT, + 'Accept-Encoding': ACCEPT_ENCODING, + }, + }, + (res) => { + res.on('error', (err) => { + Piano.revokeFetchCollection(); + reject(err); + }); + + expect(res).toHaveProperty('statusCode', constants.HTTP_STATUS_OK); + expect(res.headers).toHaveProperty('content-type', ACCEPT); + + let resBuffer = Buffer.from(''); + res.on('data', (c) => { + resBuffer = Buffer.concat([resBuffer, c]); + }); + + res.on('close', () => { + const resBufferJson = resBuffer.toString(ACCEPT_ENCODING); + const resData = JSON.parse(resBufferJson); + expect(resData).toEqual([]); + Piano.revokeFetchCollection(); + resolve(); + }); + }, + ); + + req.on('error', (err) => { + Piano.revokeFetchCollection(); + reject(err); + }); + + req.end(); + }); + }); + }); + + describe('serving items', () => { + const data = { + id: 1, + brand: 'Yamaha' + }; + + beforeEach(async () => { + const resourcePath = join(baseDir, 'pianos.jsonl'); + await writeFile(resourcePath, JSON.stringify(data)); + }); + + it('returns data', () => { + return new Promise((resolve, reject) => { + Piano.allowFetchItem(); + + const req = request( + { + host: HOST, + port: PORT, + path: '/api/pianos/1', + method: 'GET', + headers: { + 'Accept': ACCEPT, + 'Accept-Encoding': ACCEPT_ENCODING, + }, + }, + (res) => { + res.on('error', (err) => { + Piano.revokeFetchItem(); + reject(err); + }); + + expect(res).toHaveProperty('statusCode', constants.HTTP_STATUS_OK); + expect(res.headers).toHaveProperty('content-type', ACCEPT); + + let resBuffer = Buffer.from(''); + res.on('data', (c) => { + resBuffer = Buffer.concat([resBuffer, c]); + }); + + res.on('close', () => { + const resBufferJson = resBuffer.toString(ACCEPT_ENCODING); + const resData = JSON.parse(resBufferJson); + expect(resData).toEqual(data); + Piano.revokeFetchItem(); + resolve(); + }); + }, + ); + + req.on('error', (err) => { + Piano.revokeFetchItem(); + reject(err); + }); + + req.end(); + }); + }); + + it('throws on item not found', () => { + return new Promise((resolve, reject) => { + Piano.allowFetchItem(); + + const req = request( + { + host: HOST, + port: PORT, + path: '/api/pianos/2', + method: 'GET', + headers: { + 'Accept': ACCEPT, + 'Accept-Encoding': ACCEPT_ENCODING, + }, + }, + (res) => { + res.on('error', (err) => { + Piano.revokeFetchItem(); + reject(err); + }); + + expect(res).toHaveProperty('statusCode', constants.HTTP_STATUS_NOT_FOUND); + + Piano.revokeFetchItem(); + resolve(); + }, + ); + + req.on('error', (err) => { + Piano.revokeFetchItem(); + reject(err); + }); + + req.end(); + }); + }); + }); + + 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)); + }); + + // FIXME ID de/serialization problems + it('returns data', () => { + return new Promise((resolve, reject) => { + Piano.allowCreate(); + + const req = request( + { + host: HOST, + port: PORT, + path: '/api/pianos', + method: 'POST', + headers: { + 'Accept': ACCEPT, + 'Accept-Encoding': ACCEPT_ENCODING, + 'Content-Type': ACCEPT, + }, + }, + (res) => { + res.on('error', (err) => { + Piano.revokeCreate(); + reject(err); + }); + + expect(res).toHaveProperty('statusCode', constants.HTTP_STATUS_CREATED); + expect(res.headers).toHaveProperty('content-type', ACCEPT); + + let resBuffer = Buffer.from(''); + res.on('data', (c) => { + resBuffer = Buffer.concat([resBuffer, c]); + }); + + res.on('close', () => { + const resBufferJson = resBuffer.toString(ACCEPT_ENCODING); + const resData = JSON.parse(resBufferJson); + expect(resData).toEqual({ + ...newData, + id: '2' + }); + Piano.revokeCreate(); + resolve(); + }); + }, + ); + + req.on('error', (err) => { + Piano.revokeCreate(); + reject(err); + }); + + req.write(JSON.stringify(newData)); + req.end(); + }); + }); + }); + + describe('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)); + }); + + it('returns data', () => { + return new Promise((resolve, reject) => { + Piano.allowPatch(); + + const req = request( + { + host: HOST, + port: PORT, + path: '/api/pianos/1', + method: 'PATCH', + headers: { + 'Accept': ACCEPT, + 'Accept-Encoding': ACCEPT_ENCODING, + 'Content-Type': ACCEPT, + }, + }, + (res) => { + res.on('error', (err) => { + Piano.revokePatch(); + reject(err); + }); + + expect(res).toHaveProperty('statusCode', constants.HTTP_STATUS_OK); + expect(res.headers).toHaveProperty('content-type', ACCEPT); + + let resBuffer = Buffer.from(''); + res.on('data', (c) => { + resBuffer = Buffer.concat([resBuffer, c]); + }); + + res.on('close', () => { + const resBufferJson = resBuffer.toString(ACCEPT_ENCODING); + const resData = JSON.parse(resBufferJson); + expect(resData).toEqual({ + ...data, + ...newData, + }); + Piano.revokePatch(); + resolve(); + }); + }, + ); + + req.on('error', (err) => { + Piano.revokePatch(); + reject(err); + }); + + req.write(JSON.stringify(newData)); + req.end(); + }); + }); + + it('throws on item to patch not found', () => { + return new Promise((resolve, reject) => { + Piano.allowPatch(); + + const req = request( + { + host: HOST, + port: PORT, + path: '/api/pianos/2', + method: 'PATCH', + headers: { + 'Accept': ACCEPT, + 'Accept-Encoding': ACCEPT_ENCODING, + 'Content-Type': ACCEPT, + }, + }, + (res) => { + res.on('error', (err) => { + Piano.revokePatch(); + reject(err); + }); + + expect(res).toHaveProperty('statusCode', constants.HTTP_STATUS_NOT_FOUND); + + Piano.revokePatch(); + resolve(); + }, + ); + + req.on('error', (err) => { + Piano.revokePatch(); + reject(err); + }); + + req.write(JSON.stringify(newData)); + req.end(); + }); + }); + }); + + describe('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)); + }); + + // FIXME IDs not properly being de/serialized + it('returns data for replacement', () => { + return new Promise((resolve, reject) => { + Piano.allowEmplace(); + + const req = request( + { + host: HOST, + port: PORT, + path: '/api/pianos/1', + method: 'PUT', + headers: { + 'Accept': ACCEPT, + 'Accept-Encoding': ACCEPT_ENCODING, + 'Content-Type': ACCEPT, + }, + }, + (res) => { + res.on('error', (err) => { + Piano.revokeEmplace(); + reject(err); + }); + + expect(res).toHaveProperty('statusCode', constants.HTTP_STATUS_OK); + expect(res.headers).toHaveProperty('content-type', ACCEPT); + + let resBuffer = Buffer.from(''); + res.on('data', (c) => { + resBuffer = Buffer.concat([resBuffer, c]); + }); + + res.on('close', () => { + const resBufferJson = resBuffer.toString(ACCEPT_ENCODING); + const resData = JSON.parse(resBufferJson); + expect(resData).toEqual(newData); + Piano.revokeEmplace(); + resolve(); + }); + }, + ); + + req.on('error', (err) => { + Piano.revokeEmplace(); + reject(err); + }); + + req.write(JSON.stringify(newData)); + req.end(); + }); + }); + + it('returns data for creation', () => { + return new Promise((resolve, reject) => { + const id = 2; + Piano.allowEmplace(); + + const req = request( + { + host: HOST, + port: PORT, + path: `/api/pianos/${id}`, + method: 'PUT', + headers: { + 'Accept': ACCEPT, + 'Accept-Encoding': ACCEPT_ENCODING, + 'Content-Type': ACCEPT, + }, + }, + (res) => { + res.on('error', (err) => { + Piano.revokeEmplace(); + reject(err); + }); + + expect(res).toHaveProperty('statusCode', constants.HTTP_STATUS_CREATED); + expect(res.headers).toHaveProperty('content-type', ACCEPT); + + let resBuffer = Buffer.from(''); + res.on('data', (c) => { + resBuffer = Buffer.concat([resBuffer, c]); + }); + + res.on('close', () => { + const resBufferJson = resBuffer.toString(ACCEPT_ENCODING); + const resData = JSON.parse(resBufferJson); + expect(resData).toEqual({ + ...newData, + id, + }); + Piano.revokeEmplace(); + resolve(); + }); + }, + ); + + req.on('error', (err) => { + Piano.revokeEmplace(); + reject(err); + }); + + req.write(JSON.stringify({ + ...newData, + id, + })); + req.end(); + }); + }); + }); + + describe('deleting items', () => { + const data = { + id: 1, + brand: 'Yamaha' + }; + + beforeEach(async () => { + const resourcePath = join(baseDir, 'pianos.jsonl'); + await writeFile(resourcePath, JSON.stringify(data)); + }); + + it('returns data', () => { + return new Promise((resolve, reject) => { + Piano.allowDelete(); + + const req = request( + { + host: HOST, + port: PORT, + path: '/api/pianos/1', + method: 'DELETE', + headers: { + 'Accept': ACCEPT, + 'Accept-Encoding': ACCEPT_ENCODING, + }, + }, + (res) => { + res.on('error', (err) => { + Piano.revokeDelete(); + reject(err); + }); + + expect(res).toHaveProperty('statusCode', constants.HTTP_STATUS_NO_CONTENT); + Piano.revokeDelete(); + resolve(); + }, + ); + + req.on('error', (err) => { + Piano.revokeDelete(); + reject(err); + }); + + req.end(); + }); + }); + + it('throws on item not found', () => { + return new Promise((resolve, reject) => { + Piano.allowDelete(); + + const req = request( + { + host: HOST, + port: PORT, + path: '/api/pianos/2', + method: 'DELETE', + headers: { + 'Accept': ACCEPT, + 'Accept-Encoding': ACCEPT_ENCODING, + }, + }, + (res) => { + res.on('error', (err) => { + Piano.revokeDelete(); + reject(err); + }); + + expect(res).toHaveProperty('statusCode', constants.HTTP_STATUS_NOT_FOUND); + Piano.revokeDelete(); + resolve(); + }, + ); + + req.on('error', (err) => { + Piano.revokeDelete(); + reject(err); + }); + + req.end(); + }); + }); + }); +});