diff --git a/TODO.md b/TODO.md index 6647ffc..9c579f6 100644 --- a/TODO.md +++ b/TODO.md @@ -16,6 +16,7 @@ - [X] Date/Datetime handling (endpoints should be able to accept timestamps and ISO date/datetime strings) - [ ] Querying items in collections - [ ] Better URL parsing for determining target resource/resource IDs (e.g. `/api/users/3/posts/5`, `/users/3` is a query, `posts` is the target resource, `5` is the target resource ID. Different case with `/api/users/3/posts/5/attachments`) + - [ ] Declare relationship (e.g. `/users/3/posts`) - [ ] Tests - [X] Happy path - [ ] Error handling diff --git a/src/backend/core.ts b/src/backend/core.ts index 45e7081..954ca96 100644 --- a/src/backend/core.ts +++ b/src/backend/core.ts @@ -12,9 +12,9 @@ export interface Backend { dataSource?: (resource: Resource) => T; } -export interface CreateBackendParams { +export interface CreateBackendParams { app: ApplicationState; - dataSource: DataSource; + dataSource: T; } export const createBackend = (params: CreateBackendParams) => { diff --git a/src/backend/servers/http/core.ts b/src/backend/servers/http/core.ts index 9bbf997..cb67404 100644 --- a/src/backend/servers/http/core.ts +++ b/src/backend/servers/http/core.ts @@ -279,7 +279,7 @@ export const createServer = (backendState: BackendState, serverParams = {} as Cr } if (typeof resource.dataSource === 'undefined') { - throw new ErrorPlainResponse('unableToInitializeResourceDataSource', { + throw new ErrorPlainResponse('unableToBindResourceDataSource', { statusCode: constants.HTTP_STATUS_INTERNAL_SERVER_ERROR, res: theRes, }); @@ -362,16 +362,21 @@ export const createServer = (backendState: BackendState, serverParams = {} as Cr resourceReq.resource!.state.itemName) ?? ''; res.writeHead(constants.HTTP_STATUS_INTERNAL_SERVER_ERROR); res.end(); + return; } headers['Content-Type'] = [ resourceReq.backend.cn.mediaType.name, - `charset=${resourceReq.backend.cn.charset.name}`, - ].join('; '); - - const statusMessageKey = finalErr.statusMessage ? resourceReq.backend.cn.language.statusMessages[finalErr.statusMessage] : undefined; - res.statusMessage = statusMessageKey?.replace(/\$RESOURCE/g, resourceReq.resource!.state.itemName) ?? ''; - res.writeHead(finalErr.statusCode, headers); + typeof serialized !== 'undefined' ? `charset=${resourceReq.backend.cn.charset.name}` : '', + ] + .filter((s) => s.length > 0) + .join('; '); + + res.statusMessage = resourceReq.backend.cn.language.statusMessages[ + finalErr.statusMessage ?? 'internalServerError' + ]?.replace(/\$RESOURCE/g, + resourceReq.resource!.state.itemName); + res.writeHead(finalErr.statusCode ?? constants.HTTP_STATUS_INTERNAL_SERVER_ERROR, headers); if (typeof encoded !== 'undefined') { res.end(encoded); return; diff --git a/src/backend/servers/http/handlers/resource.ts b/src/backend/servers/http/handlers/resource.ts index 21e1eff..46c4471 100644 --- a/src/backend/servers/http/handlers/resource.ts +++ b/src/backend/servers/http/handlers/resource.ts @@ -2,6 +2,7 @@ import { constants } from 'http2'; import * as v from 'valibot'; import {Middleware} from '../../../common'; import {ErrorPlainResponse, PlainResponse} from '../response'; +import assert from 'assert'; export const handleGetCollection: Middleware = async (req, res) => { const { query, resource, backend } = req; @@ -39,18 +40,22 @@ export const handleGetCollection: Middleware = async (req, res) => { }); }; +const isResourceIdDefined = (resourceId?: string): resourceId is string => !( + typeof resourceId === 'undefined' || resourceId.trim().length < 1 +); + export const handleGetItem: Middleware = async (req, res) => { const { resource, resourceId } = req; - - if (typeof resourceId === 'undefined' || resourceId.trim().length < 1) { - throw new ErrorPlainResponse( + assert( + isResourceIdDefined(resourceId), + new ErrorPlainResponse( 'resourceIdNotGiven', { statusCode: constants.HTTP_STATUS_BAD_REQUEST, res, } - ); - } + ) + ); let data: v.Output | null = null; try { @@ -184,17 +189,21 @@ export const handlePatchItem: Middleware = async (req, res) => { }); }; +const isIdAttributeDefined = (idAttr?: unknown): idAttr is string => ( + typeof idAttr !== 'undefined' +); + export const handleCreateItem: Middleware = async (req, res) => { const { resource, body, backend, basePath } = req; - const idAttrRaw = resource.state.shared.get('idAttr'); - if (typeof idAttrRaw === 'undefined') { - throw new ErrorPlainResponse('unableToGenerateIdFromResourceDataSource', { + const idAttr = resource.state.shared.get('idAttr'); + assert( + isIdAttributeDefined(idAttr), + new ErrorPlainResponse('unableToGenerateIdFromResourceDataSource', { statusCode: constants.HTTP_STATUS_INTERNAL_SERVER_ERROR, res, - }); - } - const idAttr = idAttrRaw as string; + }) + ); let newId; let params: v.Output; @@ -203,7 +212,7 @@ export const handleCreateItem: Middleware = async (req, res) => { params = { ...body as Record }; params[idAttr] = newId; } catch (cause) { - throw new ErrorPlainResponse('unableToGenerateIdFromResourceDataSource', { + throw new ErrorPlainResponse('unableToAssignIdFromResourceDataSource', { cause, statusCode: constants.HTTP_STATUS_INTERNAL_SERVER_ERROR, res, @@ -220,10 +229,11 @@ export const handleCreateItem: Middleware = async (req, res) => { let totalItemCount: number | undefined; try { - newObject = await resource.dataSource.create(params); if (backend!.showTotalItemCountOnCreateItem && typeof resource.dataSource.getTotalCount === 'function') { totalItemCount = await resource.dataSource.getTotalCount(); + totalItemCount += 1; } + newObject = await resource.dataSource.create(params); } catch (cause) { throw new ErrorPlainResponse('unableToCreateResource', { cause, diff --git a/src/common/app.ts b/src/common/app.ts index 04d7cc3..f4c7e31 100644 --- a/src/common/app.ts +++ b/src/common/app.ts @@ -49,7 +49,7 @@ export interface Application< export const application = (appParams: ApplicationParams): Application => { const appState: ApplicationState = { name: appParams.name, - resources: new Set>(), + resources: new Set(), languages: new Map([ [FALLBACK_LANGUAGE.name, FALLBACK_LANGUAGE], ]), diff --git a/src/common/language.ts b/src/common/language.ts index 52e5489..4595929 100644 --- a/src/common/language.ts +++ b/src/common/language.ts @@ -17,7 +17,9 @@ export const LANGUAGE_DEFAULT_STATUS_MESSAGE_KEYS = [ 'resourceNotFound', 'deleteNonExistingResource', 'unableToCreateResource', + 'unableToBindResourceDataSource', 'unableToGenerateIdFromResourceDataSource', + 'unableToAssignIdFromResourceDataSource', 'unableToEmplaceResource', 'unableToSerializeResponse', 'unableToEncodeResponse', @@ -35,6 +37,7 @@ export const LANGUAGE_DEFAULT_STATUS_MESSAGE_KEYS = [ 'resourceReplaced', 'notImplemented', 'provideOptions', + 'internalServerError', ] as const; export type LanguageDefaultStatusMessageKey = typeof LANGUAGE_DEFAULT_STATUS_MESSAGE_KEYS[number]; @@ -62,6 +65,7 @@ export const FALLBACK_LANGUAGE = { statusMessages: { unableToSerializeResponse: 'Unable To Serialize Response', unableToEncodeResponse: 'Unable To Encode Response', + unableToBindResourceDataSource: 'Unable To Bind $RESOURCE Data Source', unableToInitializeResourceDataSource: 'Unable To Initialize $RESOURCE Data Source', unableToFetchResourceCollection: 'Unable To Fetch $RESOURCE Collection', unableToFetchResource: 'Unable To Fetch $RESOURCE', @@ -90,10 +94,12 @@ export const FALLBACK_LANGUAGE = { resourceCreated: '$RESOURCE Created', resourceReplaced: '$RESOURCE Replaced', unableToGenerateIdFromResourceDataSource: 'Unable To Generate ID From $RESOURCE Data Source', + unableToAssignIdFromResourceDataSource: 'Unable To Assign ID From $RESOURCE Data Source', unableToEmplaceResource: 'Unable To Emplace $RESOURCE', resourceIdNotGiven: '$RESOURCE ID Not Given', unableToCreateResource: 'Unable To Create $RESOURCE', - notImplemented: 'Not Implemented' + notImplemented: 'Not Implemented', + internalServerError: 'Internal Server Error', }, bodies: { languageNotAcceptable: [], diff --git a/src/common/resource.ts b/src/common/resource.ts index 8bcc13f..d837607 100644 --- a/src/common/resource.ts +++ b/src/common/resource.ts @@ -1,10 +1,12 @@ import * as v from 'valibot'; +import {BaseSchema} from 'valibot'; export interface ResourceState< ItemName extends string = string, RouteName extends string = string > { shared: Map; + relationships: Set; itemName: ItemName; routeName: RouteName; canCreate: boolean; @@ -30,6 +32,7 @@ export interface Resource< canPatch(b?: boolean): this; canEmplace(b?: boolean): this; canDelete(b?: boolean): this; + relatedTo(resource: Resource): this; } export const resource = < @@ -39,6 +42,7 @@ export const resource = < >(schema: Schema): Resource => { const resourceState = { shared: new Map(), + relationships: new Set(), canCreate: false, canFetchCollection: false, canFetchItem: false, @@ -105,6 +109,10 @@ export const resource = < get schema() { return schema; }, + relatedTo(resource: Resource) { + resourceState.relationships.add(resource); + return this; + }, } as Resource; }; diff --git a/test/e2e/http.test.ts b/test/e2e/http.test.ts deleted file mode 100644 index b505b8e..0000000 --- a/test/e2e/http.test.ts +++ /dev/null @@ -1,1089 +0,0 @@ -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 {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'; - -const PORT = 3000; -const HOST = '127.0.0.1'; -const BASE_PATH = '/api'; -const ACCEPT = 'application/json'; -const ACCEPT_LANGUAGE = 'en'; -const CONTENT_TYPE_CHARSET = 'utf-8'; -const CONTENT_TYPE = ACCEPT; - -describe('yasumi HTTP', () => { - 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('happy path', () => { - describe('serving collections', () => { - beforeEach(() => { - Piano.canFetchCollection(); - return new Promise((resolve) => { - setTimeout(() => { - resolve(); - }); - }); - }); - - afterEach(() => { - Piano.canFetchCollection(false); - }); - - it('returns options', () => { - return new Promise((resolve, reject) => { - const req = request( - { - host: HOST, - port: PORT, - path: `${BASE_PATH}/pianos`, - method: 'OPTIONS', - headers: { - 'Accept': ACCEPT, - 'Accept-Language': ACCEPT_LANGUAGE, - }, - }, - (res) => { - res.on('error', (err) => { - reject(err); - }); - - expect(res).toHaveProperty('statusCode', constants.HTTP_STATUS_NO_CONTENT); - const allowedMethods = res.headers.allow?.split(',').map((s) => s.trim()) ?? []; - expect(allowedMethods).toContain('GET'); - expect(allowedMethods).toContain('HEAD'); - resolve(); - }, - ); - - req.on('error', (err) => { - reject(err); - }); - - req.end(); - }); - }); - - it('returns data', () => { - return new Promise((resolve, reject) => { - const req = request( - { - host: HOST, - port: PORT, - path: `${BASE_PATH}/pianos`, - method: 'GET', - headers: { - 'Accept': ACCEPT, - 'Accept-Language': ACCEPT_LANGUAGE, - }, - }, - (res) => { - res.on('error', (err) => { - reject(err); - }); - - expect(res).toHaveProperty('statusCode', constants.HTTP_STATUS_OK); - // TODO test status messsages - expect(res.headers).toHaveProperty('content-type', expect.stringContaining(ACCEPT)); - - let resBuffer = Buffer.from(''); - res.on('data', (c) => { - resBuffer = Buffer.concat([resBuffer, c]); - }); - - res.on('close', () => { - const resBufferJson = resBuffer.toString(CONTENT_TYPE_CHARSET); - const resData = JSON.parse(resBufferJson); - expect(resData).toEqual([]); - resolve(); - }); - }, - ); - - req.on('error', (err) => { - reject(err); - }); - - req.end(); - }); - }); - - it('returns data on HEAD method', () => { - return new Promise((resolve, reject) => { - const req = request( - { - host: HOST, - port: PORT, - path: `${BASE_PATH}/pianos`, - method: 'HEAD', - headers: { - 'Accept': ACCEPT, - 'Accept-Language': ACCEPT_LANGUAGE, - }, - }, - (res) => { - res.on('error', (err) => { - reject(err); - }); - - expect(res).toHaveProperty('statusCode', constants.HTTP_STATUS_OK); - resolve(); - }, - ); - - req.on('error', (err) => { - reject(err); - }); - - req.end(); - }); - }); - }); - - describe('serving items', () => { - const existingResource = { - id: 1, - brand: 'Yamaha' - }; - - beforeEach(async () => { - const resourcePath = join(baseDir, 'pianos.jsonl'); - await writeFile(resourcePath, JSON.stringify(existingResource)); - }); - - beforeEach(() => { - Piano.canFetchItem(); - return new Promise((resolve) => { - setTimeout(() => { - resolve(); - }); - }); - }); - - afterEach(() => { - Piano.canFetchItem(false); - }); - - it('returns data', () => { - return new Promise((resolve, reject) => { - // TODO all responses should have serialized ids - const req = request( - { - host: HOST, - port: PORT, - path: `${BASE_PATH}/pianos/${existingResource.id}`, - method: 'GET', - headers: { - 'Accept': ACCEPT, - 'Accept-Language': ACCEPT_LANGUAGE, - }, - }, - (res) => { - res.on('error', (err) => { - reject(err); - }); - - expect(res).toHaveProperty('statusCode', constants.HTTP_STATUS_OK); - expect(res.headers).toHaveProperty('content-type', expect.stringContaining(ACCEPT)); - - let resBuffer = Buffer.from(''); - res.on('data', (c) => { - resBuffer = Buffer.concat([resBuffer, c]); - }); - - res.on('close', () => { - const resBufferJson = resBuffer.toString(CONTENT_TYPE_CHARSET); - const resData = JSON.parse(resBufferJson); - expect(resData).toEqual(existingResource); - resolve(); - }); - }, - ); - - req.on('error', (err) => { - reject(err); - }); - - req.end(); - }); - }); - - it('returns data on HEAD method', () => { - return new Promise((resolve, reject) => { - // TODO all responses should have serialized ids - const req = request( - { - host: HOST, - port: PORT, - path: `${BASE_PATH}/pianos/${existingResource.id}`, - method: 'HEAD', - headers: { - 'Accept': ACCEPT, - 'Accept-Language': ACCEPT_LANGUAGE, - }, - }, - (res) => { - res.on('error', (err) => { - reject(err); - }); - - expect(res).toHaveProperty('statusCode', constants.HTTP_STATUS_OK); - resolve(); - }, - ); - - req.on('error', (err) => { - reject(err); - }); - - req.end(); - }); - }); - - it('returns options', () => { - return new Promise((resolve, reject) => { - const req = request( - { - host: HOST, - port: PORT, - path: `${BASE_PATH}/pianos/${existingResource.id}`, - method: 'OPTIONS', - headers: { - 'Accept': ACCEPT, - 'Accept-Language': ACCEPT_LANGUAGE, - }, - }, - (res) => { - res.on('error', (err) => { - reject(err); - }); - - expect(res).toHaveProperty('statusCode', constants.HTTP_STATUS_NO_CONTENT); - const allowedMethods = res.headers.allow?.split(',').map((s) => s.trim()) ?? []; - expect(allowedMethods).toContain('GET'); - expect(allowedMethods).toContain('HEAD'); - resolve(); - }, - ); - - req.on('error', (err) => { - reject(err); - }); - - req.end(); - }); - }); - }); - - describe('creating items', () => { - const existingResource = { - id: 1, - brand: 'Yamaha' - }; - - const newResourceData = { - brand: 'K. Kawai' - }; - - beforeEach(async () => { - const resourcePath = join(baseDir, 'pianos.jsonl'); - await writeFile(resourcePath, JSON.stringify(existingResource)); - }); - - beforeEach(() => { - Piano.canCreate(); - }); - - afterEach(() => { - Piano.canCreate(false); - }); - - it('returns data', () => { - return new Promise((resolve, reject) => { - const req = request( - { - host: HOST, - port: PORT, - path: `${BASE_PATH}/pianos`, - method: 'POST', - 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_CREATED); - expect(res.headers).toHaveProperty('content-type', expect.stringContaining(ACCEPT)); - - let resBuffer = Buffer.from(''); - res.on('data', (c) => { - resBuffer = Buffer.concat([resBuffer, c]); - }); - - res.on('close', () => { - const resBufferJson = resBuffer.toString(CONTENT_TYPE_CHARSET); - const resData = JSON.parse(resBufferJson); - expect(resData).toEqual({ - ...newResourceData, - id: 2 - }); - - resolve(); - }); - }, - ); - - req.on('error', (err) => { - reject(err); - }); - - req.write(JSON.stringify(newResourceData)); - req.end(); - }); - }); - - it('returns options', () => { - return new Promise((resolve, reject) => { - const req = request( - { - host: HOST, - port: PORT, - path: `${BASE_PATH}/pianos`, - method: 'OPTIONS', - headers: { - 'Accept': ACCEPT, - 'Accept-Language': ACCEPT_LANGUAGE, - }, - }, - (res) => { - res.on('error', (err) => { - reject(err); - }); - - expect(res).toHaveProperty('statusCode', constants.HTTP_STATUS_NO_CONTENT); - const allowedMethods = res.headers.allow?.split(',').map((s) => s.trim()) ?? []; - expect(allowedMethods).toContain('POST'); - resolve(); - }, - ); - - req.on('error', (err) => { - reject(err); - }); - - req.end(); - }); - }); - }); - - describe('patching items', () => { - const existingResource = { - id: 1, - brand: 'Yamaha' - }; - - const patchData = { - brand: 'K. Kawai' - }; - - beforeEach(async () => { - const resourcePath = join(baseDir, 'pianos.jsonl'); - await writeFile(resourcePath, JSON.stringify(existingResource)); - }); - - beforeEach(() => { - Piano.canPatch(); - }); - - afterEach(() => { - Piano.canPatch(false); - }); - - it('returns data', () => { - return new Promise((resolve, reject) => { - const req = request( - { - host: HOST, - port: PORT, - path: `${BASE_PATH}/pianos/${existingResource.id}`, - 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_OK); - expect(res.headers).toHaveProperty('content-type', expect.stringContaining(ACCEPT)); - - let resBuffer = Buffer.from(''); - res.on('data', (c) => { - resBuffer = Buffer.concat([resBuffer, c]); - }); - - res.on('close', () => { - const resBufferJson = resBuffer.toString(CONTENT_TYPE_CHARSET); - const resData = JSON.parse(resBufferJson); - expect(resData).toEqual({ - ...existingResource, - ...patchData, - }); - resolve(); - }); - }, - ); - - req.on('error', (err) => { - reject(err); - }); - - req.write(JSON.stringify(patchData)); - req.end(); - }); - }); - - it('returns options', () => { - return new Promise((resolve, reject) => { - const req = request( - { - host: HOST, - port: PORT, - path: `${BASE_PATH}/pianos/${existingResource.id}`, - method: 'OPTIONS', - headers: { - 'Accept': ACCEPT, - 'Accept-Language': ACCEPT_LANGUAGE, - }, - }, - (res) => { - res.on('error', (err) => { - reject(err); - }); - - expect(res).toHaveProperty('statusCode', constants.HTTP_STATUS_NO_CONTENT); - const allowedMethods = res.headers.allow?.split(',').map((s) => s.trim()) ?? []; - expect(allowedMethods).toContain('PATCH'); - resolve(); - }, - ); - - req.on('error', (err) => { - reject(err); - }); - - req.end(); - }); - }); - }); - - describe('emplacing items', () => { - const existingResource = { - id: 1, - brand: 'Yamaha' - }; - - const emplaceResourceData = { - id: 1, - brand: 'K. Kawai' - }; - - beforeEach(async () => { - const resourcePath = join(baseDir, 'pianos.jsonl'); - await writeFile(resourcePath, JSON.stringify(existingResource)); - }); - - 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: `${BASE_PATH}/pianos/${emplaceResourceData.id}`, - method: 'PUT', - 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_OK); - expect(res.headers).toHaveProperty('content-type', expect.stringContaining(ACCEPT)); - - let resBuffer = Buffer.from(''); - res.on('data', (c) => { - resBuffer = Buffer.concat([resBuffer, c]); - }); - - res.on('close', () => { - const resBufferJson = resBuffer.toString(CONTENT_TYPE_CHARSET); - const resData = JSON.parse(resBufferJson); - expect(resData).toEqual(emplaceResourceData); - resolve(); - }); - }, - ); - - req.on('error', (err) => { - reject(err); - }); - - req.write(JSON.stringify(emplaceResourceData)); - req.end(); - }); - }); - - it('returns data for creation', () => { - return new Promise((resolve, reject) => { - const newId = 2; - - const req = request( - { - host: HOST, - port: PORT, - path: `${BASE_PATH}/pianos/${newId}`, - method: 'PUT', - 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_CREATED); - expect(res.headers).toHaveProperty('content-type', expect.stringContaining(ACCEPT)); - - let resBuffer = Buffer.from(''); - res.on('data', (c) => { - resBuffer = Buffer.concat([resBuffer, c]); - }); - - res.on('close', () => { - const resBufferJson = resBuffer.toString(CONTENT_TYPE_CHARSET); - const resData = JSON.parse(resBufferJson); - expect(resData).toEqual({ - ...emplaceResourceData, - id: newId, - }); - resolve(); - }); - }, - ); - - req.on('error', (err) => { - reject(err); - }); - - req.write(JSON.stringify({ - ...emplaceResourceData, - id: newId, - })); - req.end(); - }); - }); - - it('returns options', () => { - return new Promise((resolve, reject) => { - const req = request( - { - host: HOST, - port: PORT, - path: `${BASE_PATH}/pianos/${existingResource.id}`, - method: 'OPTIONS', - headers: { - 'Accept': ACCEPT, - 'Accept-Language': ACCEPT_LANGUAGE, - }, - }, - (res) => { - res.on('error', (err) => { - reject(err); - }); - - expect(res).toHaveProperty('statusCode', constants.HTTP_STATUS_NO_CONTENT); - const allowedMethods = res.headers.allow?.split(',').map((s) => s.trim()) ?? []; - expect(allowedMethods).toContain('PUT'); - resolve(); - }, - ); - - req.on('error', (err) => { - reject(err); - }); - - req.end(); - }); - }); - }); - - describe('deleting items', () => { - const existingResource = { - id: 1, - brand: 'Yamaha' - }; - - beforeEach(async () => { - const resourcePath = join(baseDir, 'pianos.jsonl'); - await writeFile(resourcePath, JSON.stringify(existingResource)); - }); - - beforeEach(() => { - Piano.canDelete(); - }); - - afterEach(() => { - Piano.canDelete(false); - }); - - it('responds', () => { - return new Promise((resolve, reject) => { - const req = request( - { - host: HOST, - port: PORT, - path: `${BASE_PATH}/pianos/${existingResource.id}`, - method: 'DELETE', - headers: { - 'Accept': ACCEPT, - 'Accept-Language': ACCEPT_LANGUAGE, - }, - }, - (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('returns options', () => { - return new Promise((resolve, reject) => { - const req = request( - { - host: HOST, - port: PORT, - path: `${BASE_PATH}/pianos/${existingResource.id}`, - method: 'OPTIONS', - headers: { - 'Accept': ACCEPT, - 'Accept-Language': ACCEPT_LANGUAGE, - }, - }, - (res) => { - res.on('error', (err) => { - reject(err); - }); - - expect(res).toHaveProperty('statusCode', constants.HTTP_STATUS_NO_CONTENT); - const allowedMethods = res.headers.allow?.split(',').map((s) => s.trim()) ?? []; - expect(allowedMethods).toContain('DELETE'); - resolve(); - }, - ); - - req.on('error', (err) => { - reject(err); - }); - - req.end(); - }); - }); - }); - }); - - describe('error handling', () => { - 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', () => { - return new Promise((resolve, reject) => { - const req = request( - { - host: HOST, - port: PORT, - path: `${BASE_PATH}/pianos/2`, - method: 'GET', - headers: { - 'Accept': ACCEPT, - 'Accept-Language': ACCEPT_LANGUAGE, - }, - }, - (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(); - }); - }); - - it('throws on item not found on HEAD method', () => { - return new Promise((resolve, reject) => { - const req = request( - { - host: HOST, - port: PORT, - path: `${BASE_PATH}/pianos/2`, - method: 'HEAD', - headers: { - 'Accept': ACCEPT, - 'Accept-Language': ACCEPT_LANGUAGE, - }, - }, - (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.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('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('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(); - }); - }); - }); - }); -}); diff --git a/test/e2e/http/default.test.ts b/test/e2e/http/default.test.ts new file mode 100644 index 0000000..c123031 --- /dev/null +++ b/test/e2e/http/default.test.ts @@ -0,0 +1,478 @@ +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 {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'; + +const PORT = 3000; +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; + +describe('happy path', () => { + 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); + }); + + 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('serving collections', () => { + beforeEach(() => { + Piano.canFetchCollection(); + return new Promise((resolve) => { + setTimeout(() => { + resolve(); + }); + }); + }); + + afterEach(() => { + Piano.canFetchCollection(false); + }); + + it('returns data', async () => { + const [res, resData] = await client({ + method: 'GET', + path: `${BASE_PATH}/pianos`, + }); + + expect(res).toHaveProperty('statusCode', constants.HTTP_STATUS_OK); + // TODO test status messages + expect(res.headers).toHaveProperty('content-type', expect.stringContaining(ACCEPT)); + + if (typeof resData === 'undefined') { + expect.fail('Response body must be defined.'); + return; + } + + expect(resData).toEqual([]); + }); + + it('returns data on HEAD method', async () => { + const [res] = await client({ + method: 'HEAD', + path: `${BASE_PATH}/pianos`, + }); + + expect(res).toHaveProperty('statusCode', constants.HTTP_STATUS_OK); + }); + + it('returns options', async () => { + const [res] = await client({ + method: 'OPTIONS', + path: `${BASE_PATH}/pianos`, + }); + + expect(res).toHaveProperty('statusCode', constants.HTTP_STATUS_NO_CONTENT); + const allowedMethods = res.headers.allow?.split(',').map((s) => s.trim()) ?? []; + expect(allowedMethods).toContain('GET'); + expect(allowedMethods).toContain('HEAD'); + }); + }); + + describe('serving items', () => { + const existingResource = { + id: 1, + brand: 'Yamaha' + }; + + beforeEach(async () => { + const resourcePath = join(baseDir, 'pianos.jsonl'); + await writeFile(resourcePath, JSON.stringify(existingResource)); + }); + + beforeEach(() => { + Piano.canFetchItem(); + return new Promise((resolve) => { + setTimeout(() => { + resolve(); + }); + }); + }); + + afterEach(() => { + Piano.canFetchItem(false); + }); + + it('returns data', async () => { + const [res, resData] = await client({ + method: 'GET', + path: `${BASE_PATH}/pianos/${existingResource.id}`, + }); + + expect(res).toHaveProperty('statusCode', constants.HTTP_STATUS_OK); + expect(res.headers).toHaveProperty('content-type', expect.stringContaining(ACCEPT)); + if (typeof resData === 'undefined') { + expect.fail('Response body must be defined.'); + return; + } + + expect(resData).toEqual(existingResource); + }); + + it('returns data on HEAD method', async () => { + const [res] = await client({ + method: 'HEAD', + path: `${BASE_PATH}/pianos/${existingResource.id}`, + }); + + expect(res).toHaveProperty('statusCode', constants.HTTP_STATUS_OK); + }); + + it('returns options', async () => { + const [res] = await client({ + method: 'OPTIONS', + path: `${BASE_PATH}/pianos/${existingResource.id}`, + }); + + expect(res).toHaveProperty('statusCode', constants.HTTP_STATUS_NO_CONTENT); + const allowedMethods = res.headers.allow?.split(',').map((s) => s.trim()) ?? []; + expect(allowedMethods).toContain('GET'); + expect(allowedMethods).toContain('HEAD'); + }); + }); + + describe('creating items', () => { + const existingResource = { + id: 1, + brand: 'Yamaha' + }; + + const newResourceData = { + brand: 'K. Kawai' + }; + + beforeEach(async () => { + const resourcePath = join(baseDir, 'pianos.jsonl'); + await writeFile(resourcePath, JSON.stringify(existingResource)); + }); + + beforeEach(() => { + Piano.canCreate(); + }); + + afterEach(() => { + Piano.canCreate(false); + }); + + it('returns data', async () => { + const [res, resData] = await client({ + path: `${BASE_PATH}/pianos`, + method: 'POST', + body: newResourceData, + }); + + expect(res).toHaveProperty('statusCode', constants.HTTP_STATUS_CREATED); + expect(res.headers).toHaveProperty('content-type', expect.stringContaining(ACCEPT)); + expect(res.headers).toHaveProperty('location', `${BASE_PATH}/pianos/2`); + + if (typeof resData === 'undefined') { + expect.fail('Response body must be defined.'); + return; + } + + expect(resData).toEqual({ + ...newResourceData, + id: 2 + }); + }); + + it('returns options', async () => { + const [res] = await client({ + method: 'OPTIONS', + path: `${BASE_PATH}/pianos`, + }); + + expect(res).toHaveProperty('statusCode', constants.HTTP_STATUS_NO_CONTENT); + const allowedMethods = res.headers.allow?.split(',').map((s) => s.trim()) ?? []; + expect(allowedMethods).toContain('POST'); + }); + }); + + describe('patching items', () => { + const existingResource = { + id: 1, + brand: 'Yamaha' + }; + + const patchData = { + brand: 'K. Kawai' + }; + + beforeEach(async () => { + const resourcePath = join(baseDir, 'pianos.jsonl'); + await writeFile(resourcePath, JSON.stringify(existingResource)); + }); + + beforeEach(() => { + Piano.canPatch(); + }); + + afterEach(() => { + Piano.canPatch(false); + }); + + it('returns data', async () => { + const [res, resData] = await client({ + method: 'PATCH', + path: `${BASE_PATH}/pianos/${existingResource.id}`, + body: patchData, + }); + + expect(res).toHaveProperty('statusCode', constants.HTTP_STATUS_OK); + expect(res.headers).toHaveProperty('content-type', expect.stringContaining(ACCEPT)); + + if (typeof resData === 'undefined') { + expect.fail('Response body must be defined.'); + return; + } + + expect(resData).toEqual({ + ...existingResource, + ...patchData, + }); + }); + + it('returns options', async () => { + const [res] = await client({ + method: 'OPTIONS', + path: `${BASE_PATH}/pianos/${existingResource.id}`, + }); + + expect(res).toHaveProperty('statusCode', constants.HTTP_STATUS_NO_CONTENT); + const allowedMethods = res.headers.allow?.split(',').map((s) => s.trim()) ?? []; + expect(allowedMethods).toContain('PATCH'); + }); + }); + + describe('emplacing items', () => { + const existingResource = { + id: 1, + brand: 'Yamaha' + }; + + const emplaceResourceData = { + id: 1, + brand: 'K. Kawai' + }; + + beforeEach(async () => { + const resourcePath = join(baseDir, 'pianos.jsonl'); + await writeFile(resourcePath, JSON.stringify(existingResource)); + }); + + beforeEach(() => { + Piano.canEmplace(); + }); + + afterEach(() => { + Piano.canEmplace(false); + }); + + it('returns data for replacement', async () => { + const [res, resData] = await client({ + method: 'PUT', + path: `${BASE_PATH}/pianos/${emplaceResourceData.id}`, + body: emplaceResourceData, + }); + + expect(res).toHaveProperty('statusCode', constants.HTTP_STATUS_OK); + expect(res.headers).toHaveProperty('content-type', expect.stringContaining(ACCEPT)); + + if (typeof resData === 'undefined') { + expect.fail('Response body must be defined.'); + return; + } + + expect(resData).toEqual(emplaceResourceData); + }); + + it('returns data for creation', async () => { + const newId = 2; + + const [res, resData] = await client({ + method: 'PUT', + path: `${BASE_PATH}/pianos/${newId}`, + body: { + ...emplaceResourceData, + id: newId, + }, + }); + + expect(res).toHaveProperty('statusCode', constants.HTTP_STATUS_CREATED); + expect(res.headers).toHaveProperty('content-type', expect.stringContaining(ACCEPT)); + expect(res.headers).toHaveProperty('location', `${BASE_PATH}/pianos/${newId}`); + + if (typeof resData === 'undefined') { + expect.fail('Response body must be defined.'); + return; + } + + expect(resData).toEqual({ + ...emplaceResourceData, + id: newId, + }); + }); + + it('returns options', async () => { + const [res] = await client({ + method: 'OPTIONS', + path: `${BASE_PATH}/pianos/${existingResource.id}`, + }); + + expect(res).toHaveProperty('statusCode', constants.HTTP_STATUS_NO_CONTENT); + const allowedMethods = res.headers.allow?.split(',').map((s) => s.trim()) ?? []; + expect(allowedMethods).toContain('PUT'); + }); + }); + + describe('deleting items', () => { + const existingResource = { + id: 1, + brand: 'Yamaha' + }; + + beforeEach(async () => { + const resourcePath = join(baseDir, 'pianos.jsonl'); + await writeFile(resourcePath, JSON.stringify(existingResource)); + }); + + beforeEach(() => { + Piano.canDelete(); + }); + + afterEach(() => { + Piano.canDelete(false); + }); + + it('responds', async () => { + const [res, resData] = await client({ + method: 'DELETE', + path: `${BASE_PATH}/pianos/${existingResource.id}`, + }); + + expect(res).toHaveProperty('statusCode', constants.HTTP_STATUS_NO_CONTENT); + expect(res.headers).not.toHaveProperty('content-type'); + expect(resData).toBeUndefined(); + }); + + it('returns options', async () => { + const [res] = await client({ + method: 'OPTIONS', + path: `${BASE_PATH}/pianos/${existingResource.id}`, + }); + + expect(res).toHaveProperty('statusCode', constants.HTTP_STATUS_NO_CONTENT); + const allowedMethods = res.headers.allow?.split(',').map((s) => s.trim()) ?? []; + expect(allowedMethods).toContain('DELETE'); + }); + }); +}); diff --git a/test/e2e/http/error-handling.test.ts b/test/e2e/http/error-handling.test.ts new file mode 100644 index 0000000..ecaa321 --- /dev/null +++ b/test/e2e/http/error-handling.test.ts @@ -0,0 +1,764 @@ +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(); + }); + }); + }); + }); +}); diff --git a/test/utils.ts b/test/utils.ts new file mode 100644 index 0000000..aeed614 --- /dev/null +++ b/test/utils.ts @@ -0,0 +1,143 @@ +import {IncomingMessage, OutgoingHttpHeaders, request, RequestOptions} from 'http'; +import {Method} from '../src/backend/common'; + +interface ClientParams { + method: Method; + path: string; + headers?: OutgoingHttpHeaders; + body?: unknown; +} + +type ResponseBody = Buffer | string | object; + +export interface TestClient { + (params: ClientParams): Promise<[IncomingMessage, ResponseBody?]>; + acceptMediaType(mediaType: string): this; + acceptLanguage(language: string): this; + acceptCharset(charset: string): this; + contentType(mediaType: string): this; + contentCharset(charset: string): this; +} + +export const createTestClient = (options: Omit): TestClient => { + const additionalHeaders: OutgoingHttpHeaders = {}; + const client = (params: ClientParams) => new Promise<[IncomingMessage, ResponseBody?]>((resolve, reject) => { + const { + 'content-type': contentTypeHeader, + ...etcAdditionalHeaders + } = additionalHeaders; + + const headers: OutgoingHttpHeaders = { + ...(options.headers ?? {}), + ...(params.headers ?? {}), + ...etcAdditionalHeaders, + }; + + if (typeof params.body !== 'undefined') { + headers['content-type'] = contentTypeHeader; + } + + const req = request({ + ...options, + method: params.method, + path: params.path, + headers, + }); + + req.on('response', (res) => { + res.on('error', (err) => { + reject(err); + }); + + let resBuffer: Buffer | undefined; + res.on('data', (c) => { + resBuffer = ( + typeof resBuffer === 'undefined' + ? Buffer.from(c) + : Buffer.concat([resBuffer, c]) + ); + }); + + res.on('close', () => { + const acceptHeader = Array.isArray(headers['accept']) ? headers['accept'].join('; ') : headers['accept']; + const contentTypeBase = acceptHeader ?? 'application/octet-stream'; + const [type, subtype] = contentTypeBase.split('/'); + const allSubtypes = subtype.split('+'); + if (typeof resBuffer !== 'undefined') { + if (allSubtypes.includes('json')) { + const acceptCharset = ( + Array.isArray(headers['accept-charset']) + ? headers['accept-charset'].join('; ') + : headers['accept-charset'] + ) as BufferEncoding | undefined; + resolve([res, JSON.parse(resBuffer.toString(acceptCharset ?? 'utf-8'))]); + return; + } + + if (type === 'text') { + const acceptCharset = ( + Array.isArray(headers['accept-charset']) + ? headers['accept-charset'].join('; ') + : headers['accept-charset'] + ) as BufferEncoding | undefined; + resolve([res, resBuffer.toString(acceptCharset ?? 'utf-8')]); + return; + } + + resolve([res, resBuffer]); + return; + } + + resolve([res]); + }); + }); + + req.on('error', (err) => { + reject(err); + }) + + if (typeof params.body !== 'undefined') { + const theContentTypeHeader = Array.isArray(contentTypeHeader) ? contentTypeHeader.join('; ') : contentTypeHeader?.toString(); + const contentTypeAll = theContentTypeHeader ?? 'application/octet-stream'; + const [contentTypeBase, ...contentTypeParams] = contentTypeAll.split(';').map((s) => s.replace(/\s+/g, '').trim()); + const charsetParam = contentTypeParams.find((s) => s.startsWith('charset=')); + const charset = charsetParam?.split('=')?.[1] as BufferEncoding | undefined; + const [, subtype] = contentTypeBase.split('/'); + const allSubtypes = subtype.split('+'); + req.write( + allSubtypes.includes('json') + ? JSON.stringify(params.body) + : Buffer.from(params.body?.toString() ?? '', contentTypeBase === 'text' ? charset : undefined) + ); + } + + req.end(); + }); + + client.acceptMediaType = function acceptMediaType(mediaType: string) { + additionalHeaders['accept'] = mediaType; + return this; + }; + + client.acceptLanguage = function acceptLanguage(language: string) { + additionalHeaders['accept-language'] = language; + return this; + }; + + client.acceptCharset = function acceptCharset(charset: string) { + additionalHeaders['accept-charset'] = charset; + return this; + }; + + client.contentType = function contentType(mediaType: string) { + additionalHeaders['content-type'] = mediaType; + return this; + }; + + client.contentCharset = function contentCharset(charset: string) { + additionalHeaders['content-type'] = `${additionalHeaders['content-type']}; charset="${charset}"`; + return this; + }; + + return client; +};