From 645b8f4cd74017030a25b0a13b75f0a166504ae8 Mon Sep 17 00:00:00 2001 From: TheoryOfNekomata Date: Mon, 15 Apr 2024 14:17:53 +0800 Subject: [PATCH] Implement delta patch Allow PATCH method to use JSON Patch. --- src/backend/servers/http/core.ts | 76 +++++------ src/backend/servers/http/handlers/resource.ts | 107 +++++++-------- src/common/delta/core.ts | 125 +++++++++++++++++ src/common/delta/error.ts | 8 ++ src/common/delta/index.ts | 2 + src/common/delta/object.ts | 77 +++++++++++ src/common/delta/utils.ts | 23 ++++ src/common/index.ts | 1 + src/common/resource.ts | 4 +- test/e2e/http/default.test.ts | 95 +++++++++---- test/e2e/http/error-handling.test.ts | 127 ++++++++++++++++-- 11 files changed, 512 insertions(+), 133 deletions(-) create mode 100644 src/common/delta/core.ts create mode 100644 src/common/delta/error.ts create mode 100644 src/common/delta/index.ts create mode 100644 src/common/delta/object.ts create mode 100644 src/common/delta/utils.ts diff --git a/src/backend/servers/http/core.ts b/src/backend/servers/http/core.ts index 5617d80..449ba2f 100644 --- a/src/backend/servers/http/core.ts +++ b/src/backend/servers/http/core.ts @@ -9,7 +9,7 @@ import { RequestContext, RequestDecorator, Response, } from '../../common'; -import {CanPatchSpec, Resource} from '../../../common'; +import {CanPatchSpec, DELTA_SCHEMA, PATCH_CONTENT_MAP_TYPE, PatchContentType, Resource} from '../../../common'; import { handleGetRoot, handleOptions, } from './handlers/default'; @@ -79,8 +79,8 @@ const constructPutSchema = (resource: Resource, mainR const constructPatchSchema = (resource: Resource) => { const schema = resource.schema.type === 'object' ? resource.schema as unknown as v.ObjectSchema : resource.schema; - if (schema.type !== 'object') { - return schema; + if (resource.schema.type !== 'object') { + return resource.schema; } const schemaChoices = { @@ -89,39 +89,7 @@ const constructPatchSchema = (resource: Resource) => (schema as v.ObjectSchema).rest, (schema as v.ObjectSchema).pipe ), - delta: v.array( - v.union([ - v.object({ - op: v.literal('add'), - path: v.string(), // todo validate if valid path? - value: v.any() // todo validate if valid value? - }), - v.object({ - op: v.literal('remove'), - path: v.string(), - }), - v.object({ - op: v.literal('replace'), - path: v.string(), // todo validate if valid path? - value: v.any() // todo validate if valid value? - }), - v.object({ - op: v.literal('move'), - path: v.string(), - from: v.string(), - }), - v.object({ - op: v.literal('copy'), - path: v.string(), - from: v.string(), - }), - v.object({ - op: v.literal('test'), - path: v.string(), // todo validate if valid path? - value: v.any() // todo validate if valid value? - }), - ]) - ), + delta: v.array(DELTA_SCHEMA), } const selectedSchemaChoices = Object.entries(schemaChoices) @@ -161,7 +129,7 @@ const defaultItemMiddlewares: AllowedMiddlewareSpecification[] = [ method: 'PATCH', middleware: handlePatchItem, constructBodySchema: constructPatchSchema, - allowed: (resource) => resource.state.canPatch, + allowed: (resource) => resource.state.canPatch.merge || resource.state.canPatch.delta, }, { method: 'DELETE', @@ -251,7 +219,29 @@ export const createServer = (backendState: BackendState, serverParams = {} as Cr ) ? charsetRaw.slice(1, -1).trim() : charsetRaw.trim() - ) ?? (isTextMediaType(mediaType) ? 'utf-8' : 'binary') + ) ?? (isTextMediaType(mediaType) ? 'utf-8' : 'binary'); + + if (effectiveMethod === 'PATCH') { + const validPatchTypes = Object.entries(req.resource.state.canPatch) + .filter(([, allowed]) => allowed) + .map(([patchType]) => patchType); + + const validPatchContentTypes = Object.entries(PATCH_CONTENT_MAP_TYPE) + .filter(([ patchType]) => validPatchTypes.includes(patchType)) + .map(([contentType ]) => contentType); + + const isPatchEnabled = req.resource.state.canPatch[PATCH_CONTENT_MAP_TYPE[mediaType as PatchContentType]]; + if (!isPatchEnabled) { + throw new ErrorPlainResponse('invalidResourcePatchType', { + statusCode: constants.HTTP_STATUS_UNSUPPORTED_MEDIA_TYPE, + res: theRes, + headers: { + 'Accept-Patch': validPatchContentTypes.join(', '), + }, + }); + } + } + const theBodyBuffer = await getBody(req); const encodingPair = req.backend.app.charsets.get(charset); if (typeof encodingPair === 'undefined') { @@ -277,6 +267,16 @@ export const createServer = (backendState: BackendState, serverParams = {} as Cr // TODO better error reporting, localizable messages // TODO handle error handlers' errors if (Array.isArray(err.issues)) { + if (req.method === 'PATCH' && req.headers['content-type']?.startsWith('application/json-patch+json')) { + throw new ErrorPlainResponse('invalidResourcePatch', { + statusCode: constants.HTTP_STATUS_BAD_REQUEST, + body: err.issues.map((i) => ( + `${i.path?.map((p) => p.key)?.join('.') ?? i.reason}: ${i.message}` + )), + res: theRes, + }); + } + throw new ErrorPlainResponse('invalidResource', { statusCode: constants.HTTP_STATUS_BAD_REQUEST, body: err.issues.map((i) => ( diff --git a/src/backend/servers/http/handlers/resource.ts b/src/backend/servers/http/handlers/resource.ts index a1122b0..95e4535 100644 --- a/src/backend/servers/http/handlers/resource.ts +++ b/src/backend/servers/http/handlers/resource.ts @@ -4,9 +4,9 @@ import {Middleware} from '../../../common'; import {ErrorPlainResponse, PlainResponse} from '../response'; import assert from 'assert'; import { - CanPatchSpec, + applyDelta, + Delta, PATCH_CONTENT_MAP_TYPE, - PATCH_CONTENT_TYPES, PatchContentType, } from '../../../../common'; @@ -148,10 +148,6 @@ export const handleDeleteItem: Middleware = async (req, res) => { }); }; -const isValidPatch = (s: string): s is PatchContentType => { - return PATCH_CONTENT_TYPES.includes(s as PatchContentType); -}; - export const handlePatchItem: Middleware = async (req, res) => { const { resource, @@ -171,51 +167,9 @@ export const handlePatchItem: Middleware = async (req, res) => { ) ); - const validPatchTypes = Object.entries(resource.state.canPatch) - .filter(([, allowed]) => allowed) - .map(([patchType]) => patchType); - - const validPatchContentTypes = Object.entries(PATCH_CONTENT_MAP_TYPE) - .filter(([ patchType]) => validPatchTypes.includes(patchType)) - .map(([contentType ]) => contentType); - - const requestContentType = headers['content-type']; - if (typeof requestContentType !== 'string') { - throw new ErrorPlainResponse('invalidResourcePatchType', { - statusCode: constants.HTTP_STATUS_BAD_REQUEST, - res, - headers: { - 'Accept-Patch': validPatchContentTypes.join(', '), - }, - }); - } - - const [patchMimeTypeAll, ...patchParams] = requestContentType.replace(/\s+/g, '').split(';') as [PatchContentType, ...string[]]; - assert(isValidPatch(patchMimeTypeAll), new ErrorPlainResponse('invalidResourcePatchType', { - statusCode: constants.HTTP_STATUS_BAD_REQUEST, - res, - headers: { - 'Accept-Patch': validPatchContentTypes.join(', '), - }, - })); - - const isPatchEnabled = resource.state.canPatch[PATCH_CONTENT_MAP_TYPE[patchMimeTypeAll]]; - if (!isPatchEnabled) { - throw new ErrorPlainResponse('invalidResourcePatchType', { - statusCode: constants.HTTP_STATUS_BAD_REQUEST, - res, - headers: { - 'Accept-Patch': validPatchContentTypes.join(', '), - }, - }); - } - - const charsetParam = (patchParams.find((s) => s.startsWith('charset=')) ?? 'charset=utf-8'); - const [, charset] = charsetParam.split('=') as [never, BufferEncoding]; - let existing: unknown | null; try { - existing = await resource.dataSource.getById(resourceId!); + existing = await resource.dataSource.getById(resourceId); } catch (cause) { throw new ErrorPlainResponse('unableToFetchResource', { cause, @@ -232,14 +186,53 @@ export const handlePatchItem: Middleware = async (req, res) => { } let newObject: v.Output | null; - try { - newObject = await resource.dataSource.patch(resourceId!, body as object); - } catch (cause) { - throw new ErrorPlainResponse('unableToPatchResource', { - cause, - statusCode: constants.HTTP_STATUS_INTERNAL_SERVER_ERROR, - res, - }); + const patchType = PATCH_CONTENT_MAP_TYPE[headers['content-type'] as PatchContentType]; + + switch (patchType) { + case 'merge': { + try { + newObject = await resource.dataSource.patch(resourceId, body as object); + } catch (cause) { + throw new ErrorPlainResponse('unableToPatchResource', { + cause, + statusCode: constants.HTTP_STATUS_INTERNAL_SERVER_ERROR, + res, + }); + } + break; + } + case 'delta': { + let modifiedObject: Record; + try { + modifiedObject = await applyDelta( + resource.schema, + existing as Record, + body as Delta[] + ); + } catch (cause) { + throw new ErrorPlainResponse('invalidResourcePatch', { + cause, + statusCode: constants.HTTP_STATUS_UNPROCESSABLE_ENTITY, + res, + }); + } + + try { + newObject = await resource.dataSource.patch(resourceId, modifiedObject); + } catch (cause) { + throw new ErrorPlainResponse('unableToPatchResource', { + cause, + statusCode: constants.HTTP_STATUS_INTERNAL_SERVER_ERROR, + res, + }); + } + break; + } + default: + throw new ErrorPlainResponse('invalidResourcePatchType', { + statusCode: constants.HTTP_STATUS_BAD_REQUEST, + res, + }); } return new PlainResponse({ diff --git a/src/common/delta/core.ts b/src/common/delta/core.ts new file mode 100644 index 0000000..3d1a9a5 --- /dev/null +++ b/src/common/delta/core.ts @@ -0,0 +1,125 @@ +import * as v from 'valibot'; +import {getObjectSchema} from './utils'; +import { + InvalidOperationError, + InvalidPathValueError, + InvalidSchemaInPathError, + PathValueTestFailedError, +} from './error'; +import {append, get, remove, set} from './object'; + +export const DELTA_SCHEMA = v.union([ + v.object({ + op: v.literal('add'), + path: v.string(), // todo validate if valid path? + value: v.unknown() // todo validate if valid value? + }), + v.object({ + op: v.literal('remove'), + path: v.string(), + }), + v.object({ + op: v.literal('replace'), + path: v.string(), // todo validate if valid path? + value: v.unknown() // todo validate if valid value? + }), + v.object({ + op: v.literal('move'), + path: v.string(), + from: v.string(), + }), + v.object({ + op: v.literal('copy'), + path: v.string(), + from: v.string(), + }), + v.object({ + op: v.literal('test'), + path: v.string(), // todo validate if valid path? + value: v.unknown() // todo validate if valid value? + }), +]); + +export type Delta = v.Output; + +export const applyDelta = async ( + resourceSchema: T, + existing: Record, + deltaCollection: Delta[], +) => { + return await deltaCollection.reduce( + async (resultObject, deltaItem) => { + const mutablePreviousObject = await resultObject; + + if (resourceSchema.type !== 'object') { + return mutablePreviousObject; + } + + const resourceObjectSchema = resourceSchema as unknown as v.ObjectSchema; + const pathSchema = getObjectSchema(resourceObjectSchema, deltaItem.path); + if (typeof pathSchema === 'undefined') { + throw new InvalidSchemaInPathError(); + } + + switch (deltaItem.op) { + case 'replace': { + if (!v.is(pathSchema, deltaItem.value)) { + throw new InvalidPathValueError(); + } + + set(mutablePreviousObject, deltaItem.path, deltaItem.value); + return mutablePreviousObject; + } + case 'add': { + if (pathSchema.type !== 'array') { + throw new InvalidOperationError(); + } + + const arraySchema = pathSchema as v.ArraySchema; + if (!v.is(arraySchema.item, deltaItem.value)) { + throw new InvalidPathValueError(); + } + + return append(mutablePreviousObject, deltaItem.path, deltaItem.value); + } + case 'remove': { + return remove(mutablePreviousObject, deltaItem.path); + } + case 'copy': { + const value = get(mutablePreviousObject, deltaItem.from); + if (!v.is(pathSchema, value)) { + throw new InvalidPathValueError(); + } + + return set(mutablePreviousObject, deltaItem.path, value); + } + case 'move': { + const value = get(mutablePreviousObject, deltaItem.from); + if (!v.is(pathSchema, value)) { + throw new InvalidPathValueError(); + } + + remove(mutablePreviousObject, deltaItem.from) + return set(mutablePreviousObject, deltaItem.path, value); + } + case 'test': { + const value = get(mutablePreviousObject, deltaItem.path); + if (value !== deltaItem.value) { + throw new PathValueTestFailedError(); + } + + return mutablePreviousObject; + } + default: + break; + } + + if (!v.is(resourceObjectSchema, mutablePreviousObject)) { + throw new InvalidOperationError(); + } + + return mutablePreviousObject; + }, + Promise.resolve(existing), + ); +}; diff --git a/src/common/delta/error.ts b/src/common/delta/error.ts new file mode 100644 index 0000000..832536f --- /dev/null +++ b/src/common/delta/error.ts @@ -0,0 +1,8 @@ + +export class InvalidSchemaInPathError extends Error {} + +export class InvalidPathValueError extends Error {} + +export class InvalidOperationError extends Error {} + +export class PathValueTestFailedError extends Error {} diff --git a/src/common/delta/index.ts b/src/common/delta/index.ts new file mode 100644 index 0000000..d47621f --- /dev/null +++ b/src/common/delta/index.ts @@ -0,0 +1,2 @@ +export * from './error'; +export * from './core'; diff --git a/src/common/delta/object.ts b/src/common/delta/object.ts new file mode 100644 index 0000000..7bbff69 --- /dev/null +++ b/src/common/delta/object.ts @@ -0,0 +1,77 @@ +import {tokenizePath} from './utils'; + +export const set = (origObject: Record, path: string, value: unknown) => { + if (path.length <= 0) { + return origObject; + } + + const pathFragments = tokenizePath(path); + let cursor = origObject; + let thisPath = pathFragments.shift() as string; + while (pathFragments.length > 0) { + cursor = cursor?.[thisPath] as Record; + thisPath = pathFragments.shift() as string; + } + + if (typeof cursor === 'undefined') { + throw new Error(`Could not set path: ${path}`); + } + + cursor[thisPath] = value; + return origObject; +}; + +export const get = (origObject: Record, path: string) => { + if (path.length <= 0) { + return origObject; + } + const pathFragments = tokenizePath(path); + let cursor = origObject; + let thisPath = pathFragments.shift() as string; + while (pathFragments.length > 0) { + cursor = cursor?.[thisPath] as Record; + thisPath = pathFragments.shift() as string; + } + + return cursor?.[thisPath]; +}; + +export const remove = (origObject: Record, path: string) => { + if (path.length <= 0) { + return origObject; + } + const pathFragments = tokenizePath(path); + let cursor = origObject; + let thisPath = pathFragments.shift() as string; + while (pathFragments.length > 0) { + cursor = cursor?.[thisPath] as Record; + thisPath = pathFragments.shift() as string; + } + + if (typeof cursor === 'undefined') { + throw new Error(`Could not remove on path: ${path}`); + } + + delete cursor[thisPath]; + return origObject; +}; + +export const append = (origObject: Record, path: string, value: unknown) => { + if (path.length <= 0) { + return origObject; + } + const pathFragments = tokenizePath(path); + let cursor = origObject; + let thisPath = pathFragments.shift() as string; + while (pathFragments.length > 0) { + cursor = cursor?.[thisPath] as Record; + thisPath = pathFragments.shift() as string; + } + + if (!Array.isArray(cursor?.[thisPath])) { + throw new Error(`Could not append on path: ${path}`); + } + + (cursor[thisPath] as unknown[]).push(value); + return origObject; +}; diff --git a/src/common/delta/utils.ts b/src/common/delta/utils.ts new file mode 100644 index 0000000..0ef9cd3 --- /dev/null +++ b/src/common/delta/utils.ts @@ -0,0 +1,23 @@ +import * as v from 'valibot'; + +export const DELTA_PATH_SEPARATOR = '/' as const; + +export const tokenizePath = (path: string) => { + return path.split(DELTA_PATH_SEPARATOR); +}; + +export const combinePathFragments = (pathFragments: string[]) => { + return pathFragments.join(DELTA_PATH_SEPARATOR); +}; + +export const getObjectSchema = (schema?: v.ObjectSchema, path?: string): v.BaseSchema => { + if (typeof path !== 'string') { + return schema as v.BaseSchema; + } + if (path.length <= 0) { + return schema as v.BaseSchema; + } + const pathFragments = tokenizePath(path); + const thisPath = pathFragments.shift() as string; + return getObjectSchema(schema?.entries?.[thisPath], combinePathFragments(pathFragments)); +}; diff --git a/src/common/index.ts b/src/common/index.ts index 44b7a47..ede5a64 100644 --- a/src/common/index.ts +++ b/src/common/index.ts @@ -4,6 +4,7 @@ import {MediaType} from './media-type'; export * from './app'; export * from './charset'; +export * from './delta'; export * from './media-type'; export * from './resource'; export * from './language'; diff --git a/src/common/resource.ts b/src/common/resource.ts index 823d9b7..9adfb4f 100644 --- a/src/common/resource.ts +++ b/src/common/resource.ts @@ -28,7 +28,7 @@ export interface ResourceState< canDelete: boolean; } -type CanPatch = boolean | CanPatchObject | CanPatchSpec[]; +type CanPatch = boolean | Partial | CanPatchSpec[]; export interface Resource< Schema extends v.BaseSchema = v.BaseSchema, @@ -101,7 +101,7 @@ export const resource = < } if (b !== null) { CAN_PATCH_VALID_VALUES.forEach((p) => { - resourceState.canPatch[p] = b[p]; + resourceState.canPatch[p] = b[p] ?? false; }); } } diff --git a/test/e2e/http/default.test.ts b/test/e2e/http/default.test.ts index 6b32e0e..b0a42d7 100644 --- a/test/e2e/http/default.test.ts +++ b/test/e2e/http/default.test.ts @@ -282,7 +282,7 @@ describe.only('happy path', () => { }); }); - describe('patching items', () => { + describe.only('patching items', () => { const existingResource = { id: 1, brand: 'Yamaha' @@ -315,31 +315,6 @@ describe.only('happy path', () => { Piano.canPatch(false); }); - it('returns data', async () => { - const [res, resData] = await client({ - method: 'PATCH', - path: `${BASE_PATH}/pianos/${existingResource.id}`, - body: patchData, - headers: { - 'content-type': 'application/merge-patch+json', - }, - }); - - expect(res).toHaveProperty('statusCode', constants.HTTP_STATUS_OK); - expect(res).toHaveProperty('statusMessage', 'Piano Patched'); - 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', @@ -354,6 +329,74 @@ describe.only('happy path', () => { expect(acceptPatch).toContain('application/json-patch+json'); expect(acceptPatch).toContain('application/merge-patch+json'); }); + + describe('on merge', () => { + beforeEach(() => { + Piano.canPatch(false).canPatch(['merge']); + }); + + it('returns data', async () => { + const [res, resData] = await client({ + method: 'PATCH', + path: `${BASE_PATH}/pianos/${existingResource.id}`, + body: patchData, + headers: { + 'content-type': 'application/merge-patch+json', + }, + }); + + expect(res).toHaveProperty('statusCode', constants.HTTP_STATUS_OK); + expect(res).toHaveProperty('statusMessage', 'Piano Patched'); + 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, + }); + }); + }); + + describe('on delta', () => { + beforeEach(() => { + Piano.canPatch(false).canPatch(['delta']); + }); + + it.only('returns data', async () => { + const [res, resData] = await client({ + method: 'PATCH', + path: `${BASE_PATH}/pianos/${existingResource.id}`, + body: [ + { + op: 'replace', + path: 'brand', + value: patchData.brand, + }, + ], + headers: { + 'content-type': 'application/json-patch+json', + }, + }); + + expect(res).toHaveProperty('statusCode', constants.HTTP_STATUS_OK); + expect(res).toHaveProperty('statusMessage', 'Piano Patched'); + 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, + }); + }); + }); }); describe('emplacing items', () => { diff --git a/test/e2e/http/error-handling.test.ts b/test/e2e/http/error-handling.test.ts index c6a4e64..90d8427 100644 --- a/test/e2e/http/error-handling.test.ts +++ b/test/e2e/http/error-handling.test.ts @@ -7,10 +7,9 @@ import { expect, it, vi, } from 'vitest'; -import {request} from 'http'; import {constants} from 'http2'; import {Backend} from '../../../src/backend'; -import {application, resource, validation as v, Resource, Application} from '../../../src/common'; +import {application, resource, validation as v, Resource, Application, Delta} from '../../../src/common'; import { autoIncrement } from '../../fixtures'; import {createTestClient, TestClient, DummyDataSource, DummyError} from '../../utils'; import {DataSource} from '../../../src/backend/data-source'; @@ -262,17 +261,10 @@ describe('error handling', () => { brand: 'K. Kawai' }; - beforeEach(() => { - Piano.canPatch(); - }); - - afterEach(() => { - Piano.canPatch(false); - }); - // TODO add more tests it('throws on unable to fetch existing item', async () => { + Piano.canPatch(); vi .spyOn(DummyDataSource.prototype, 'getById') .mockImplementationOnce(() => { throw new DummyError() }); @@ -288,9 +280,11 @@ describe('error handling', () => { expect(res).toHaveProperty('statusCode', constants.HTTP_STATUS_INTERNAL_SERVER_ERROR); expect(res).toHaveProperty('statusMessage', 'Unable To Fetch Piano'); + Piano.canPatch(false); }); it('throws on item to patch not found', async () => { + Piano.canPatch(); vi .spyOn(DummyDataSource.prototype, 'getById') .mockResolvedValueOnce(null as never); @@ -306,6 +300,119 @@ describe('error handling', () => { expect(res).toHaveProperty('statusCode', constants.HTTP_STATUS_NOT_FOUND); expect(res).toHaveProperty('statusMessage', 'Patch Non-Existing Piano'); + Piano.canPatch(false); + }); + + describe('on merge patch', () => { + const newMergeData = { + brand: 'K. Kawai' + }; + + beforeEach(() => { + Piano.canPatch(['merge']); + }); + + afterEach(() => { + Piano.canPatch(false); + }); + + it('throws on attempting to request a delta patch', async () => { + const [res] = await client({ + method: 'PATCH', + path: `${BASE_PATH}/pianos/${existingResource.id}`, + body: newMergeData, + headers: { + 'content-type': 'application/json-patch+json', + }, + }); + + expect(res).toHaveProperty('statusCode', constants.HTTP_STATUS_UNSUPPORTED_MEDIA_TYPE); + expect(res).toHaveProperty('statusMessage', 'Invalid Piano Patch Type'); + }); + }); + + describe('on delta patch', () => { + beforeEach(() => { + Piano.canPatch(['delta']); + }); + + afterEach(() => { + Piano.canPatch(false); + }); + + it('throws on attempting to request a merge patch', async () => { + const [res] = await client({ + method: 'PATCH', + path: `${BASE_PATH}/pianos/${existingResource.id}`, + body: { brand: 'Hello' }, + headers: { + 'content-type': 'application/merge-patch+json', + }, + }); + + expect(res).toHaveProperty('statusCode', constants.HTTP_STATUS_UNSUPPORTED_MEDIA_TYPE); + expect(res).toHaveProperty('statusMessage', 'Invalid Piano Patch Type'); + }); + + it('throws on operating with a delta to an attribute outside the schema', async () => { + const [res] = await client({ + method: 'PATCH', + path: `${BASE_PATH}/pianos/${existingResource.id}`, + body: [ + { + op: 'replace', + path: 'brandUnknown', + value: 'K. Kawai', + }, + ] satisfies Delta[], + headers: { + 'content-type': 'application/json-patch+json', + }, + }); + + expect(res).toHaveProperty('statusCode', constants.HTTP_STATUS_UNPROCESSABLE_ENTITY); + expect(res).toHaveProperty('statusMessage', 'Invalid Piano Patch'); + }); + + it('throws on operating a delta with mismatched value type', async () => { + const [res] = await client({ + method: 'PATCH', + path: `${BASE_PATH}/pianos/${existingResource.id}`, + body: [ + { + op: 'replace', + path: 'brand', + value: 5, + }, + ] satisfies Delta[], + headers: { + 'content-type': 'application/json-patch+json', + }, + }); + + expect(res).toHaveProperty('statusCode', constants.HTTP_STATUS_UNPROCESSABLE_ENTITY); + expect(res).toHaveProperty('statusMessage', 'Invalid Piano Patch'); + }); + + it.skip('throws on performing an invalid delta', async () => { + const [res] = await client({ + method: 'PATCH', + path: `${BASE_PATH}/pianos/${existingResource.id}`, + body: [ + { + op: 'add', + path: 'brand', + value: 5, + }, + ] satisfies Delta[], + headers: { + 'content-type': 'application/json-patch+json', + }, + }); + + expect(res).toHaveProperty('statusCode', constants.HTTP_STATUS_UNPROCESSABLE_ENTITY); + expect(res).toHaveProperty('statusMessage', 'Invalid Piano Patch'); + }); }); });