From ab6e471616fd9d7f9f1a3731445253622228f88b Mon Sep 17 00:00:00 2001 From: Ivan Artemiev <29709626+iartemiev@users.noreply.github.com> Date: Tue, 15 Oct 2024 10:05:49 -0400 Subject: [PATCH] tests --- .github/integ-config/detox-integ-all.yml | 44 ++-- .github/integ-config/integ-all.yml | 16 +- .github/workflows/callable-e2e-tests.yml | 28 +- .vscode/launch.json | 2 +- .../AWSAppSyncRealTimeProvider.test.ts | 13 +- packages/api-graphql/__tests__/events.test.ts | 242 ++++++++++++++---- .../AWSAppSyncEventsProvider/index.ts | 5 +- packages/api-graphql/src/index.ts | 5 +- .../src/internals/events/appsyncRequest.ts | 6 +- .../api-graphql/src/internals/events/index.ts | 47 +--- packages/core/src/parseAmplifyOutputs.ts | 6 +- .../src/singleton/AmplifyOutputs/types.ts | 1 + 12 files changed, 266 insertions(+), 149 deletions(-) diff --git a/.github/integ-config/detox-integ-all.yml b/.github/integ-config/detox-integ-all.yml index 0d9282e0bf3..3debdf83482 100644 --- a/.github/integ-config/detox-integ-all.yml +++ b/.github/integ-config/detox-integ-all.yml @@ -1,22 +1,22 @@ -# - test_name: 'integ_rn_ios_storage' -# working_directory: amplify-js-samples-staging/samples/react-native/storage/StorageApp -# timeout_minutes: 120 -# - test_name: 'integ_rn_ios_storage_multipart_progress' -# working_directory: amplify-js-samples-staging/samples/react-native/storage/MultiPartUploadWithProgress -# timeout_minutes: 120 -# - test_name: 'integ_rn_ios_device_tracking' -# working_directory: amplify-js-samples-staging/samples/react-native/auth/deviceTracking -# timeout_minutes: 120 -# - test_name: 'integ_rn_ios_datastore_sqlite_adapter' -# working_directory: amplify-js-samples-staging/samples/react-native/datastore/SQLiteAdapter -# timeout_minutes: 120 -# - test_name: 'integ_rn_ios_api_gen2_rn_72_detox_cli' -# working_directory: amplify-js-samples-staging/samples/react-native/api/gen2/ApiGraphQLGen2 -# timeout_minutes: 120 -# - test_name: 'integ_rn_ios_api_v6_rn_72_detox_cli' -# working_directory: amplify-js-samples-staging/samples/react-native/api/v6/ApiGRAPHQL -# timeout_minutes: 120 -# - test_name: 'integ_rn_ios_oidc_signout' -# working_directory: amplify-js-samples-staging/samples/react-native/auth/HosteduiApp -# timeout_minutes: 120 -# host_signout_page: true +- test_name: 'integ_rn_ios_storage' + working_directory: amplify-js-samples-staging/samples/react-native/storage/StorageApp + timeout_minutes: 120 +- test_name: 'integ_rn_ios_storage_multipart_progress' + working_directory: amplify-js-samples-staging/samples/react-native/storage/MultiPartUploadWithProgress + timeout_minutes: 120 +- test_name: 'integ_rn_ios_device_tracking' + working_directory: amplify-js-samples-staging/samples/react-native/auth/deviceTracking + timeout_minutes: 120 +- test_name: 'integ_rn_ios_datastore_sqlite_adapter' + working_directory: amplify-js-samples-staging/samples/react-native/datastore/SQLiteAdapter + timeout_minutes: 120 +- test_name: 'integ_rn_ios_api_gen2_rn_72_detox_cli' + working_directory: amplify-js-samples-staging/samples/react-native/api/gen2/ApiGraphQLGen2 + timeout_minutes: 120 +- test_name: 'integ_rn_ios_api_v6_rn_72_detox_cli' + working_directory: amplify-js-samples-staging/samples/react-native/api/v6/ApiGRAPHQL + timeout_minutes: 120 +- test_name: 'integ_rn_ios_oidc_signout' + working_directory: amplify-js-samples-staging/samples/react-native/auth/HosteduiApp + timeout_minutes: 120 + host_signout_page: true diff --git a/.github/integ-config/integ-all.yml b/.github/integ-config/integ-all.yml index 9a8c9241491..c7316c37c1f 100644 --- a/.github/integ-config/integ-all.yml +++ b/.github/integ-config/integ-all.yml @@ -701,14 +701,14 @@ tests: browser: *minimal_browser_list # GEO - # - test_name: integ_react_geo_display_map - # desc: 'Display Map' - # framework: react - # category: geo - # sample_name: [display-map] - # spec: display-map - # # Temp fix: - # browser: [chrome] + - test_name: integ_react_geo_display_map + desc: 'Display Map' + framework: react + category: geo + sample_name: [display-map] + spec: display-map + # Temp fix: + browser: [chrome] - test_name: integ_react_geo_search_outside_map desc: 'Search Outside Map' framework: react diff --git a/.github/workflows/callable-e2e-tests.yml b/.github/workflows/callable-e2e-tests.yml index 2ece9fbde47..4ae74a69c88 100644 --- a/.github/workflows/callable-e2e-tests.yml +++ b/.github/workflows/callable-e2e-tests.yml @@ -61,17 +61,17 @@ jobs: # timeout_minutes: ${{ matrix.integ-config.timeout_minutes || 35 }} # retry_count: ${{ matrix.integ-config.retry_count || 3 }} - # detox-e2e-test-runner: - # name: E2E test runner - # needs: e2e-prep - # strategy: - # matrix: - # integ-config: ${{ fromJson(needs.e2e-prep.outputs.detox-integ-config) }} - # fail-fast: false - # secrets: inherit - # uses: ./.github/workflows/callable-e2e-test-detox.yml - # with: - # test_name: ${{ matrix.integ-config.test_name }} - # working_directory: ${{ matrix.integ-config.working_directory }} - # timeout_minutes: ${{ matrix.integ-config.timeout_minutes || 45 }} - # host_signout_page: ${{ matrix.integ-config.host_signout_page || false }} + detox-e2e-test-runner: + name: E2E test runner + needs: e2e-prep + strategy: + matrix: + integ-config: ${{ fromJson(needs.e2e-prep.outputs.detox-integ-config) }} + fail-fast: false + secrets: inherit + uses: ./.github/workflows/callable-e2e-test-detox.yml + with: + test_name: ${{ matrix.integ-config.test_name }} + working_directory: ${{ matrix.integ-config.working_directory }} + timeout_minutes: ${{ matrix.integ-config.timeout_minutes || 45 }} + host_signout_page: ${{ matrix.integ-config.host_signout_page || false }} diff --git a/.vscode/launch.json b/.vscode/launch.json index 7a5d2c24080..116f5ef9480 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -15,7 +15,7 @@ "--inspect-brk", "${workspaceRoot}/node_modules/.bin/jest", // Optionally specify a single test file to run/debug: - "AWSAppSyncRealTimeProvider.test.ts", + "generateClient.test.ts", "--runInBand", "--testTimeout", "600000", // 10 min timeout so jest doesn't error while we're stepping through code diff --git a/packages/api-graphql/__tests__/AWSAppSyncRealTimeProvider.test.ts b/packages/api-graphql/__tests__/AWSAppSyncRealTimeProvider.test.ts index 66db531fae9..041b9624898 100644 --- a/packages/api-graphql/__tests__/AWSAppSyncRealTimeProvider.test.ts +++ b/packages/api-graphql/__tests__/AWSAppSyncRealTimeProvider.test.ts @@ -21,7 +21,7 @@ jest.mock('@aws-amplify/core/internals/aws-client-utils', () => { ); return { ...original, - signRequest: (_request, _options) => { + signRequest: (_request: any, _options: any) => { return { method: 'test', headers: { test: 'test' }, @@ -47,7 +47,7 @@ jest.mock('@aws-amplify/core', () => { }; return { ...original, - fetchAuthSession: (_request, _options) => { + fetchAuthSession: (_request: any, _options: any) => { return Promise.resolve(session); }, Amplify: { @@ -628,7 +628,7 @@ describe('AWSAppSyncRealTimeProvider', () => { }); test('subscription observer error is triggered when a connection is formed and a non-retriable connection_error data message is received', async () => { - expect.assertions(2); + expect.assertions(3); const socketCloseSpy = jest.spyOn( fakeWebSocketInterface.webSocket, @@ -675,8 +675,9 @@ describe('AWSAppSyncRealTimeProvider', () => { }), ); - // TODO: this method is getting called (validated via breakpoint) but test fails - // expect(socketCloseSpy).toHaveBeenCalledWith(3001); + await delay(1); + + expect(socketCloseSpy).toHaveBeenCalledWith(3001); }); test('subscription observer error is triggered when a connection is formed', async () => { @@ -1180,7 +1181,7 @@ describe('AWSAppSyncRealTimeProvider', () => { }); test('authenticating with AWS_LAMBDA/custom w/ custom header function that accepts request options', async () => { - expect.assertions(3); // TODO: check this; should be 2 + expect.assertions(3); provider .subscribe({ diff --git a/packages/api-graphql/__tests__/events.test.ts b/packages/api-graphql/__tests__/events.test.ts index 767e765b34a..334d8e49b7c 100644 --- a/packages/api-graphql/__tests__/events.test.ts +++ b/packages/api-graphql/__tests__/events.test.ts @@ -1,78 +1,212 @@ -import { Subscription } from 'rxjs'; import { Amplify } from '@aws-amplify/core'; -import { MESSAGE_TYPES } from '../src/Providers/constants'; -import * as constants from '../src/Providers/constants'; - -import { - delay, - FakeWebSocketInterface, - replaceConstant, -} from '../__tests__/helpers'; -import { ConnectionState as CS } from '../src/types/PubSub'; - -import { AWSAppSyncEventProvider } from '../src/Providers/AWSAppSyncEventsProvider'; +import { AppSyncEventProvider } from '../src/Providers/AWSAppSyncEventsProvider'; import { events } from '../src/'; +import { appsyncRequest } from '../src/internals/events/appsyncRequest'; import { GraphQLAuthMode } from '@aws-amplify/core/internals/utils'; +const abortController = new AbortController(); + +var mockSubscribeObservable: any; + +jest.mock('../src/Providers/AWSAppSyncEventsProvider', () => { + mockSubscribeObservable = jest.fn(() => ({ + subscribe: jest.fn(), + })); + + return { + AppSyncEventProvider: { + connect: jest.fn(), + subscribe: jest.fn(mockSubscribeObservable), + publish: jest.fn(), + close: jest.fn(), + }, + }; +}); + +jest.mock('../src/internals/events/appsyncRequest', () => { + return { + appsyncRequest: jest.fn().mockResolvedValue({}), + }; +}); + /** - * TODO: - * 1. gen2 config - * 2. manual config - * 3. all auth modes - * 4. ensure auth works as expected for all modes/locations + * Note: thorough auth testing, including validating correct auth header generation + * is performed in __tests__/AWSAppSyncRealTimeProvider.test.ts + * + * The auth implementation is shared between AWSAppSyncEventsProvider and AWSAppSyncRealTimeProvider, + * so we're just sanity checking that the expected auth mode is passed to the provider in this test file. */ -test('no configure()', async () => { - await expect(events.connect('/')).rejects.toThrow( - 'Amplify configuration is missing. Have you called Amplify.configure()?', - ); -}); +describe('Events', () => { + afterAll(() => { + jest.resetAllMocks(); + jest.clearAllMocks(); + }); + + describe('config', () => { + test('no configure()', async () => { + await expect(events.connect('/')).rejects.toThrow( + 'Amplify configuration is missing. Have you called Amplify.configure()?', + ); + }); -describe('Events Client', () => { - beforeEach(() => { - Amplify.configure({ - custom: { - events: { - url: 'https://not-a-real.ddpg-api.us-west-2.amazonaws.com/event', - aws_region: 'us-west-2', - default_authorization_type: 'API_KEY', - api_key: 'da2-abcxyz321', + test('manual (resource config)', async () => { + Amplify.configure({ + API: { + Events: { + endpoint: + 'https://not-a-real.ddpg-api.us-west-2.amazonaws.com/event', + region: 'us-west-2', + defaultAuthMode: 'apiKey', + apiKey: 'da2-abcxyz321', + }, }, - }, - version: '1.2', + }); + + await expect(events.connect('/')).resolves.not.toThrow(); + }); + + test('outputs (amplify-backend config)', async () => { + Amplify.configure({ + custom: { + events: { + url: 'https://not-a-real.ddpg-api.us-west-2.amazonaws.com/event', + aws_region: 'us-west-2', + default_authorization_type: 'API_KEY', + api_key: 'da2-abcxyz321', + }, + }, + version: '1.2', + }); + + await expect(events.connect('/')).resolves.not.toThrow(); }); }); - const authModes: GraphQLAuthMode[] = [ - 'apiKey', - 'userPool', - 'oidc', - 'iam', - 'lambda', - 'none', - ]; - - describe('channel', () => { - test('happy connect', async () => { - const channel = await events.connect('/'); - - expect(channel.subscribe).toBeInstanceOf(Function); - expect(channel.close).toBeInstanceOf(Function); + describe('client', () => { + beforeEach(() => { + Amplify.configure({ + API: { + Events: { + endpoint: + 'https://not-a-real.ddpg-api.us-west-2.amazonaws.com/event', + region: 'us-west-2', + defaultAuthMode: 'apiKey', + apiKey: 'da2-abcxyz321', + }, + }, + }); }); - describe('auth modes', () => { - let provider: typeof AWSAppSyncEventProvider; - let providerSpy: any; + const authModes: GraphQLAuthMode[] = [ + 'apiKey', + 'userPool', + 'oidc', + 'iam', + 'lambda', + 'none', + ]; + + describe('channel', () => { + test('happy connect', async () => { + const channel = await events.connect('/'); + + expect(channel.subscribe).toBeInstanceOf(Function); + expect(channel.close).toBeInstanceOf(Function); + }); + + describe('auth modes', () => { + let mockProvider: typeof AppSyncEventProvider; + + beforeEach(() => { + mockProvider = AppSyncEventProvider; + }); + + for (const authMode of authModes) { + test(`auth override: ${authMode}`, async () => { + await events.connect('/', { authMode }); + + expect(mockProvider.connect).toHaveBeenCalledWith( + expect.objectContaining({ authenticationType: authMode }), + ); + }); + } + }); + }); + + describe('subscribe', () => { + test('happy subscribe', async () => { + const channel = await events.connect('/'); + + channel.subscribe({ + next: data => void data, + error: error => void error, + }); + }); + + describe('auth modes', () => { + let mockProvider: typeof AppSyncEventProvider; + + beforeEach(() => { + mockProvider = AppSyncEventProvider; + }); + + for (const authMode of authModes) { + test(`auth override: ${authMode}`, async () => { + const channel = await events.connect('/'); + + channel.subscribe( + { + next: data => void data, + error: error => void error, + }, + { authMode }, + ); + + expect(mockSubscribeObservable).toHaveBeenCalledWith( + expect.objectContaining({ authenticationType: authMode }), + ); + }); + } + }); + }); + + describe('post', () => { + let mockReq: typeof appsyncRequest; beforeEach(() => { - provider = new AWSAppSyncEventProvider(); - providerSpy = jest.spyOn(provider, 'connect'); + mockReq = appsyncRequest; + }); + + test('happy post', async () => { + await events.post('/', { test: 'data' }); + + expect(mockReq).toHaveBeenCalledWith( + Amplify, + expect.objectContaining({ + query: '/', + variables: ['{"test":"data"}'], + }), + {}, + abortController, + ); }); for (const authMode of authModes) { test(`auth override: ${authMode}`, async () => { - await events.connect('/', { authMode }); + await events.post('/', { test: 'data' }, { authMode }); + + expect(mockReq).toHaveBeenCalledWith( + Amplify, + expect.objectContaining({ + query: '/', + variables: ['{"test":"data"}'], + authenticationType: authMode, + }), + {}, + abortController, + ); }); } }); diff --git a/packages/api-graphql/src/Providers/AWSAppSyncEventsProvider/index.ts b/packages/api-graphql/src/Providers/AWSAppSyncEventsProvider/index.ts index 612d5d2faf1..af66fc6c564 100644 --- a/packages/api-graphql/src/Providers/AWSAppSyncEventsProvider/index.ts +++ b/packages/api-graphql/src/Providers/AWSAppSyncEventsProvider/index.ts @@ -12,6 +12,7 @@ import { CustomHeaders } from '@aws-amplify/data-schema/runtime'; import { MESSAGE_TYPES } from '../constants'; import { AWSWebSocketProvider } from '../AWSWebSocketProvider'; import { awsRealTimeHeaderBasedAuth } from '../AWSWebSocketProvider/authHeaders'; + // resolved/actual AuthMode values. identityPool gets resolves to IAM upstream in InternalGraphQLAPI._graphqlSubscribe type ResolvedGraphQLAuthModes = Exclude; @@ -42,7 +43,7 @@ interface DataResponse { const PROVIDER_NAME = 'AWSAppSyncEventsProvider'; -export class AWSAppSyncEventProvider extends AWSWebSocketProvider { +class AWSAppSyncEventProvider extends AWSWebSocketProvider { constructor() { super(PROVIDER_NAME); } @@ -182,3 +183,5 @@ export class AWSAppSyncEventProvider extends AWSWebSocketProvider { }; } } + +export const AppSyncEventProvider = new AWSAppSyncEventProvider(); diff --git a/packages/api-graphql/src/index.ts b/packages/api-graphql/src/index.ts index f4479b416ec..bec5dd43416 100644 --- a/packages/api-graphql/src/index.ts +++ b/packages/api-graphql/src/index.ts @@ -1,9 +1,12 @@ // Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. // SPDX-License-Identifier: Apache-2.0 +import * as events from './internals/events'; + +export { events }; + export { GraphQLAPI, GraphQLAPIClass, graphqlOperation } from './GraphQLAPI'; export * from './types'; export { CONNECTION_STATE_CHANGE } from './Providers/constants'; -export * as events from './internals/events'; export * from './internals/events/types'; diff --git a/packages/api-graphql/src/internals/events/appsyncRequest.ts b/packages/api-graphql/src/internals/events/appsyncRequest.ts index eadcd7db585..5b53d81204d 100644 --- a/packages/api-graphql/src/internals/events/appsyncRequest.ts +++ b/packages/api-graphql/src/internals/events/appsyncRequest.ts @@ -7,11 +7,7 @@ import { GraphQLAuthMode, getAmplifyUserAgent, } from '@aws-amplify/core/internals/utils'; -import { - // cancel as cancelREST, - post, - // updateRequestToBeCancellable, -} from '@aws-amplify/api-rest/internals'; +import { post } from '@aws-amplify/api-rest/internals'; import { CustomHeaders, RequestOptions, diff --git a/packages/api-graphql/src/internals/events/index.ts b/packages/api-graphql/src/internals/events/index.ts index e49a629d762..02f003c4d7c 100644 --- a/packages/api-graphql/src/internals/events/index.ts +++ b/packages/api-graphql/src/internals/events/index.ts @@ -5,10 +5,10 @@ import { Subscription } from 'rxjs'; import { Amplify } from '@aws-amplify/core'; import { DocumentType } from '@aws-amplify/core/internals/utils'; -import { AWSAppSyncEventProvider } from '../../Providers/AWSAppSyncEventsProvider'; +import { AppSyncEventProvider as eventProvider } from '../../Providers/AWSAppSyncEventsProvider'; import { appsyncRequest } from './appsyncRequest'; -import { configure, normalizeAuth } from './utils'; +import { configure, normalizeAuth, serializeEvents } from './utils'; import type { EventsChannel, EventsOptions, @@ -17,12 +17,9 @@ import type { SubscriptionObserver, } from './types'; -const eventProvider = new AWSAppSyncEventProvider(); - /** + * Establish a WebSocket connection to an Events channel * - * @param channelName - * @param options */ async function connect( channelName: string, @@ -79,42 +76,20 @@ async function connect( }; return { - // WS publish is not enabled in the service yet, will be a follow up feature - // publish: pub, + /** + * Subscribe to incoming events + */ subscribe: sub, + /** + * Close channel and any active subscriptions + */ close, + // publish: pub, }; } /** - * Event API expects and array of JSON strings - * - * @param events - JSON-serializable value or an array of values - * @returns array of JSON strings - */ -const serializeEvents = (events: DocumentType | DocumentType[]): string[] => { - if (Array.isArray(events)) { - return events.map((ev, idx) => { - const eventJson = JSON.stringify(ev); - if (eventJson === undefined) { - throw new Error( - `Event must be a valid JSON value. Received ${ev} at index ${idx}`, - ); - } - - return eventJson; - }); - } - - const eventJson = JSON.stringify(events); - if (eventJson === undefined) { - throw new Error(`Event must be a valid JSON value. Received ${events}`); - } - - return [eventJson]; -}; - -/** + * Publish events to a channel via REST request * * @param channelName - publish destination * @param event - JSON-serializable value or an array of values diff --git a/packages/core/src/parseAmplifyOutputs.ts b/packages/core/src/parseAmplifyOutputs.ts index 5d6e8d871a5..c7a5d819487 100644 --- a/packages/core/src/parseAmplifyOutputs.ts +++ b/packages/core/src/parseAmplifyOutputs.ts @@ -314,7 +314,11 @@ export function parseAmplifyOutputs( } if (amplifyOutputs.custom) { - resourcesConfig.API = parseCustom(amplifyOutputs.custom); + const customConfig = parseCustom(amplifyOutputs.custom); + + if (customConfig && 'Events' in customConfig) { + resourcesConfig.API = { ...resourcesConfig.API, ...customConfig }; + } } if (amplifyOutputs.notifications) { diff --git a/packages/core/src/singleton/AmplifyOutputs/types.ts b/packages/core/src/singleton/AmplifyOutputs/types.ts index 21e1c71e594..c3a23fc98ab 100644 --- a/packages/core/src/singleton/AmplifyOutputs/types.ts +++ b/packages/core/src/singleton/AmplifyOutputs/types.ts @@ -102,6 +102,7 @@ export interface AmplifyOutputsCustomProperties { default_authorization_type: string; api_key?: string; }; + [key: string]: any; } export interface AmplifyOutputsNotificationsProperties {