import { readFile } from 'fs/promises'; import { resolve } from 'path'; import { SchemaValidationError } from '../../errors/SchemaValidationError'; import { JsonValidationService } from '../../JsonValidationService'; const NAME_PATTERN = '^[a-z][a-z0-9_\\-]+$'; async function fetchTestManifest(filename: string) { const contents = await readFile(resolve(__dirname, '__test__', 'schemas', filename), { encoding: 'utf-8', }); return JSON.parse(contents); } // eslint-disable-next-line @typescript-eslint/ban-types function expectToThrowErrorMatchingTypeAndMessage(received: Function, errorType: Function, message: string) { let error: null | Error = null; try { received(); } catch (e: any) { error = e; } expect(error).toBeTruthy(); expect(error).toBeInstanceOf(errorType); expect(error?.message).toEqual(message); } describe('manifest/v1', () => { let validationService: JsonValidationService; beforeAll(() => { validationService = new JsonValidationService(); }); it('succeeds on a valid manifest', async () => { const manifest = await fetchTestManifest('validComponent.json'); expect(validationService.validateManifest(manifest, 'v1')).toBeTruthy(); }); it('errors on invalid property types in function input', async () => { const manifest = await fetchTestManifest('badFunctionInputComponent.json'); expectToThrowErrorMatchingTypeAndMessage( () => { validationService.validateManifest(manifest, 'v1'); }, SchemaValidationError, 'failed validation: Value `badInputType` at `#/functions/0/input/properties/textValue/type` does not match any schema', ); }); it('errors on invalid property types in nested function input', async () => { const manifest = await fetchTestManifest('badNestedFunctionInput.json'); expectToThrowErrorMatchingTypeAndMessage( () => { validationService.validateManifest(manifest, 'v1'); }, SchemaValidationError, 'failed validation: Value `astd` at `#/functions/0/input/properties/anotherValue/properties/xt/type` does not match any schema', ); }); it('errors on non-object top level input', async () => { const manifest = await fetchTestManifest('nonObjectFunctionInputComponent.json'); expectToThrowErrorMatchingTypeAndMessage( () => { validationService.validateManifest(manifest, 'v1'); }, SchemaValidationError, 'failed validation: Expected value at `#/functions/0/input/type` to be `object`, but value given is `string`', ); }); describe.each(['_my-name', '-my-name', 'MyName', 'myName', '0my-name'])( 'fails name-pattern validation for %s', (propertyValue) => { it.each(['namespace', 'name'])(`fails validation for manifests with %s of %s`, async (propertyName) => { const manifest = await fetchTestManifest('validComponent.json'); expectToThrowErrorMatchingTypeAndMessage( () => { validationService.validateManifest( { ...manifest, [propertyName]: propertyValue, }, 'v1', ); }, SchemaValidationError, `failed validation: Value in \`#/${propertyName}\` should match \`${NAME_PATTERN}\`, but received \`${propertyValue}\``, ); }); it('fails validation for manifests with function names of %s', async () => { const manifest = await fetchTestManifest('validComponent.json'); expectToThrowErrorMatchingTypeAndMessage( () => { validationService.validateManifest( { ...manifest, functions: [ { name: propertyValue, entry: 'main.js', input: {}, output: { responseType: 'html', }, }, ], }, 'v1', ); }, SchemaValidationError, `failed validation: Value in \`#/functions/0/name\` should match \`${NAME_PATTERN}\`, but received \`${propertyValue}\``, ); }); }, ); it('should allow uppercase letters in property names withe previews', async () => { const manifest = await fetchTestManifest('validComponent.json'); expect( validationService.validateManifest( { ...manifest, previews: { ValidPreview: { functionData: { main: {}, }, }, }, }, 'v1', ), ).toEqual(true); }); it('errors for non-alphanumeric characters in preview keys', async () => { const manifest = await fetchTestManifest('validComponent.json'); expectToThrowErrorMatchingTypeAndMessage( () => { validationService.validateManifest( { ...manifest, previews: { 'Bad-@@@Preview': { functionData: { main: {}, }, }, }, }, 'v1', ); }, SchemaValidationError, 'failed validation: Invalid property name `Bad-@@@Preview` at `#/previews`', ); }); it('should allow ui:metadata property object on input', async () => { const manifest = await fetchTestManifest('validComponent.json'); expect( validationService.validateManifest( { ...manifest, functions: [ { name: 'main', entry: 'main.js', output: { responseType: 'html', }, input: { type: 'object', properties: { 'users-contact': { type: 'object', description: 'A description of group can be provided if needed', required: ['fist-name', 'last-name', 'phone-number', 'email'], 'ui:metadata': { collapsedByDefault: true, }, properties: { 'fist-name': { type: 'string', 'ui:metadata': { inlineEditable: true, quickOption: false, }, }, 'last-name': { type: 'string', 'ui:metadata': { inlineEditable: true, quickOption: false, }, }, 'phone-number': { type: 'number', 'ui:metadata': { inlineEditable: true, quickOption: false, }, }, email: { type: 'string', 'ui:metadata': { inlineEditable: true, quickOption: false, }, }, }, }, 'next-of-kin': { type: 'object', 'ui:metadata': { collapsedByDefault: true, }, required: ['fist-name', 'last-name', 'phone-number', 'email'], properties: { 'fist-name': { type: 'string', 'ui:metadata': { inlineEditable: true, quickOption: false, }, }, 'last-name': { type: 'string', 'ui:metadata': { inlineEditable: true, quickOption: false, }, }, 'phone-number': { type: 'number', 'ui:metadata': { inlineEditable: true, quickOption: false, }, }, email: { type: 'string', 'ui:metadata': { inlineEditable: true, quickOption: false, }, }, }, }, 'text-position': { type: 'string', enum: ['left', 'right'], 'ui:metadata': { inlineEditable: false, quickOption: true, }, }, }, required: ['users-contact', 'next-of-kin'], }, }, ], }, 'v1', ), ).toEqual(true); }); it('should allow the autoReload field on the manifest', async () => { const manifest = await fetchTestManifest('validComponent.json'); expect( validationService.validateManifest( { ...manifest, 'ui:metadata': { autoReload: false, }, }, 'v1', ), ).toEqual(true); }); it('autoReload is required if ui:metadata is specified on the root of the manifest', async () => { const manifest = await fetchTestManifest('validComponent.json'); expectToThrowErrorMatchingTypeAndMessage( () => { validationService.validateManifest( { ...manifest, 'ui:metadata': {}, }, 'v1', ); }, SchemaValidationError, 'failed validation: The required property `autoReload` is missing at `#/ui:metadata`', ); }); it('should allow the disableBuildPreview field on the manifest', async () => { const manifest = await fetchTestManifest('validComponent.json'); expect( validationService.validateManifest( { ...manifest, 'ui:metadata': { autoReload: true, // is requiredin the ui:metadata section of the schema enableBuildPreview: true, }, }, 'v1', ), ).toEqual(true); }); it('should fail if ui:metadata property on object with type string has collapsedByDefault flag', async () => { const manifest = await fetchTestManifest('validComponent.json'); expectToThrowErrorMatchingTypeAndMessage( () => { validationService.validateManifest( { ...manifest, functions: [ { name: 'main', entry: 'main.js', output: { responseType: 'html', }, input: { type: 'object', properties: { 'users-contact': { type: 'object', description: 'A description of group can be provided if needed', required: ['fist-name', 'last-name', 'phone-number', 'email'], 'ui:metadata': { collapsedByDefault: true, }, properties: { 'fist-name': { type: 'string', 'ui:metadata': { inlineEditable: true, quickOption: false, collapsedByDefault: true, }, }, 'last-name': { type: 'string', 'ui:metadata': { inlineEditable: true, quickOption: false, }, }, 'phone-number': { type: 'number', 'ui:metadata': { inlineEditable: true, quickOption: false, }, }, email: { type: 'string', 'ui:metadata': { inlineEditable: true, quickOption: false, }, }, }, }, }, required: ['users-contact'], }, }, ], }, 'v1', ); }, SchemaValidationError, 'failed validation: ui:metadata property collapsedByDefault is only valid for object properties.', ); }); });