diff --git a/doc/matchers/appsync.md b/doc/matchers/appsync.md index d9eaee6..c98903b 100644 --- a/doc/matchers/appsync.md +++ b/doc/matchers/appsync.md @@ -1,54 +1,43 @@ # AppSync -A collection of matchers to test mapping templates. +A collection of matchers to test AWS AppSync mapping templates and JS resolvers. -Use the `appSyncMappingTemplate` helper function with mapping template matchers. +## Helper Functions -- `template`: A string representing the mapping template +### `appSyncResolver(input: AppSyncResolverInput)` + +Use the `appSyncResolver` helper function to test JS resolvers. + +- `code`: The path to a file containing an `APPSYNC_JS` resolver code. The path can either be absolute, or relative to the working directory (`process.cwd()`). +- `function`: The function to test. Must be `request` or `response`. +- `context`: The [context object](https://docs.aws.amazon.com/appsync/latest/devguide/resolver-context-reference-js.html) to be passed to the function + +### `appSyncMappingTemplate(input: AppSyncMappingTemplateInput)` + +Use the `appSyncMappingTemplate` helper function to test VTL mapping templates. + +- `template`: The path to a file containing a mapping template. The path can either be absolute, or relative to the working directory (`process.cwd()`). - `context`: The [context object](https://docs.aws.amazon.com/appsync/latest/devguide/resolver-context-reference.html#accessing-the-context) to be injected into the template -### `toEvaluateTo(expected: E)` +## Matchers -Asserts that a mapping template evaluates to a given string or object for a given context. +### `toEvaluateTo(expected: E)` -If you pass an object as `value`, the matcher will try to parse the generated template into a javascript object before comparing the values. +Asserts that a mapping template or resolver evaluates to a given object for a given context. ```typescript // matching as a string await expect( - appSyncMappingTemplate({ - template: fs.readFileSync('tempalte.vtl', { encoding: 'utf8' }), + appSyncResolver({ + code: __dirname + '/resolver.js', + function: 'request', context: { arguments: { id: '123', }, }, }), -).toEvaluateTo(` -{ - "version" : "2017-02-28", - "operation" : "GetItem", - "key" : { - "pk" : {"S":"123"} - } -} -`); -``` - -```typescript -// matching as an object also works as long as the mapping template evaluates to a valid JSON -// otherwise, an error will be thrown -await expect( - appSyncMappingTemplate({ - template: fs.readFileSync('tempalte.vtl', { encoding: 'utf8' }), - context: { - arguments: { - id: '123', - }, - }, - }), -).toEvaluateTo({ - version: '2017-02-28', +).toEvaluateTo({ operation: 'GetItem', key: { pk: { S: '123' }, @@ -88,12 +77,14 @@ await expect( }, }), ).toEvaluateToInlineSnapshot(` - { - "version" : "2017-02-28", - "operation" : "GetItem", - "key" : { - "pk" : {"S":"123"} - } + Object { + "key": Object { + "pk": Object { + "S": "789", + }, + }, + "operation": "GetItem", + "version": "2017-02-28", } `); ``` diff --git a/examples/__tests__/__snapshots__/appSync.test.ts.snap b/examples/__tests__/__snapshots__/appSync.test.ts.snap index a11fcfa..69461ea 100644 --- a/examples/__tests__/__snapshots__/appSync.test.ts.snap +++ b/examples/__tests__/__snapshots__/appSync.test.ts.snap @@ -1,6 +1,17 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP -exports[`Mapping Template should evaluate a template snapshot as object 1`] = ` +exports[`JS resolvers should evaluate a js resolver with snapshot 1`] = ` +Object { + "key": Object { + "id": Object { + "S": "123", + }, + }, + "operation": "GetItem", +} +`; + +exports[`Mapping Template should evaluate a template snapshot 1`] = ` Object { "key": Object { "pk": Object { @@ -11,5 +22,3 @@ Object { "version": "2017-02-28", } `; - -exports[`Mapping Template should evaluate a template snapshot as string 1`] = `"hello 456"`; diff --git a/examples/__tests__/appSync.test.ts b/examples/__tests__/appSync.test.ts index 1e02af3..cb1e57a 100644 --- a/examples/__tests__/appSync.test.ts +++ b/examples/__tests__/appSync.test.ts @@ -1,4 +1,5 @@ -import { appSyncMappingTemplate } from 'sls-jest'; +import { DynamoDBGetItemRequest } from '@aws-appsync/utils'; +import { appSyncMappingTemplate, appSyncResolver } from 'sls-jest'; type DynamoDBGetItem = { version: string; @@ -8,44 +9,10 @@ type DynamoDBGetItem = { }; }; -const template = ` -#set($id=$ctx.args.id) -{ - "version" : "2017-02-28", - "operation" : "GetItem", - "key" : { - "pk" : $util.dynamodb.toDynamoDBJson($id) - } -} -`; +const template = __dirname + '/assets/mapping-template.vtl'; describe('Mapping Template', () => { - it('should evaluate a template string', async () => { - const expected = ` -{ - "version" : "2017-02-28", - "operation" : "GetItem", - "key" : { - "pk" : {"S":"123"} - } -} -`; - - // test that the template evaluates to the expected value as a string - await expect( - appSyncMappingTemplate({ - template, - context: { - arguments: { - id: '123', - }, - }, - }), - ).toEvaluateTo(expected); - }); - - it('should evaluate a template object', async () => { - // test that the template evaluates to the expected value as an object + it('should evaluate a template', async () => { await expect( appSyncMappingTemplate({ template, @@ -64,9 +31,7 @@ describe('Mapping Template', () => { }); }); - it('should evaluate a template snapshot as object', async () => { - // test that the template evaluates to the expected snapshot - // if the snapshot evaluates to an object, it is parsed before being saved + it('should evaluate a template snapshot', async () => { await expect( appSyncMappingTemplate({ template, @@ -79,9 +44,7 @@ describe('Mapping Template', () => { ).toEvaluateToSnapshot(); }); - it('should evaluate a template inline snapshot as object', async () => { - // test that the template evaluates to the expected inline snapshot - // if the snapshot evaluates to an object, it is parsed before being saved + it('should evaluate a template inline snapshot', async () => { await expect( appSyncMappingTemplate({ template, @@ -103,32 +66,83 @@ describe('Mapping Template', () => { } `); }); +}); - it('should evaluate a template snapshot as string', async () => { - // test that the template evaluates to the expected snapshot +describe('JS resolvers', () => { + const code = __dirname + '/assets/js-resolver.js'; + + it('should evaluate a js resolver', async () => { await expect( - appSyncMappingTemplate({ - template: 'hello ${ctx.args.id}', + appSyncResolver({ + code, + function: 'request', context: { arguments: { - id: '456', + id: '123', + }, + }, + }), + ).toEvaluateTo({ + operation: 'GetItem', + key: { + id: { S: '123' }, + }, + }); + + await expect( + appSyncResolver({ + code, + function: 'response', + context: { + arguments: { + id: '123', + }, + result: { + id: '123', + name: 'test', + }, + }, + }), + ).toEvaluateTo({ + id: '123', + name: 'test', + }); + }); + + it('should evaluate a js resolver with snapshot', async () => { + await expect( + appSyncResolver({ + code, + function: 'request', + context: { + arguments: { + id: '123', }, }, }), ).toEvaluateToSnapshot(); }); - it('should evaluate a template inline snapshot', async () => { - // test that the template evaluates to the expected inline snapshot + it('should evaluate a js resolver inline snapshot', async () => { await expect( - appSyncMappingTemplate({ - template: 'hello ${ctx.args.id}', + appSyncResolver({ + code, + function: 'request', context: { arguments: { id: '789', }, }, }), - ).toEvaluateToInlineSnapshot(`"hello 789"`); + ).toEvaluateToInlineSnapshot(` + Object { + "key": Object { + "id": Object { + "S": "789", + }, + }, + "operation": "GetItem", + } + `); }); }); diff --git a/examples/__tests__/assets/js-resolver.js b/examples/__tests__/assets/js-resolver.js new file mode 100644 index 0000000..aa2b8c5 --- /dev/null +++ b/examples/__tests__/assets/js-resolver.js @@ -0,0 +1,13 @@ +import { get } from '@aws-appsync/utils/dynamodb'; + +export const request = (ctx) => { + return get({ + key: { + id: ctx.args.id, + }, + }); +}; + +export const response = (ctx) => { + return ctx.result; +}; diff --git a/examples/__tests__/assets/mapping-template.vtl b/examples/__tests__/assets/mapping-template.vtl new file mode 100644 index 0000000..0355115 --- /dev/null +++ b/examples/__tests__/assets/mapping-template.vtl @@ -0,0 +1,8 @@ +#set($id=$ctx.args.id) +{ + "version" : "2017-02-28", + "operation" : "GetItem", + "key" : { + "pk" : $util.dynamodb.toDynamoDBJson($id) + } +} diff --git a/examples/package-lock.json b/examples/package-lock.json index 6a6a8cf..c39d501 100644 --- a/examples/package-lock.json +++ b/examples/package-lock.json @@ -23,22 +23,24 @@ "dev": true, "license": "ISC", "dependencies": { - "@aws-sdk/client-appsync": "^3.142.0", - "@aws-sdk/client-cloudformation": "^3.162.0", - "@aws-sdk/client-cloudwatch-logs": "^3.137.0", - "@aws-sdk/client-dynamodb": "^3.137.0", - "@aws-sdk/client-s3": "^3.169.0", - "@aws-sdk/client-sqs": "^3.142.0", - "@aws-sdk/lib-dynamodb": "^3.145.0", - "@jest/expect-utils": "^28.1.3", + "@aws-sdk/client-appsync": "^3.478.0", + "@aws-sdk/client-cloudformation": "^3.478.0", + "@aws-sdk/client-cloudwatch": "^3.478.0", + "@aws-sdk/client-cloudwatch-logs": "^3.478.0", + "@aws-sdk/client-dynamodb": "^3.478.0", + "@aws-sdk/client-s3": "^3.478.0", + "@aws-sdk/client-sqs": "^3.478.0", + "@aws-sdk/lib-dynamodb": "^3.478.0", + "@jest/expect-utils": "^29.1.2", + "@types/async-retry": "^1.4.5", "async-retry": "^1.3.3", - "aws-cdk": "^2.40.0", - "aws-cdk-lib": "^2.35.0", + "aws-cdk": "^2.116.1", + "aws-cdk-lib": "^2.116.1", "command-line-args": "^5.2.1", "commander": "^9.4.0", "constructs": "^10.1.67", - "jest-matcher-utils": "^28.1.3", - "jest-snapshot": "^28.1.3", + "jest-matcher-utils": "^29.1.2", + "jest-snapshot": "^29.1.2", "json-canonicalize": "^1.0.4", "lodash": "^4.17.21", "rxjs": "^7.5.6", @@ -51,13 +53,13 @@ "sls-jest-deploy-stack": "lib/bin/sls-jest-deploy-stack.js" }, "devDependencies": { - "@tsconfig/node16": "^1.0.3", - "@types/async-retry": "^1.4.5", + "@aws-appsync/utils": "^1.6.0", + "@tsconfig/node20": "^20.1.2", "@types/aws-lambda": "^8.10.101", "@types/command-line-args": "^5.2.0", - "@types/jest": "^29.0.1", + "@types/jest": "^29.1.1", "@types/lodash": "^4.14.182", - "@types/node": "^18.6.3", + "@types/node": "^20.10.5", "@typescript-eslint/eslint-plugin": "^5.32.0", "@typescript-eslint/parser": "^5.32.0", "aws-lambda": "^1.0.7", @@ -65,13 +67,20 @@ "eslint-config-prettier": "^8.5.0", "eslint-plugin-prettier": "^4.2.1", "husky": "^6.0.0", - "jest": "^28.1.3", + "jest": "^29.1.2", "prettier": "^2.7.1", "rimraf": "^3.0.2", "semantic-release": "^19.0.5", - "ts-jest": "^28.0.7", + "ts-jest": "^29.0.3", + "ts-node": "^10.9.1", "ts-toolbelt": "^9.6.0", "typescript": "^4.7.4" + }, + "engines": { + "node": ">=18.0.0" + }, + "peerDependencies": { + "jest": ">=28" } }, "node_modules/@ampproject/remapping": { @@ -7806,26 +7815,28 @@ "sls-jest": { "version": "file:..", "requires": { - "@aws-sdk/client-appsync": "^3.142.0", - "@aws-sdk/client-cloudformation": "^3.162.0", - "@aws-sdk/client-cloudwatch-logs": "^3.137.0", - "@aws-sdk/client-dynamodb": "^3.137.0", - "@aws-sdk/client-s3": "^3.169.0", - "@aws-sdk/client-sqs": "^3.142.0", - "@aws-sdk/lib-dynamodb": "^3.145.0", - "@jest/expect-utils": "^28.1.3", - "@tsconfig/node16": "^1.0.3", + "@aws-appsync/utils": "^1.6.0", + "@aws-sdk/client-appsync": "^3.478.0", + "@aws-sdk/client-cloudformation": "^3.478.0", + "@aws-sdk/client-cloudwatch": "^3.478.0", + "@aws-sdk/client-cloudwatch-logs": "^3.478.0", + "@aws-sdk/client-dynamodb": "^3.478.0", + "@aws-sdk/client-s3": "^3.478.0", + "@aws-sdk/client-sqs": "^3.478.0", + "@aws-sdk/lib-dynamodb": "^3.478.0", + "@jest/expect-utils": "^29.1.2", + "@tsconfig/node20": "^20.1.2", "@types/async-retry": "^1.4.5", "@types/aws-lambda": "^8.10.101", "@types/command-line-args": "^5.2.0", - "@types/jest": "^29.0.1", + "@types/jest": "^29.1.1", "@types/lodash": "^4.14.182", - "@types/node": "^18.6.3", + "@types/node": "^20.10.5", "@typescript-eslint/eslint-plugin": "^5.32.0", "@typescript-eslint/parser": "^5.32.0", "async-retry": "^1.3.3", - "aws-cdk": "^2.40.0", - "aws-cdk-lib": "^2.35.0", + "aws-cdk": "^2.116.1", + "aws-cdk-lib": "^2.116.1", "aws-lambda": "^1.0.7", "command-line-args": "^5.2.1", "commander": "^9.4.0", @@ -7834,9 +7845,9 @@ "eslint-config-prettier": "^8.5.0", "eslint-plugin-prettier": "^4.2.1", "husky": "^6.0.0", - "jest": "^28.1.3", - "jest-matcher-utils": "^28.1.3", - "jest-snapshot": "^28.1.3", + "jest": "^29.1.2", + "jest-matcher-utils": "^29.1.2", + "jest-snapshot": "^29.1.2", "json-canonicalize": "^1.0.4", "lodash": "^4.17.21", "prettier": "^2.7.1", @@ -7844,7 +7855,8 @@ "rxjs": "^7.5.6", "semantic-release": "^19.0.5", "source-map-support": "^0.5.21", - "ts-jest": "^28.0.7", + "ts-jest": "^29.0.3", + "ts-node": "^10.9.1", "ts-toolbelt": "^9.6.0", "typescript": "^4.7.4", "zod": "^3.18.0" diff --git a/package-lock.json b/package-lock.json index ced9b37..0fe56f9 100644 --- a/package-lock.json +++ b/package-lock.json @@ -39,6 +39,7 @@ "sls-jest-deploy-stack": "lib/bin/sls-jest-deploy-stack.js" }, "devDependencies": { + "@aws-appsync/utils": "^1.6.0", "@tsconfig/node20": "^20.1.2", "@types/aws-lambda": "^8.10.101", "@types/command-line-args": "^5.2.0", @@ -61,6 +62,9 @@ "ts-toolbelt": "^9.6.0", "typescript": "^4.7.4" }, + "engines": { + "node": ">=18.0.0" + }, "peerDependencies": { "jest": ">=28" } @@ -77,6 +81,12 @@ "node": ">=6.0.0" } }, + "node_modules/@aws-appsync/utils": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/@aws-appsync/utils/-/utils-1.6.0.tgz", + "integrity": "sha512-nDxvyG2J5iqjQkrLuptssfNwJxxE5a5IZb/JPTeQc6i3mF3rG2ZfoKqxNdlT9ArDjoQhaIDYlWb37vWKQvXtgg==", + "dev": true + }, "node_modules/@aws-cdk/asset-awscli-v1": { "version": "2.2.201", "resolved": "https://registry.npmjs.org/@aws-cdk/asset-awscli-v1/-/asset-awscli-v1-2.2.201.tgz", @@ -12743,6 +12753,12 @@ "@jridgewell/trace-mapping": "^0.3.9" } }, + "@aws-appsync/utils": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/@aws-appsync/utils/-/utils-1.6.0.tgz", + "integrity": "sha512-nDxvyG2J5iqjQkrLuptssfNwJxxE5a5IZb/JPTeQc6i3mF3rG2ZfoKqxNdlT9ArDjoQhaIDYlWb37vWKQvXtgg==", + "dev": true + }, "@aws-cdk/asset-awscli-v1": { "version": "2.2.201", "resolved": "https://registry.npmjs.org/@aws-cdk/asset-awscli-v1/-/asset-awscli-v1-2.2.201.tgz", diff --git a/package.json b/package.json index cc408cc..fdf1b45 100644 --- a/package.json +++ b/package.json @@ -27,6 +27,7 @@ "node": ">=18.0.0" }, "devDependencies": { + "@aws-appsync/utils": "^1.6.0", "@tsconfig/node20": "^20.1.2", "@types/aws-lambda": "^8.10.101", "@types/command-line-args": "^5.2.0", diff --git a/src/__tests__/helpers/appsync.test.ts b/src/__tests__/helpers/appsync.test.ts index 29325f2..b20096c 100644 --- a/src/__tests__/helpers/appsync.test.ts +++ b/src/__tests__/helpers/appsync.test.ts @@ -1,6 +1,8 @@ import { appSyncMappingTemplate, AppSyncMappingTemplateInput, + appSyncResolver, + AppSyncResolverInput, } from '../../helpers'; describe('appSyncMappingTemplate', () => { @@ -49,3 +51,55 @@ describe('appSyncMappingTemplate', () => { `); }); }); + +describe('appSyncResolver', () => { + it('should return a valid matcher input', () => { + expect( + appSyncResolver({ + code: `export const request = (ctx) => {};`, + function: 'request', + context: { + arguments: { + foo: 'bar', + }, + }, + }) as object, + ).toMatchInlineSnapshot(` + { + "_slsJestHelperName": "appSyncResolver", + "code": "export const request = (ctx) => {};", + "context": { + "arguments": { + "foo": "bar", + }, + }, + "function": "request", + } + `); + }); + + it('should throw an error on missing input', () => { + expect(() => appSyncResolver({} as AppSyncResolverInput)) + .toThrowErrorMatchingInlineSnapshot(` + "Invalid appSyncResolver() input: + code: Required + function: Required + context: Required" + `); + }); + + it('should throw an error on invalid input', () => { + expect(() => + appSyncResolver({ + code: 123, + function: 'foo', + context: 123, + } as unknown as AppSyncResolverInput), + ).toThrowErrorMatchingInlineSnapshot(` + "Invalid appSyncResolver() input: + code: Expected string, received number + function: Invalid enum value. Expected 'request' | 'response', received 'foo' + context: Expected object, received number" + `); + }); +}); diff --git a/src/helpers/appsync.ts b/src/helpers/appsync.ts index 264fb01..fdede8e 100644 --- a/src/helpers/appsync.ts +++ b/src/helpers/appsync.ts @@ -1,22 +1,30 @@ import { AppSyncClientConfig } from '@aws-sdk/client-appsync'; -import { AppSyncResolverEvent } from 'aws-lambda'; import { HelperZodSchema, MatcherHelper, assertMatcherHelperInputValue, } from './internal'; +import { Context } from '@aws-appsync/utils'; import { O } from 'ts-toolbelt'; import { z } from 'zod'; +type PartialContext = O.Partial< + Context< + Record, + Record, + Record, + Record, + unknown + >, + 'deep' +>; + /** * AppSync mapping template helper input */ export type AppSyncMappingTemplateInput = { template: string; - context: O.Partial< - AppSyncResolverEvent, Record>, - 'deep' - >; + context: PartialContext; clientConfig?: AppSyncClientConfig; }; @@ -31,7 +39,14 @@ const appSyncMappingTemplateInputSchema: HelperZodSchema< }); /** - * AppSync mapping template helper + * AppSync mapping template matcher helper. + * + * Use it to evaluate a mapping template and assert on the result. + * + * @param {string} template The path to a file containing containing a mapping template. + * The path can either be absolute, or relative to the working directory (`process.cwd()`). + * @param {object} context The context to pass to the resolver function. + * @param {object} clientConfig An optional AppSync SDK client configuration. */ export const appSyncMappingTemplate: MatcherHelper< 'appSyncMappingTemplate', @@ -48,3 +63,50 @@ export const appSyncMappingTemplate: MatcherHelper< ...input, }; }; + +/** + * AppSync js resolver helper input + */ +export type AppSyncResolverInput = { + code: string; + function: 'request' | 'response'; + context: PartialContext; + clientConfig?: AppSyncClientConfig; +}; + +/** + * AppSync Resolver schema + */ +const appSyncResolverInputSchema: HelperZodSchema = + z.object({ + code: z.string(), + function: z.enum(['request', 'response']), + context: z.object({}), + }); + +/** + * AppSync Resolver matcher helper. + * + * Use it to evaluate a js resolver and assert on the result. + * + * @param {string} code The path to a file containing an `APPSYNC_JS` resolver code. + * The path can either be absolute, or relative to the working directory (`process.cwd()`). + * @param {string} function The function to evaluate. `request` or `response`. + * @param {object} context The context to pass to the resolver function. + * @param {object} clientConfig An optional AppSync SDK client configuration. + */ +export const appSyncResolver: MatcherHelper< + 'appSyncResolver', + AppSyncResolverInput +> = (input) => { + assertMatcherHelperInputValue( + 'appSyncResolver', + appSyncResolverInputSchema, + input, + ); + + return { + _slsJestHelperName: 'appSyncResolver', + ...input, + }; +}; diff --git a/src/helpers/internal.ts b/src/helpers/internal.ts index 706ff00..0c44cf5 100644 --- a/src/helpers/internal.ts +++ b/src/helpers/internal.ts @@ -12,7 +12,11 @@ export type HelperZodSchema any> = z.ZodType< /** * Helper input type names */ -export type ItemType = 'dynamodbItem' | 'appSyncMappingTemplate' | 's3Object'; +export type ItemType = + | 'dynamodbItem' + | 'appSyncMappingTemplate' + | 'appSyncResolver' + | 's3Object'; /** * Matcher helper input diff --git a/src/index.ts b/src/index.ts index 3ca4e87..7092729 100644 --- a/src/index.ts +++ b/src/index.ts @@ -17,14 +17,51 @@ declare global { // eslint-disable-next-line @typescript-eslint/no-namespace namespace jest { interface EvaluateMatchers { - toEvaluateTo(template: E): Promise; - toEvaluateToSnapshot( - propertiesOrHint?: string, - hint?: string, + /** + * Asserts that the received AppSync resolver evaluation + * matches the expected object. + * + * @param {object} expected The expected object. + */ + toEvaluateTo(expected: E): Promise; + + /** + * Asserts that the received AppSync resolver evaluation + * matches the existing snapshot. + * + * @param {string} snapshotName Optional snapshot name. + */ + toEvaluateToSnapshot(snapshotName?: string): Promise; + + /** + * Asserts that the received AppSync resolver evaluation + * matches the existing snapshot. + * + * @param {object} propertyMatchers The snapshot properties. + * @param {string} snapshotName Optional snapshot name. + */ + toEvaluateToSnapshot( + propertyMatchers: Partial, + snapshotName?: string, ): Promise; - toEvaluateToInlineSnapshot( - propertiesOrHint?: string, - hint?: string, + + /** + * Asserts that the received AppSync resolver evaluation + * matches the inline snapshot. + * + * @param snapshot The expected snapshot. + */ + toEvaluateToInlineSnapshot(snapshot?: string): Promise; + /** + * Asserts that the received AppSync resolver evaluation + * matches the inline snapshot. + * + * @param {object} propertyMatchers The snapshot properties. + * @param {string} snapshot The expected snapshot. + */ + toEvaluateToInlineSnapshot( + propertyMatchers: Partial, + snapshot?: string, ): Promise; } @@ -67,7 +104,7 @@ declare global { (actual: IfAny): JestMatchers; // AppSync matchers overload - >( + >( actual: T, ): AndNot; diff --git a/src/matchers/appsync/client.ts b/src/matchers/appsync/client.ts new file mode 100644 index 0000000..4afda0d --- /dev/null +++ b/src/matchers/appsync/client.ts @@ -0,0 +1,13 @@ +import { AppSyncClient, AppSyncClientConfig } from '@aws-sdk/client-appsync'; +import { canonicalize } from 'json-canonicalize'; + +const appSyncClients: Record = {}; + +export const getAppSyncClient = (config: AppSyncClientConfig = {}) => { + const key = canonicalize(config); + if (!appSyncClients[key]) { + appSyncClients[key] = new AppSyncClient(config); + } + + return appSyncClients[key]; +}; diff --git a/src/matchers/appsync/index.ts b/src/matchers/appsync/index.ts new file mode 100644 index 0000000..d4722b1 --- /dev/null +++ b/src/matchers/appsync/index.ts @@ -0,0 +1 @@ +export * from './resolvers'; diff --git a/src/matchers/appsync/js-resolvers.ts b/src/matchers/appsync/js-resolvers.ts new file mode 100644 index 0000000..3337530 --- /dev/null +++ b/src/matchers/appsync/js-resolvers.ts @@ -0,0 +1,127 @@ +import { + matcherHint, + MatcherHintOptions, + printDiffOrStringify, + printExpected, + printReceived, +} from 'jest-matcher-utils'; +import { MatcherContext } from 'expect'; +import { EvaluateCodeCommand, RuntimeName } from '@aws-sdk/client-appsync'; +import { Context, toMatchInlineSnapshot, toMatchSnapshot } from 'jest-snapshot'; +import { equals, subsetEquality, iterableEquality } from '@jest/expect-utils'; +import { AppSyncResolverInput } from '../../helpers/appsync'; +import { MatcherFunction } from '../internal'; +import { getAppSyncClient } from './client'; +import { promises } from 'fs'; +import path from 'path'; +const { readFile } = promises; + +const EXPECTED_LABEL = 'Expected'; +const RECEIVED_LABEL = 'Received'; + +const evaluateResolver = async ( + input: AppSyncResolverInput, +): Promise => { + const client = getAppSyncClient(input.clientConfig); + + const filePath = input.code.startsWith('/') + ? input.code + : path.resolve(`${process.cwd()}/${input.code}`); + + const code = await readFile(filePath, { encoding: 'utf8' }); + + const { evaluationResult: received, error } = await client.send( + new EvaluateCodeCommand({ + code, + function: input.function, + runtime: { + name: RuntimeName.APPSYNC_JS, + runtimeVersion: '1.0.0', + }, + context: JSON.stringify(input.context), + }), + ); + + if (error) { + throw new Error(`AppSync resolver evaluation failed: ${error.message}`); + } + + if (!received) { + throw new Error('Received empty response from AppSync'); + } + + try { + const result = JSON.parse(received); + if (typeof result !== 'object') { + throw new Error( + `The AppSync resolver handler did not return an object: ${received}`, + ); + } + + return result; + } catch (e) { + throw new Error( + `The AppSync resolver handler did not return valid JSON: ${received}`, + ); + } +}; + +export const toEvaluateTo: MatcherFunction = async function ( + this: MatcherContext, + input: AppSyncResolverInput, + expected: string | object, +) { + const matcherName = 'toEvaluateTo'; + const options: MatcherHintOptions = { + isNot: this.isNot, + }; + + const received = await evaluateResolver(input); + + const pass = equals(received, expected, [iterableEquality, subsetEquality]); + + const message = pass + ? () => + matcherHint(matcherName, undefined, undefined, options) + + '\n\n' + + `Expected: not ${printExpected(expected)}\n` + + (expected !== received + ? `Received: ${printReceived(received)}` + : '') + : () => + matcherHint(matcherName, undefined, undefined, options) + + '\n\n' + + printDiffOrStringify( + expected, + received, + EXPECTED_LABEL, + RECEIVED_LABEL, + this.expand !== false, + ); + + return { actual: received, expected, message, name: matcherName, pass }; +}; + +export const toEvaluateToSnapshot: MatcherFunction = async function ( + this: Context, + input: AppSyncResolverInput, + ...rest: any +) { + const received = await evaluateResolver(input); + + return toMatchSnapshot.call(this, received, ...rest); +}; + +export const toEvaluateToInlineSnapshot: MatcherFunction = async function ( + this: Context, + input: AppSyncResolverInput, + ...rest: any +) { + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore + this.error = new Error(); + + const received = await evaluateResolver(input); + + return toMatchInlineSnapshot.call(this, received, ...rest); +}; diff --git a/src/matchers/appSync.ts b/src/matchers/appsync/mapping-templates.ts similarity index 53% rename from src/matchers/appSync.ts rename to src/matchers/appsync/mapping-templates.ts index 4b59ab9..119fb2e 100644 --- a/src/matchers/appSync.ts +++ b/src/matchers/appsync/mapping-templates.ts @@ -6,31 +6,55 @@ import { printReceived, } from 'jest-matcher-utils'; import { MatcherContext } from 'expect'; -import { - AppSyncClient, - AppSyncClientConfig, - EvaluateMappingTemplateCommand, -} from '@aws-sdk/client-appsync'; +import { EvaluateMappingTemplateCommand } from '@aws-sdk/client-appsync'; import { Context, toMatchInlineSnapshot, toMatchSnapshot } from 'jest-snapshot'; import { equals, subsetEquality, iterableEquality } from '@jest/expect-utils'; -import { maybeParseJson } from './utils'; -import { canonicalize } from 'json-canonicalize'; -import { AppSyncMappingTemplateInput } from '../helpers/appsync'; -import { assertMatcherHelperInputType } from '../helpers/internal'; -import { MatcherFunction } from './internal'; +import { AppSyncMappingTemplateInput } from '../../helpers/appsync'; +import { MatcherFunction } from '../internal'; +import { getAppSyncClient } from './client'; +import { promises } from 'fs'; +import path from 'path'; +const { readFile } = promises; const EXPECTED_LABEL = 'Expected'; const RECEIVED_LABEL = 'Received'; -const appSyncClients: Record = {}; +const evaluateMappingTemplate = async ( + input: AppSyncMappingTemplateInput, +): Promise => { + const client = getAppSyncClient(input.clientConfig); + + const filePath = input.template.startsWith('/') + ? input.template + : path.resolve(`${process.cwd()}/${input.template}`); + + const code = await readFile(filePath, { encoding: 'utf8' }); -const getAppSyncClient = (config: AppSyncClientConfig = {}) => { - const key = canonicalize(config); - if (!appSyncClients[key]) { - appSyncClients[key] = new AppSyncClient(config); + const { evaluationResult: received } = await client.send( + new EvaluateMappingTemplateCommand({ + template: code, + context: JSON.stringify(input.context), + }), + ); + + if (!received) { + throw new Error('Received empty response from AppSync'); } - return appSyncClients[key]; + try { + const result = JSON.parse(received); + if (typeof result !== 'object') { + throw new Error( + `The AppSync resolver did not return an object: ${received}`, + ); + } + + return result; + } catch (e) { + throw new Error( + `The AppSync mapping template did not return valid JSON: ${received}`, + ); + } }; export const toEvaluateTo: MatcherFunction = async function ( @@ -38,29 +62,12 @@ export const toEvaluateTo: MatcherFunction = async function ( input: AppSyncMappingTemplateInput, expected: string | object, ) { - assertMatcherHelperInputType( - 'toEvaluateTo', - ['appSyncMappingTemplate'], - input, - ); - const matcherName = 'toEvaluateTo'; const options: MatcherHintOptions = { isNot: this.isNot, }; - const client = getAppSyncClient(input.clientConfig); - - let { evaluationResult: received } = await client.send( - new EvaluateMappingTemplateCommand({ - template: input.template, - context: JSON.stringify(input.context), - }), - ); - - if (typeof expected === 'object') { - received = maybeParseJson(received); - } + const received = await evaluateMappingTemplate(input); const pass = equals(received, expected, [iterableEquality, subsetEquality]); @@ -91,21 +98,9 @@ export const toEvaluateToSnapshot: MatcherFunction = async function ( input: AppSyncMappingTemplateInput, ...rest: any ) { - assertMatcherHelperInputType( - 'toEvaluateToSnapshot', - ['appSyncMappingTemplate'], - input, - ); - const client = getAppSyncClient(input.clientConfig); + const received = await evaluateMappingTemplate(input); - const { evaluationResult: received } = await client.send( - new EvaluateMappingTemplateCommand({ - template: input.template, - context: JSON.stringify(input.context), - }), - ); - - return toMatchSnapshot.call(this, maybeParseJson(received), ...rest); + return toMatchSnapshot.call(this, received, ...rest); }; export const toEvaluateToInlineSnapshot: MatcherFunction = async function ( @@ -113,23 +108,11 @@ export const toEvaluateToInlineSnapshot: MatcherFunction = async function ( input: AppSyncMappingTemplateInput, ...rest: any ) { - assertMatcherHelperInputType( - 'toEvaluateToInlineSnapshot', - ['appSyncMappingTemplate'], - input, - ); - const client = getAppSyncClient(input.clientConfig); - // eslint-disable-next-line @typescript-eslint/ban-ts-comment // @ts-ignore this.error = new Error(); - const { evaluationResult: received } = await client.send( - new EvaluateMappingTemplateCommand({ - template: input.template, - context: JSON.stringify(input.context), - }), - ); + const received = await evaluateMappingTemplate(input); - return toMatchInlineSnapshot.call(this, maybeParseJson(received), ...rest); + return toMatchInlineSnapshot.call(this, received, ...rest); }; diff --git a/src/matchers/appsync/resolvers.ts b/src/matchers/appsync/resolvers.ts new file mode 100644 index 0000000..cf1a39f --- /dev/null +++ b/src/matchers/appsync/resolvers.ts @@ -0,0 +1,81 @@ +import { MatcherFunction } from '../internal'; +import { MatcherState } from 'expect'; +import { + assertMatcherHelperInputType, + IMatcherHelperInput, +} from '../../helpers/internal'; +import * as mappingTemplates from './mapping-templates'; +import * as jsResolvers from './js-resolvers'; + +/** + * Assert that the resolver element evaluates to the expected value. + */ +export const toEvaluateTo: MatcherFunction = async function ( + this: MatcherState, + input: IMatcherHelperInput, + ...rest: any +) { + const item = assertMatcherHelperInputType( + 'toEvaluateTo', + ['appSyncMappingTemplate', 'appSyncResolver'], + input, + ); + + const { _slsJestHelperName } = item; + switch (_slsJestHelperName) { + case 'appSyncMappingTemplate': + return mappingTemplates.toEvaluateTo.call(this, item, ...rest); + case 'appSyncResolver': + return jsResolvers.toEvaluateTo.call(this, item, ...rest); + } +}; + +/** + * Assert that the resolver element evaluates to the expected snapshot. + */ +export const toEvaluateToSnapshot: MatcherFunction = async function ( + this: MatcherState, + input: IMatcherHelperInput, + ...rest: any +) { + const item = assertMatcherHelperInputType( + 'toEvaluateTo', + ['appSyncMappingTemplate', 'appSyncResolver'], + input, + ); + + const { _slsJestHelperName } = item; + switch (_slsJestHelperName) { + case 'appSyncMappingTemplate': + return mappingTemplates.toEvaluateToSnapshot.call(this, item, ...rest); + case 'appSyncResolver': + return jsResolvers.toEvaluateToSnapshot.call(this, item, ...rest); + } +}; + +/** + * Assert that the resolver element evaluates to the expected inline snapshot. + */ +export const toEvaluateToInlineSnapshot: MatcherFunction = async function ( + this: MatcherState, + input: IMatcherHelperInput, + ...rest: any +) { + const item = assertMatcherHelperInputType( + 'toEvaluateTo', + ['appSyncMappingTemplate', 'appSyncResolver'], + input, + ); + + const { _slsJestHelperName } = item; + switch (_slsJestHelperName) { + case 'appSyncMappingTemplate': + return mappingTemplates.toEvaluateToInlineSnapshot.call( + this, + item, + ...rest, + ); + case 'appSyncResolver': + return jsResolvers.toEvaluateToInlineSnapshot.call(this, item, ...rest); + } +}; diff --git a/src/matchers/index.ts b/src/matchers/index.ts index 319b5f1..b8c5811 100644 --- a/src/matchers/index.ts +++ b/src/matchers/index.ts @@ -1,3 +1,3 @@ export * from './eventBridge'; -export * from './appSync'; +export * from './appsync'; export * from './common';