@@ -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 = <T extends v.BaseSchema>(resource: Resource<T>, mainR | |||
const constructPatchSchema = <T extends v.BaseSchema>(resource: Resource<T>) => { | |||
const schema = resource.schema.type === 'object' ? resource.schema as unknown as v.ObjectSchema<any> : resource.schema; | |||
if (schema.type !== 'object') { | |||
return schema; | |||
if (resource.schema.type !== 'object') { | |||
return resource.schema; | |||
} | |||
const schemaChoices = { | |||
@@ -89,39 +89,7 @@ const constructPatchSchema = <T extends v.BaseSchema>(resource: Resource<T>) => | |||
(schema as v.ObjectSchema<any>).rest, | |||
(schema as v.ObjectSchema<any>).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) => ( | |||
@@ -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<typeof resource.schema> | 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<string, unknown>; | |||
try { | |||
modifiedObject = await applyDelta( | |||
resource.schema, | |||
existing as Record<string, unknown>, | |||
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({ | |||
@@ -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<typeof DELTA_SCHEMA>; | |||
export const applyDelta = async <T extends v.BaseSchema = v.BaseSchema>( | |||
resourceSchema: T, | |||
existing: Record<string, unknown>, | |||
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<any>; | |||
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<any>; | |||
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), | |||
); | |||
}; |
@@ -0,0 +1,8 @@ | |||
export class InvalidSchemaInPathError extends Error {} | |||
export class InvalidPathValueError extends Error {} | |||
export class InvalidOperationError extends Error {} | |||
export class PathValueTestFailedError extends Error {} |
@@ -0,0 +1,2 @@ | |||
export * from './error'; | |||
export * from './core'; |
@@ -0,0 +1,77 @@ | |||
import {tokenizePath} from './utils'; | |||
export const set = (origObject: Record<string, unknown>, 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<string, unknown>; | |||
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<string, unknown>, 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<string, unknown>; | |||
thisPath = pathFragments.shift() as string; | |||
} | |||
return cursor?.[thisPath]; | |||
}; | |||
export const remove = (origObject: Record<string, unknown>, 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<string, unknown>; | |||
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<string, unknown>, 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<string, unknown>; | |||
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; | |||
}; |
@@ -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<any>, 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)); | |||
}; |
@@ -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'; | |||
@@ -28,7 +28,7 @@ export interface ResourceState< | |||
canDelete: boolean; | |||
} | |||
type CanPatch = boolean | CanPatchObject | CanPatchSpec[]; | |||
type CanPatch = boolean | Partial<CanPatchObject> | 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; | |||
}); | |||
} | |||
} | |||
@@ -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', () => { | |||
@@ -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'); | |||
}); | |||
}); | |||
}); | |||