Browse Source

Implement delta patch

Allow PATCH method to use JSON Patch.
master
TheoryOfNekomata 8 months ago
parent
commit
645b8f4cd7
11 changed files with 512 additions and 133 deletions
  1. +38
    -38
      src/backend/servers/http/core.ts
  2. +50
    -57
      src/backend/servers/http/handlers/resource.ts
  3. +125
    -0
      src/common/delta/core.ts
  4. +8
    -0
      src/common/delta/error.ts
  5. +2
    -0
      src/common/delta/index.ts
  6. +77
    -0
      src/common/delta/object.ts
  7. +23
    -0
      src/common/delta/utils.ts
  8. +1
    -0
      src/common/index.ts
  9. +2
    -2
      src/common/resource.ts
  10. +69
    -26
      test/e2e/http/default.test.ts
  11. +117
    -10
      test/e2e/http/error-handling.test.ts

+ 38
- 38
src/backend/servers/http/core.ts View File

@@ -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) => (


+ 50
- 57
src/backend/servers/http/handlers/resource.ts View File

@@ -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({


+ 125
- 0
src/common/delta/core.ts View File

@@ -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),
);
};

+ 8
- 0
src/common/delta/error.ts View File

@@ -0,0 +1,8 @@

export class InvalidSchemaInPathError extends Error {}

export class InvalidPathValueError extends Error {}

export class InvalidOperationError extends Error {}

export class PathValueTestFailedError extends Error {}

+ 2
- 0
src/common/delta/index.ts View File

@@ -0,0 +1,2 @@
export * from './error';
export * from './core';

+ 77
- 0
src/common/delta/object.ts View File

@@ -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;
};

+ 23
- 0
src/common/delta/utils.ts View File

@@ -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));
};

+ 1
- 0
src/common/index.ts View File

@@ -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';


+ 2
- 2
src/common/resource.ts View File

@@ -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;
});
}
}


+ 69
- 26
test/e2e/http/default.test.ts View File

@@ -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', () => {


+ 117
- 10
test/e2e/http/error-handling.test.ts View File

@@ -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');
});
});
});



Loading…
Cancel
Save