From de6834773b4b0f3d044d8fc64242e76cdfb06ac0 Mon Sep 17 00:00:00 2001 From: TheoryOfNekomata Date: Thu, 4 Apr 2024 06:34:31 +0800 Subject: [PATCH] Update handler logic Remove duplicate checks and add OPTIONS method handler for allowed methods. --- src/backend/common.ts | 13 +- src/backend/http/handlers/default.ts | 57 + .../{handlers.ts => handlers/resource.ts} | 136 +- src/backend/http/server.ts | 34 +- test/e2e/default.test.ts | 765 ------------ test/e2e/http.test.ts | 1103 +++++++++++++++++ 6 files changed, 1203 insertions(+), 905 deletions(-) create mode 100644 src/backend/http/handlers/default.ts rename src/backend/http/{handlers.ts => handlers/resource.ts} (65%) delete mode 100644 test/e2e/default.test.ts create mode 100644 test/e2e/http.test.ts diff --git a/src/backend/common.ts b/src/backend/common.ts index 2f6d7e0..0ebed9b 100644 --- a/src/backend/common.ts +++ b/src/backend/common.ts @@ -1,5 +1,7 @@ -import {ApplicationState, ContentNegotiation} from '../common'; +import {ApplicationState, ContentNegotiation, Resource} from '../common'; import {DataSource} from './data-source'; +import {BaseSchema} from 'valibot'; +import {Middleware} from './http/server'; export interface BackendState { app: ApplicationState; @@ -16,3 +18,12 @@ export interface RequestContext {} export type RequestDecorator = (req: RequestContext) => RequestContext | Promise; export type ParamRequestDecorator = []> = (...args: Params) => RequestDecorator; + +export type Method = 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE' | 'OPTIONS' | 'HEAD'; + +export interface AllowedMiddlewareSpecification { + method: Method; + middleware: Middleware; + constructBodySchema?: (resource: Resource, resourceId?: string) => BaseSchema; + allowed: (resource: Resource) => boolean; +} diff --git a/src/backend/http/handlers/default.ts b/src/backend/http/handlers/default.ts new file mode 100644 index 0000000..aadcce5 --- /dev/null +++ b/src/backend/http/handlers/default.ts @@ -0,0 +1,57 @@ +import {HttpMiddlewareError, Middleware, PlainResponse} from '../server'; +import {LinkMap} from '../utils'; +import {constants} from 'http2'; +import {AllowedMiddlewareSpecification} from '../../common'; + +export const handleGetRoot: Middleware = (req) => { + const { backend, basePath } = req; + + const data = { + name: backend.app.name + }; + + const registeredResources = Array.from(backend.app.resources); + const availableResources = registeredResources.filter((r) => ( + r.state.canFetchCollection + || r.state.canCreate + )); + + const headers: Record = {}; + if (availableResources.length > 0) { + // https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Link + headers['Link'] = new LinkMap( + availableResources.map((r) => ({ + url: `${basePath}/${r.state.routeName}`, + params: { + rel: 'related', + name: r.state.routeName, + }, + })) + ) + .toString(); + } + + return new PlainResponse({ + headers, + statusMessage: 'ok', + statusCode: constants.HTTP_STATUS_OK, + body: data + }); +}; + +export const handleOptions = (middlewares: AllowedMiddlewareSpecification[]): Middleware => () => { + if (middlewares.length > 0) { + return new PlainResponse({ + headers: { + 'Allow': middlewares.flatMap((m) => m.method === 'GET' ? [m.method, 'HEAD'] : [m.method]).join(', '), + }, + statusMessage: 'ok', + statusCode: constants.HTTP_STATUS_NO_CONTENT, + }); + } + + // TODO add option for custom error handler + throw new HttpMiddlewareError('methodNotAllowed', { + statusCode: constants.HTTP_STATUS_METHOD_NOT_ALLOWED, + }); +}; diff --git a/src/backend/http/handlers.ts b/src/backend/http/handlers/resource.ts similarity index 65% rename from src/backend/http/handlers.ts rename to src/backend/http/handlers/resource.ts index 8a64d53..b0d2509 100644 --- a/src/backend/http/handlers.ts +++ b/src/backend/http/handlers/resource.ts @@ -1,59 +1,10 @@ import { constants } from 'http2'; import * as v from 'valibot'; -import {HttpMiddlewareError, PlainResponse, Middleware} from './server'; -import {LinkMap} from './utils'; - -export const handleGetRoot: Middleware = (req) => { - const { backend, basePath } = req; - - const data = { - name: backend.app.name - }; - - const registeredResources = Array.from(backend.app.resources); - const availableResources = registeredResources.filter((r) => ( - r.state.canFetchCollection - || r.state.canCreate - )); - - const headers: Record = {}; - if (availableResources.length > 0) { - // https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Link - headers['Link'] = new LinkMap( - availableResources.map((r) => ({ - url: `${basePath}/${r.state.routeName}`, - params: { - rel: 'related', - name: r.state.routeName, - }, - })) - ) - .toString(); - } - - return new PlainResponse({ - headers, - statusMessage: 'ok', - statusCode: constants.HTTP_STATUS_OK, - body: data - }); -}; +import {HttpMiddlewareError, PlainResponse, Middleware} from '../server'; export const handleGetCollection: Middleware = async (req) => { const { query, resource, backend } = req; - if (typeof resource === 'undefined') { - throw new HttpMiddlewareError('resourceNotFound', { - statusCode: constants.HTTP_STATUS_NOT_FOUND, - }); - } - - if (typeof resource.dataSource === 'undefined') { - throw new HttpMiddlewareError('unableToInitializeResourceDataSource', { - statusCode: constants.HTTP_STATUS_INTERNAL_SERVER_ERROR, - }); - } - let data: v.Output[]; let totalItemCount: number | undefined; try { @@ -88,32 +39,11 @@ export const handleGetCollection: Middleware = async (req) => { export const handleGetItem: Middleware = async (req) => { const { resource, resourceId } = req; - if (typeof resource === 'undefined') { - throw new HttpMiddlewareError('resourceNotFound', { - statusCode: constants.HTTP_STATUS_NOT_FOUND, - }); - } - - if (typeof resource.dataSource === 'undefined') { - throw new HttpMiddlewareError('unableToInitializeResourceDataSource', { - statusCode: constants.HTTP_STATUS_INTERNAL_SERVER_ERROR, - }); - } - - if (typeof resourceId === 'undefined') { + if (typeof resourceId === 'undefined' || resourceId.trim().length < 1) { throw new HttpMiddlewareError( 'resourceIdNotGiven', { - statusCode: constants.HTTP_STATUS_INTERNAL_SERVER_ERROR, - } - ); - } - - if ((resourceId.trim().length ?? 0) < 1) { - throw new HttpMiddlewareError( - 'resourceIdNotGiven', - { - statusCode: constants.HTTP_STATUS_INTERNAL_SERVER_ERROR, + statusCode: constants.HTTP_STATUS_BAD_REQUEST, } ); } @@ -150,23 +80,11 @@ export const handleGetItem: Middleware = async (req) => { export const handleDeleteItem: Middleware = async (req) => { const { resource, resourceId, backend } = req; - if (typeof resource === 'undefined') { - throw new HttpMiddlewareError('resourceNotFound', { - statusCode: constants.HTTP_STATUS_NOT_FOUND, - }); - } - - if (typeof resource.dataSource === 'undefined') { - throw new HttpMiddlewareError('unableToInitializeResourceDataSource', { - statusCode: constants.HTTP_STATUS_INTERNAL_SERVER_ERROR, - }); - } - - if (typeof resourceId === 'undefined') { + if (typeof resourceId === 'undefined' || resourceId.trim().length < 1) { throw new HttpMiddlewareError( 'resourceIdNotGiven', { - statusCode: constants.HTTP_STATUS_INTERNAL_SERVER_ERROR, + statusCode: constants.HTTP_STATUS_BAD_REQUEST, } ); } @@ -207,23 +125,11 @@ export const handleDeleteItem: Middleware = async (req) => { export const handlePatchItem: Middleware = async (req) => { const { resource, resourceId, body } = req; - if (typeof resource === 'undefined') { - throw new HttpMiddlewareError('resourceNotFound', { - statusCode: constants.HTTP_STATUS_NOT_FOUND, - }); - } - - if (typeof resource.dataSource === 'undefined') { - throw new HttpMiddlewareError('unableToInitializeResourceDataSource', { - statusCode: constants.HTTP_STATUS_INTERNAL_SERVER_ERROR, - }); - } - - if (typeof resourceId === 'undefined') { + if (typeof resourceId === 'undefined' || resourceId.trim().length < 1) { throw new HttpMiddlewareError( 'resourceIdNotGiven', { - statusCode: constants.HTTP_STATUS_INTERNAL_SERVER_ERROR, + statusCode: constants.HTTP_STATUS_BAD_REQUEST, } ); } @@ -264,21 +170,9 @@ export const handlePatchItem: Middleware = async (req) => { export const handleCreateItem: Middleware = async (req) => { const { resource, body, backend, basePath } = req; - if (typeof resource === 'undefined') { - throw new HttpMiddlewareError('resourceNotFound', { - statusCode: constants.HTTP_STATUS_NOT_FOUND, - }); - } - - if (typeof resource.dataSource === 'undefined') { - throw new HttpMiddlewareError('unableToInitializeResourceDataSource', { - statusCode: constants.HTTP_STATUS_INTERNAL_SERVER_ERROR, - }); - } - const idAttrRaw = resource.state.shared.get('idAttr'); if (typeof idAttrRaw === 'undefined') { - throw new HttpMiddlewareError('unableToInitializeResourceDataSource', { + throw new HttpMiddlewareError('unableToGenerateIdFromResourceDataSource', { statusCode: constants.HTTP_STATUS_INTERNAL_SERVER_ERROR, }); } @@ -338,21 +232,9 @@ export const handleCreateItem: Middleware = async (req) => { export const handleEmplaceItem: Middleware = async (req) => { const { resource, resourceId, basePath, body, backend } = req; - if (typeof resource === 'undefined') { - throw new HttpMiddlewareError('resourceNotFound', { - statusCode: constants.HTTP_STATUS_NOT_FOUND, - }); - } - - if (typeof resource.dataSource === 'undefined') { - throw new HttpMiddlewareError('unableToInitializeResourceDataSource', { - statusCode: constants.HTTP_STATUS_INTERNAL_SERVER_ERROR, - }); - } - const idAttrRaw = resource.state.shared.get('idAttr'); if (typeof idAttrRaw === 'undefined') { - throw new HttpMiddlewareError('unableToInitializeResourceDataSource', { + throw new HttpMiddlewareError('unableToGenerateIdFromResourceDataSource', { statusCode: constants.HTTP_STATUS_INTERNAL_SERVER_ERROR, }); } diff --git a/src/backend/http/server.ts b/src/backend/http/server.ts index 394ab13..76700e4 100644 --- a/src/backend/http/server.ts +++ b/src/backend/http/server.ts @@ -1,18 +1,20 @@ import http from 'http'; -import {BackendState, RequestContext} from '../common'; +import {AllowedMiddlewareSpecification, BackendState, RequestContext} from '../common'; import {Language, Resource, LanguageStatusMessageMap} from '../../common'; import https from 'https'; import {constants} from 'http2'; import * as v from 'valibot'; +import { + handleGetRoot, handleOptions, +} from './handlers/default'; import { handleCreateItem, handleDeleteItem, handleEmplaceItem, handleGetCollection, handleGetItem, - handleGetRoot, handlePatchItem, -} from './handlers'; +} from './handlers/resource'; import {getBody} from './utils'; import {decorateRequestWithBackend} from './decorators/backend'; import {decorateRequestWithMethod} from './decorators/method'; @@ -87,19 +89,18 @@ export interface CreateServerParams { streamResponses?: boolean; } -type ResourceRequestContext = Omit & Required>; +type RequiredResource = Required>['resource']; -export interface Middleware { - (req: Req): undefined | Response | Promise; +interface ResourceWithDataSource extends Omit { + dataSource: Required>['dataSource']; } -type Method = 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE'; +interface ResourceRequestContext extends Omit { + resource: ResourceWithDataSource; +} -interface AllowedMiddlewareSpecification { - method: Method; - middleware: Middleware; - constructBodySchema?: (resource: Resource, resourceId?: string) => v.BaseSchema; - allowed: (resource: Resource) => boolean; +export interface Middleware { + (req: Req): undefined | Response | Promise; } const constructPostSchema = (resource: Resource) => { @@ -304,6 +305,10 @@ export const createServer = (backendState: BackendState, serverParams = {} as Cr }); } + if (req.method === 'OPTIONS') { + return handleOptions(middlewares)(req); + } + if (typeof resource.dataSource === 'undefined') { throw new HttpMiddlewareError('unableToInitializeResourceDataSource', { statusCode: constants.HTTP_STATUS_INTERNAL_SERVER_ERROR, @@ -374,6 +379,9 @@ export const createServer = (backendState: BackendState, serverParams = {} as Cr try { serialized = typeof finalErr.response.body !== 'undefined' ? resourceReq.backend.cn.mediaType.serialize(finalErr.response.body) : undefined; } catch (cause) { + res.statusMessage = resourceReq.backend.cn.language.statusMessages['unableToSerializeResponse']?.replace( + /\$RESOURCE/g, + resourceReq.resource!.state.itemName) ?? ''; res.writeHead(constants.HTTP_STATUS_INTERNAL_SERVER_ERROR); res.end(); return; @@ -382,6 +390,8 @@ export const createServer = (backendState: BackendState, serverParams = {} as Cr try { encoded = typeof serialized !== 'undefined' ? resourceReq.backend.cn.charset.encode(serialized) : undefined; } catch (cause) { + res.statusMessage = resourceReq.backend.cn.language.statusMessages['unableToEncodeResponse']?.replace(/\$RESOURCE/g, + resourceReq.resource!.state.itemName) ?? ''; res.writeHead(constants.HTTP_STATUS_INTERNAL_SERVER_ERROR); res.end(); } diff --git a/test/e2e/default.test.ts b/test/e2e/default.test.ts deleted file mode 100644 index beb670d..0000000 --- a/test/e2e/default.test.ts +++ /dev/null @@ -1,765 +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, Server} from 'http'; -import {constants} from 'http2'; -import {DataSource} from '../../src/backend/data-source'; -import { dataSources } from '../../src/backend'; -import { application, resource, validation as v, Resource } from '../../src'; - -const PORT = 3000; -const HOST = '127.0.0.1'; -const ACCEPT = 'application/json'; -const ACCEPT_LANGUAGE = 'en'; -const CONTENT_TYPE_CHARSET = 'utf-8'; -const CONTENT_TYPE = ACCEPT; - -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' 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 server: Server; - beforeEach(() => { - const app = application({ - name: 'piano-service', - }) - .resource(Piano); - - const backend = app - .createBackend({ - dataSource: new dataSources.jsonlFile.DataSource(baseDir), - }) - .throwsErrorOnDeletingNotFound(); - - server = backend.createHttpServer({ - basePath: '/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(); - return new Promise((resolve) => { - setTimeout(() => { - resolve(); - }); - }); - }); - - 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-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: '/api/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 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('returns data', () => { - return new Promise((resolve, reject) => { - // TODO all responses should have serialized ids - const req = request( - { - host: HOST, - port: PORT, - path: '/api/pianos/1', - 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(data); - 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: '/api/pianos/1', - 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('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-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: '/api/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('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-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({ - ...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-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({ - ...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-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('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-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(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-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({ - ...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-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('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-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.test.ts b/test/e2e/http.test.ts new file mode 100644 index 0000000..127b082 --- /dev/null +++ b/test/e2e/http.test.ts @@ -0,0 +1,1103 @@ +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, Server} from 'http'; +import {constants} from 'http2'; +import {DataSource} from '../../src/backend/data-source'; +import { dataSources } from '../../src/backend'; +import { application, resource, validation as v, Resource } from '../../src'; + +const PORT = 3000; +const HOST = '127.0.0.1'; +const ACCEPT = 'application/json'; +const ACCEPT_LANGUAGE = 'en'; +const CONTENT_TYPE_CHARSET = 'utf-8'; +const CONTENT_TYPE = ACCEPT; +const BASE_PATH = '/api'; + +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 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 server: Server; + beforeEach(() => { + const app = application({ + name: 'piano-service', + }) + .resource(Piano); + + const backend = app + .createBackend({ + dataSource: new dataSources.jsonlFile.DataSource(baseDir), + }) + .throwsErrorOnDeletingNotFound(); + + 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(); + }); + + afterEach(() => { + Piano.canDelete(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(); + }); + }); + }); + }); +});