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, schema: v.number(), }) }); 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); const backend = app .createBackend() .throws404OnDeletingNotFound(); server = backend.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', () => { beforeEach(() => { Piano.canFetchCollection(); }); afterEach(() => { Piano.canFetchCollection(false); }); it('returns data', () => { return new Promise((resolve, reject) => { const req = request( { host: HOST, port: PORT, path: '/api/pianos', method: 'GET', headers: { 'Accept': ACCEPT, 'Accept-Encoding': ACCEPT_ENCODING, }, }, (res) => { res.on('error', (err) => { 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([]); resolve(); }); }, ); req.on('error', (err) => { 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)); }); beforeEach(() => { Piano.canFetchItem(); }); afterEach(() => { Piano.canFetchItem(false); }); it('returns data', () => { return new Promise((resolve, reject) => { 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) => { 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); resolve(); }); }, ); req.on('error', (err) => { reject(err); }); req.end(); }); }); it('throws on item not found', () => { return new Promise((resolve, reject) => { 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.canFetchItem(false); reject(err); }); expect(res).toHaveProperty('statusCode', constants.HTTP_STATUS_NOT_FOUND); resolve(); }, ); req.on('error', (err) => { 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)); }); beforeEach(() => { Piano.canCreate(); }); afterEach(() => { Piano.canCreate(false); }); it('returns data', () => { return new Promise((resolve, reject) => { 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) => { 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 }); resolve(); }); }, ); req.on('error', (err) => { 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)); }); beforeEach(() => { Piano.canPatch(); }); afterEach(() => { Piano.canPatch(false); }); it('returns data', () => { return new Promise((resolve, reject) => { const req = request( { host: HOST, port: PORT, path: `/api/pianos/${data.id}`, method: 'PATCH', headers: { 'Accept': ACCEPT, 'Accept-Encoding': ACCEPT_ENCODING, 'Content-Type': ACCEPT, }, }, (res) => { res.on('error', (err) => { 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, }); resolve(); }); }, ); req.on('error', (err) => { reject(err); }); req.write(JSON.stringify(newData)); req.end(); }); }); it('throws on item to patch not found', () => { return new Promise((resolve, reject) => { 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) => { 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('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); }); it('returns data for replacement', () => { return new Promise((resolve, reject) => { const req = request( { host: HOST, port: PORT, path: `/api/pianos/${newData.id}`, method: 'PUT', headers: { 'Accept': ACCEPT, 'Accept-Encoding': ACCEPT_ENCODING, 'Content-Type': ACCEPT, }, }, (res) => { res.on('error', (err) => { 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); resolve(); }); }, ); req.on('error', (err) => { reject(err); }); req.write(JSON.stringify(newData)); req.end(); }); }); it('returns data for creation', () => { return new Promise((resolve, reject) => { const id = 2; 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) => { 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, }); resolve(); }); }, ); req.on('error', (err) => { 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)); }); beforeEach(() => { Piano.canDelete(); }); afterEach(() => { Piano.canDelete(false); }); it('returns data', () => { return new Promise((resolve, reject) => { const req = request( { host: HOST, port: PORT, path: `/api/pianos/${data.id}`, method: 'DELETE', headers: { 'Accept': ACCEPT, 'Accept-Encoding': ACCEPT_ENCODING, }, }, (res) => { res.on('error', (err) => { reject(err); }); expect(res).toHaveProperty('statusCode', constants.HTTP_STATUS_NO_CONTENT); resolve(); }, ); req.on('error', (err) => { reject(err); }); req.end(); }); }); it('throws on item not found', () => { return new Promise((resolve, reject) => { 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) => { reject(err); }); expect(res).toHaveProperty('statusCode', constants.HTTP_STATUS_NOT_FOUND); resolve(); }, ); req.on('error', (err) => { reject(err); }); req.end(); }); }); }); });