From b5178644e01339af2348bf161b1973494071e913 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Timo=20M=C3=A4mecke?= Date: Mon, 19 Feb 2024 17:34:17 +0100 Subject: [PATCH 1/7] Do all the initialization --- .eslintrc.cjs | 10 + lib/PortingEmbed/PortingEmbed.tsx | 19 ++ .../__stories__/WelcomeEmbed.stories.tsx | 19 ++ .../__tests__/PortingEmbed.test.tsx | 12 ++ lib/PortingEmbed/__tests__/index.test.tsx | 173 ++++++++++++++++++ lib/PortingEmbed/index.tsx | 118 ++++++++++++ lib/core/__tests__/assert.test.ts | 16 ++ lib/core/__tests__/subscription.test.ts | 18 ++ lib/core/__tests__/token.test.ts | 30 +++ lib/core/assert.ts | 9 + lib/core/subscription.ts | 29 +++ lib/core/token.ts | 38 ++++ lib/types/connectSession.ts | 17 ++ lib/types/index.ts | 3 + lib/types/porting.ts | 64 +++++++ lib/types/subscription.ts | 8 + package-lock.json | 16 ++ package.json | 1 + scripts/setupTests.ts | 4 + testing/db.ts | 19 ++ testing/factories/connectSession.ts | 46 +++++ testing/factories/porting.ts | 44 +++++ testing/factories/serviceProvider.ts | 19 ++ testing/factories/subscription.ts | 32 ++++ testing/http.ts | 36 ++++ tsconfig.json | 3 +- 26 files changed, 802 insertions(+), 1 deletion(-) create mode 100644 lib/PortingEmbed/PortingEmbed.tsx create mode 100644 lib/PortingEmbed/__stories__/WelcomeEmbed.stories.tsx create mode 100644 lib/PortingEmbed/__tests__/PortingEmbed.test.tsx create mode 100644 lib/PortingEmbed/__tests__/index.test.tsx create mode 100644 lib/PortingEmbed/index.tsx create mode 100644 lib/core/__tests__/assert.test.ts create mode 100644 lib/core/__tests__/subscription.test.ts create mode 100644 lib/core/__tests__/token.test.ts create mode 100644 lib/core/assert.ts create mode 100644 lib/core/subscription.ts create mode 100644 lib/core/token.ts create mode 100644 lib/types/connectSession.ts create mode 100644 lib/types/index.ts create mode 100644 lib/types/porting.ts create mode 100644 lib/types/subscription.ts create mode 100644 testing/db.ts create mode 100644 testing/factories/connectSession.ts create mode 100644 testing/factories/porting.ts create mode 100644 testing/factories/serviceProvider.ts create mode 100644 testing/factories/subscription.ts diff --git a/.eslintrc.cjs b/.eslintrc.cjs index a5c295f..376a3d7 100644 --- a/.eslintrc.cjs +++ b/.eslintrc.cjs @@ -19,5 +19,15 @@ module.exports = { }, ], 'simple-import-sort/imports': 'error', + '@typescript-eslint/no-unused-vars': [ + 'error', + { + args: 'all', + argsIgnorePattern: '^_', + ignoreRestSiblings: true, + varsIgnorePattern: '^_', + caughtErrors: 'all', + }, + ], }, } diff --git a/lib/PortingEmbed/PortingEmbed.tsx b/lib/PortingEmbed/PortingEmbed.tsx new file mode 100644 index 0000000..6b46dca --- /dev/null +++ b/lib/PortingEmbed/PortingEmbed.tsx @@ -0,0 +1,19 @@ +import { Porting } from '../types' + +export type CustomizableEmbedProps = { + // TODO: add styling options + styleConfig?: { + foo?: string + } +} + +type CoreEmbedProps = { + token: string + initialPorting: Porting +} + +type PortingEmbedProps = CoreEmbedProps & CustomizableEmbedProps + +export function PortingEmbed({ token: _, initialPorting }: PortingEmbedProps) { + return
Hello {initialPorting.id}!
+} diff --git a/lib/PortingEmbed/__stories__/WelcomeEmbed.stories.tsx b/lib/PortingEmbed/__stories__/WelcomeEmbed.stories.tsx new file mode 100644 index 0000000..c7aaefc --- /dev/null +++ b/lib/PortingEmbed/__stories__/WelcomeEmbed.stories.tsx @@ -0,0 +1,19 @@ +import { WelcomeEmbed } from '../WelcomeEmbed' + +export default { + title: 'Example/WelcomeEmbed', + component: WelcomeEmbed, + tags: ['autodocs'], + argTypes: { + token: { control: 'text' }, + name: { control: 'text' }, + onCounterChange: { action: 'counterChange' }, + }, +} + +export const Primary = { + args: { + token: 'abc:123', + name: 'Jerry', + }, +} diff --git a/lib/PortingEmbed/__tests__/PortingEmbed.test.tsx b/lib/PortingEmbed/__tests__/PortingEmbed.test.tsx new file mode 100644 index 0000000..e4e113e --- /dev/null +++ b/lib/PortingEmbed/__tests__/PortingEmbed.test.tsx @@ -0,0 +1,12 @@ +import { render, screen } from '@testing-library/preact' + +import { portingFactory } from '@/testing/factories/porting' + +import { PortingEmbed } from '../PortingEmbed' + +it('gets the porting', () => { + const porting = portingFactory.params({ id: 'prt_123' }).build() + render() + const greeting = screen.getByText(/Hello prt_123/i) + expect(greeting).toBeInTheDocument() +}) diff --git a/lib/PortingEmbed/__tests__/index.test.tsx b/lib/PortingEmbed/__tests__/index.test.tsx new file mode 100644 index 0000000..72452a0 --- /dev/null +++ b/lib/PortingEmbed/__tests__/index.test.tsx @@ -0,0 +1,173 @@ +import { render } from '@testing-library/preact' + +import { connectSessionFactory } from '@/testing/factories/connectSession' +import { portingFactory } from '@/testing/factories/porting' +import { subscriptionFactory } from '@/testing/factories/subscription' + +import { PortingStatus } from '../../types' +import { PortingEmbed } from '../' + +const project = 'test_project' + +async function createFixtures() { + const subscription = await subscriptionFactory.create() + const connectSession = connectSessionFactory + .completePorting(subscription.id) + .build() + return connectSession +} + +beforeEach(() => { + render(
) +}) + +describe('mounting', () => { + it('mounts into a DOM selector', async () => { + const csn = await createFixtures() + const embed = await PortingEmbed(csn, { project }) + + embed.mount('#mount') + expect(document.querySelector('.__gigsPortingEmbed')).toBeInTheDocument() + }) + + it('mounts into a DOM element', async () => { + const csn = await createFixtures() + const embed = await PortingEmbed(csn, { project }) + + embed.mount(document.getElementById('mount')!) + expect(document.querySelector('.__gigsPortingEmbed')).toBeInTheDocument() + }) +}) + +describe('updating', () => { + it('updates the embed', async () => { + const csn = await createFixtures() + const embed = await PortingEmbed(csn, { project }) + + embed.mount('#mount') + embed.update({}) + expect(document.querySelector('.__gigsPortingEmbed')).toBeInTheDocument() + }) + + it('fails to update an unmounted embed', async () => { + const csn = await createFixtures() + const embed = await PortingEmbed(csn, { project }) + + expect(() => embed.update({})).toThrow(/an unmounted embed/i) + }) +}) + +describe('unmounting', () => { + it('unmounts the embed', async () => { + const csn = await createFixtures() + const embed = await PortingEmbed(csn, { project }) + + embed.mount('#mount') + expect(document.getElementById('mount')).not.toBeEmptyDOMElement() + embed.unmount() + expect(document.getElementById('mount')).toBeEmptyDOMElement() + }) + + it('fails to unmount an unmounted embed', async () => { + const csn = await createFixtures() + const embed = await PortingEmbed(csn, { project }) + expect(() => embed.unmount()).toThrow(/an unmounted embed/i) + }) +}) + +describe('initialization', () => { + it('initializes with valid data', async () => { + const csn = await createFixtures() + const embed = await PortingEmbed(csn, { project }) + + expect(embed.mount).toBeDefined() + expect(embed.update).toBeDefined() + expect(embed.unmount).toBeDefined() + expect(embed.on).toBeDefined() + expect(embed.off).toBeDefined() + }) + + it('throws without a project', async () => { + const csn = await createFixtures() + // @ts-expect-error Assume a JS-developer forgets the project + const init = PortingEmbed(csn, {}) + expect(init).rejects.toThrow(/NO_PROJECT/) + }) + + it('throws with a wrong intent', async () => { + const csn = connectSessionFactory + // @ts-expect-error Unsupported intent type + .params({ intent: { type: 'foo' } }) + .build() + const init = PortingEmbed(csn, { project }) + expect(init).rejects.toThrow(/WRONG_INTENT/) + }) + + it('throws with a non-existing subscription', async () => { + const csn = connectSessionFactory.completePorting('sub_404').build() + const init = PortingEmbed(csn, { project }) + expect(init).rejects.toThrow(/NOT_FOUND/) + }) + + it('throws without a porting', async () => { + const sub = await subscriptionFactory.withoutPorting().create() + const csn = connectSessionFactory.completePorting(sub.id).build() + const init = PortingEmbed(csn, { project }) + expect(init).rejects.toThrow(/NOT_FOUND/) + }) + + describe('with porting status', () => { + async function createWithStatus(status: PortingStatus) { + const porting = portingFactory.params({ status }).build() + const subscription = await subscriptionFactory + .associations({ porting }) + .create() + const connectSession = connectSessionFactory + .completePorting(subscription.id) + .build() + return connectSession + } + + it('initializes with informationRequired', async () => { + const csn = await createWithStatus('informationRequired') + const init = PortingEmbed(csn, { project }) + expect(init).resolves.toBeDefined() + }) + + it('initializes with declined', async () => { + const csn = await createWithStatus('declined') + const init = PortingEmbed(csn, { project }) + expect(init).resolves.toBeDefined() + }) + + it('throws with draft', async () => { + const csn = await createWithStatus('draft') + const init = PortingEmbed(csn, { project }) + expect(init).rejects.toThrow(/UNSUPPORTED/) + }) + + it('throws with requested', async () => { + const csn = await createWithStatus('requested') + const init = PortingEmbed(csn, { project }) + expect(init).rejects.toThrow(/UNSUPPORTED/) + }) + + it('throws with completed', async () => { + const csn = await createWithStatus('completed') + const init = PortingEmbed(csn, { project }) + expect(init).rejects.toThrow(/UNSUPPORTED/) + }) + + it('throws with canceled', async () => { + const csn = await createWithStatus('canceled') + const init = PortingEmbed(csn, { project }) + expect(init).rejects.toThrow(/UNSUPPORTED/) + }) + + it('throws with expired', async () => { + const csn = await createWithStatus('expired') + const init = PortingEmbed(csn, { project }) + expect(init).rejects.toThrow(/UNSUPPORTED/) + }) + }) +}) diff --git a/lib/PortingEmbed/index.tsx b/lib/PortingEmbed/index.tsx new file mode 100644 index 0000000..257b370 --- /dev/null +++ b/lib/PortingEmbed/index.tsx @@ -0,0 +1,118 @@ +import mitt from 'mitt' +import { render } from 'preact' + +import { fetchSubscription } from '../core/subscription' +import { exchangeSessionWithToken } from '../core/token' +import { ConnectSession, PortingStatus } from '../types' +import { + CustomizableEmbedProps, + PortingEmbed as PortingEmbedComponent, +} from './PortingEmbed' + +type PortingEmbedInit = { project: string } +export type PortingEmbedOptions = CustomizableEmbedProps + +type Events = never + +export async function PortingEmbed( + session: ConnectSession, + { + options: initialOptions, + project, + }: { options?: PortingEmbedOptions } & PortingEmbedInit, +) { + // Ensure embed was initialized with proper options + const { intent } = session + assert( + project, + 'NO_PROJECT: Cannot initialize PortingEmbed without a project.', + ) + assert( + intent.type === 'completePorting', + `WRONG_INTENT: PortingEmbed must be initialized with the "completePorting" intent, but got "${intent.type}" instead.`, + ) + + // Get a user token and ensure that the ConnectSession is valid. + const token = await exchangeSessionWithToken(session) + + let element: Element | null = null + let options = initialOptions + const emitter = mitt() + + // Fetch the necessary data before the embed can be mounted. While the embed + // is loading, the embedder can show their own loading state. + const subscription = await fetchSubscription( + intent.completePorting.subscription, + { project, token }, + ) + const { porting } = subscription + assert(porting, 'NOT_FOUND: The given subscription has no porting.') + + const supportedPortingStatus: PortingStatus[] = [ + 'informationRequired', + 'declined', + ] + assert( + supportedPortingStatus.includes(porting.status), + `UNSUPPORTED: Porting status "${porting.status}" is not supported by the embed.`, + ) + + /** + * Mount the embed into a container. + * @param container The HTML Element or selector in which the embed should be + * mounted to. + */ + const mount = (container: Element | string) => { + assert(container, 'Cannot call mount() without specifying a container.') + + element = + typeof container === 'string' + ? document.querySelector(container) + : container + assert(element, 'Element to mount to could not be found.') + + renderWithCurrentOptions() + } + + /** + * Unmount the mounted embed. + */ + const unmount = () => { + assert(element, 'Cannot call unmount() on an unmounted embed.') + + render(null, element) + element = null + } + + /** + * Update the mounted embed with new options. + * @param newOptions New options for the embed + */ + const update = (newOptions: PortingEmbedOptions) => { + assert(element, 'Cannot call update() on an unmounted embed.') + + options = newOptions + renderWithCurrentOptions() + } + + const renderWithCurrentOptions = () => { + assert(element, 'No element present to render embed into.') + + render( + , + element, + ) + } + + return { + mount, + update, + unmount, + on: emitter.on.bind(emitter), + off: emitter.off.bind(emitter), + } +} diff --git a/lib/core/__tests__/assert.test.ts b/lib/core/__tests__/assert.test.ts new file mode 100644 index 0000000..5b12a32 --- /dev/null +++ b/lib/core/__tests__/assert.test.ts @@ -0,0 +1,16 @@ +import { assert } from '../assert' + +it('throws when it evaluates to false', () => { + expect(() => assert(false, 'is false')).toThrowError('is false') + expect(() => assert(null, 'is null')).toThrowError('is null') + expect(() => assert(0, 'is zero')).toThrowError('is zero') + expect(() => assert('', 'is empty')).toThrowError('is empty') +}) + +it('does not throw when it evaluates to true', () => { + expect(() => assert(true, 'is true')).not.toThrowError('is true') + expect(() => assert({}, 'is object')).not.toThrowError('is object') + expect(() => assert('string', 'is string')).not.toThrowError('is string') + expect(() => assert(1, 'is 1')).not.toThrowError('is 1') + expect(() => assert([], 'is array')).not.toThrowError('is array') +}) diff --git a/lib/core/__tests__/subscription.test.ts b/lib/core/__tests__/subscription.test.ts new file mode 100644 index 0000000..ef0b084 --- /dev/null +++ b/lib/core/__tests__/subscription.test.ts @@ -0,0 +1,18 @@ +import { subscriptionFactory } from '@/testing/factories/subscription' + +import { fetchSubscription } from '../subscription' + +const project = 'test_project' +const token = 'test_token' + +it('returns an existing subscription', async () => { + const sub = await subscriptionFactory.create() + const result = await fetchSubscription(sub.id, { project, token }) + expect(result).toEqual(sub) +}) + +it('throws if the subscription does not exist', async () => { + expect( + fetchSubscription('sub_not_found', { project, token }), + ).rejects.toThrow(/SUB_NOT_FOUND/) +}) diff --git a/lib/core/__tests__/token.test.ts b/lib/core/__tests__/token.test.ts new file mode 100644 index 0000000..02e472b --- /dev/null +++ b/lib/core/__tests__/token.test.ts @@ -0,0 +1,30 @@ +import { connectSessionFactory } from '@/testing/factories/connectSession' + +import { exchangeSessionWithToken } from '../token' + +it('exchanges an authenticated session with a user token', async () => { + const csn = connectSessionFactory.transient({ token: 'secret_sauce' }).build() + const token = await exchangeSessionWithToken(csn) + expect(token).toBe('exchanged:secret_sauce') +}) + +it('throws with an unauthenticated session', async () => { + const csn = connectSessionFactory.unauthenticated().build() + expect(exchangeSessionWithToken(csn)).rejects.toThrow( + /INVALID_SESSION: Session has no token./, + ) +}) + +it('throws without a url in the session', async () => { + const csn = connectSessionFactory.params({ url: null }).build() + expect(exchangeSessionWithToken(csn)).rejects.toThrow( + /INVALID_SESSION: Session has no URL/, + ) +}) + +it('throws with an expired session', async () => { + const csn = connectSessionFactory.transient({ token: 'expired' }).build() + expect(exchangeSessionWithToken(csn)).rejects.toThrow( + /INVALID_SESSION: Session is expired/, + ) +}) diff --git a/lib/core/assert.ts b/lib/core/assert.ts new file mode 100644 index 0000000..3410093 --- /dev/null +++ b/lib/core/assert.ts @@ -0,0 +1,9 @@ +export function assert( + condition: unknown, + message: string, + cause?: Error, +): asserts condition { + if (!condition) { + throw new Error(message, { cause }) + } +} diff --git a/lib/core/subscription.ts b/lib/core/subscription.ts new file mode 100644 index 0000000..a47cbea --- /dev/null +++ b/lib/core/subscription.ts @@ -0,0 +1,29 @@ +import { Subscription } from '../types' + +type FetchSubOptions = { + /** Project id of the subscription. */ + project: string + /** User token. */ + token: string +} + +/** + * Fetch the user's subscription. + * @param sub The subscription id. + * @param opts Additional options. + */ +export async function fetchSubscription(sub: string, opts: FetchSubOptions) { + const res = await fetch( + `https://api.gigs.com/projects/${opts.project}/subscriptions/${sub}`, + { headers: { authorization: `bearer ${opts.token} ` } }, + ) + const body = await res.json().catch(() => res.text()) + assert( + res.status !== 404, + 'SUB_NOT_FOUND: Subscription could not be fetched.', + ) + assert(res.ok, `FETCH_FAILED: ${body?.message || body?.toString()}`) + const subscription = body as Subscription + + return subscription +} diff --git a/lib/core/token.ts b/lib/core/token.ts new file mode 100644 index 0000000..0b53af1 --- /dev/null +++ b/lib/core/token.ts @@ -0,0 +1,38 @@ +import { ConnectSession } from '../types' + +type Tokens = { + access_token: string +} + +/** + * Exchange an authenticated ConnectSession with a user token. + */ +export async function exchangeSessionWithToken(session: ConnectSession) { + assert( + session.url, + 'INVALID_SESSION: Session has no URL. Did you pass in the created session?', + ) + const url = new URL(session.url) + const token = url.searchParams.get('token') + assert( + token, + 'INVALID_SESSION: Session has no token. Is it an authenticated session?', + ) + + const res = await fetch(`https://connect.gigs.com/api/embeds/auth`, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ secret: token }), + }) + const body: { token?: Tokens; error?: string } = await res + .json() + .catch(async () => ({ error: await res.text() })) + assert(res.status !== 422, 'INVALID_SESSION: Session is expired.') + assert(res.ok, `FETCH_FAILED: ${body.error}`) + assert( + body.token?.access_token, + 'Expected user token to be returned in response of token exchange, but was not found.', + ) + + return body.token.access_token +} diff --git a/lib/types/connectSession.ts b/lib/types/connectSession.ts new file mode 100644 index 0000000..069ec11 --- /dev/null +++ b/lib/types/connectSession.ts @@ -0,0 +1,17 @@ +export type ConnectSession = { + object: 'connectSession' + id: string + callbackUrl: string | null + intent: ConnectSessionIntent + url: string | null + user: string | null +} + +export type ConnectSessionIntent = ConnectSessionCompletePortingIntent + +export type ConnectSessionCompletePortingIntent = { + type: 'completePorting' + completePorting: { + subscription: string + } +} diff --git a/lib/types/index.ts b/lib/types/index.ts new file mode 100644 index 0000000..71119f6 --- /dev/null +++ b/lib/types/index.ts @@ -0,0 +1,3 @@ +export * from './connectSession' +export * from './porting' +export * from './subscription' diff --git a/lib/types/porting.ts b/lib/types/porting.ts new file mode 100644 index 0000000..5330131 --- /dev/null +++ b/lib/types/porting.ts @@ -0,0 +1,64 @@ +export type Porting = { + object: 'porting' + id: string + accountNumber: string | null + accountPinExists: boolean + address: PortingAddress | null + birthday: string | null + declinedAttempts: number + declinedCode: string | null + declinedMessage: string | null + donorProvider: ServiceProvider | null + donorProviderApproval: boolean | null + firstName: string | null + lastName: string | null + phoneNumber: string + provider: string + recipientProvider: ServiceProvider + required: PortingRequiredField[] + status: PortingStatus + subscription: string | null + user: string + canceledAt: string | null + completedAt: string | null + createdAt: string + expiredAt: string | null + lastDeclinedAt: string | null + lastRequestedAt: string | null +} + +export type PortingAddress = { + city: string + country: string + line1: string + line2: string + postalCode: string + state: string | null +} + +export type ServiceProvider = { + object: 'serviceProvider' + id: string + name: string + recipientProviders: string[] +} + +export type PortingRequiredField = + | 'accountNumber' + | 'accountPin' + | 'address' + | 'birthday' + | 'donorProvider' + | 'donorProviderApproval' + | 'firstName' + | 'lastName' + +export type PortingStatus = + | 'draft' + | 'pending' + | 'informationRequired' + | 'requested' + | 'declined' + | 'completed' + | 'canceled' + | 'expired' diff --git a/lib/types/subscription.ts b/lib/types/subscription.ts new file mode 100644 index 0000000..dda709c --- /dev/null +++ b/lib/types/subscription.ts @@ -0,0 +1,8 @@ +import { Porting } from '.' + +// Reduced version of a subscription because we only care about the porting +export type Subscription = { + object: 'subscription' + id: string + porting: Porting | null +} diff --git a/package-lock.json b/package-lock.json index 2f9dfaf..b5a319a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -29,6 +29,7 @@ "eslint-plugin-react-refresh": "^0.4.5", "eslint-plugin-simple-import-sort": "^10.0.0", "eslint-plugin-storybook": "^0.6.15", + "fishery": "^2.2.2", "jsdom": "^24.0.0", "mitt": "^3.0.1", "msw": "^2.2.0", @@ -11560,6 +11561,15 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/fishery": { + "version": "2.2.2", + "resolved": "https://registry.npmjs.org/fishery/-/fishery-2.2.2.tgz", + "integrity": "sha512-jeU0nDhPHJkupmjX+r9niKgVMTBDB8X+U/pktoGHAiWOSyNlMd0HhmqnjrpjUOCDPJYaSSu4Ze16h6dZOKSp2w==", + "dev": true, + "dependencies": { + "lodash.mergewith": "^4.6.2" + } + }, "node_modules/flat-cache": { "version": "3.2.0", "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-3.2.0.tgz", @@ -13957,6 +13967,12 @@ "integrity": "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==", "dev": true }, + "node_modules/lodash.mergewith": { + "version": "4.6.2", + "resolved": "https://registry.npmjs.org/lodash.mergewith/-/lodash.mergewith-4.6.2.tgz", + "integrity": "sha512-GK3g5RPZWTRSeLSpgP8Xhra+pnjBC56q9FZYe1d5RN3TJ35dbkGy3YqBSMbyCrlbi+CM9Z3Jk5yTL7RCsqboyQ==", + "dev": true + }, "node_modules/lodash.uniqby": { "version": "4.7.0", "resolved": "https://registry.npmjs.org/lodash.uniqby/-/lodash.uniqby-4.7.0.tgz", diff --git a/package.json b/package.json index 6ea0d48..e1ec15a 100644 --- a/package.json +++ b/package.json @@ -46,6 +46,7 @@ "eslint-plugin-react-refresh": "^0.4.5", "eslint-plugin-simple-import-sort": "^10.0.0", "eslint-plugin-storybook": "^0.6.15", + "fishery": "^2.2.2", "jsdom": "^24.0.0", "mitt": "^3.0.1", "msw": "^2.2.0", diff --git a/scripts/setupTests.ts b/scripts/setupTests.ts index c6e5fd6..80b9f8e 100644 --- a/scripts/setupTests.ts +++ b/scripts/setupTests.ts @@ -4,6 +4,8 @@ import '@testing-library/jest-dom/vitest' import { cleanup } from '@testing-library/preact' +import { clearDb } from '@/testing/db' + import { server } from '../testing/http' afterEach(() => cleanup()) @@ -12,3 +14,5 @@ afterEach(() => cleanup()) beforeAll(() => server.listen({ onUnhandledRequest: 'error' })) afterAll(() => server.close()) afterEach(() => server.resetHandlers()) + +afterEach(() => clearDb()) diff --git a/testing/db.ts b/testing/db.ts new file mode 100644 index 0000000..b1c32a0 --- /dev/null +++ b/testing/db.ts @@ -0,0 +1,19 @@ +import { ConnectSession, Porting, Subscription } from '../lib/types' + +type TestDb = { + subscriptions: Subscription[] + portings: Porting[] + connectSessions: ConnectSession[] +} + +export const db: TestDb = { + subscriptions: [], + portings: [], + connectSessions: [], +} + +export function clearDb() { + db.subscriptions = [] + db.portings = [] + db.connectSessions = [] +} diff --git a/testing/factories/connectSession.ts b/testing/factories/connectSession.ts new file mode 100644 index 0000000..ee3701e --- /dev/null +++ b/testing/factories/connectSession.ts @@ -0,0 +1,46 @@ +import { Factory } from 'fishery' + +import { ConnectSession } from '../../lib/types' + +type ConnectSessionTransientParams = { + token: string +} + +class ConnectSessionFactory extends Factory< + ConnectSession, + ConnectSessionTransientParams +> { + completePorting(subscription?: string) { + return this.params({ + intent: { + type: 'completePorting' as const, + completePorting: { + subscription: subscription || `sub_${this.sequence()}`, + }, + }, + }) + } + + unauthenticated() { + return this.params({ + url: `https://connect.gigs.com/portal/entry?session=csn_${this.sequence()}`, + user: null, + }) + } +} + +export const connectSessionFactory = ConnectSessionFactory.define( + ({ sequence, transientParams }) => ({ + object: 'connectSession' as const, + id: `csn_${sequence}`, + intent: { + type: 'completePorting' as const, + completePorting: { + subscription: 'sub_123', + }, + }, + callbackUrl: null, + url: `https://connect.gigs.com/portal/entry?session=csn_${sequence}&token=${transientParams.token || 'secrettoken'}`, + user: 'usr_123', + }), +) diff --git a/testing/factories/porting.ts b/testing/factories/porting.ts new file mode 100644 index 0000000..e3db637 --- /dev/null +++ b/testing/factories/porting.ts @@ -0,0 +1,44 @@ +import { Factory } from 'fishery' + +import { Porting } from '../../lib/types' +import { serviceProviderFactory } from './serviceProvider' + +type PortingTransientParams = never + +class PortingFactory extends Factory { + declined() { + return this.params({ status: 'declined' }) + } +} + +export const portingFactory = PortingFactory.define( + ({ sequence, associations }) => ({ + object: 'porting' as const, + id: `prt_${sequence}`, + accountNumber: null, + accountPinExists: false, + address: null, + birthday: null, + declinedAttempts: 0, + declinedCode: null, + declinedMessage: null, + donorProvider: associations.donorProvider || serviceProviderFactory.build(), + donorProviderApproval: null, + firstName: null, + lastName: null, + phoneNumber: '+19591234567', + provider: 'p7', + recipientProvider: + associations.recipientProvider || serviceProviderFactory.build(), + required: [], + status: 'informationRequired' as const, + subscription: null, + user: 'usr_123', + canceledAt: null, + completedAt: null, + createdAt: new Date().toISOString(), + expiredAt: null, + lastDeclinedAt: null, + lastRequestedAt: null, + }), +) diff --git a/testing/factories/serviceProvider.ts b/testing/factories/serviceProvider.ts new file mode 100644 index 0000000..dac23dc --- /dev/null +++ b/testing/factories/serviceProvider.ts @@ -0,0 +1,19 @@ +import { Factory } from 'fishery' + +import { ServiceProvider } from '../../lib/types' + +type ServiceProviderTransientParams = never + +class ServiceProviderFactory extends Factory< + ServiceProvider, + ServiceProviderTransientParams +> {} + +export const serviceProviderFactory = ServiceProviderFactory.define( + ({ sequence }) => ({ + object: 'serviceProvider' as const, + id: `svp_${sequence}`, + name: 'Example Provider', + recipientProviders: [], + }), +) diff --git a/testing/factories/subscription.ts b/testing/factories/subscription.ts new file mode 100644 index 0000000..c0a0d84 --- /dev/null +++ b/testing/factories/subscription.ts @@ -0,0 +1,32 @@ +import { Factory } from 'fishery' + +import { Subscription } from '../../lib/types' +import { db } from '../db' +import { portingFactory } from './porting' + +type SubscriptionTransientParams = never + +class SubscriptionFactory extends Factory< + Subscription, + SubscriptionTransientParams +> { + withoutPorting() { + return this.params({ porting: null }) + } +} + +export const subscriptionFactory = SubscriptionFactory.define( + ({ sequence, associations, onCreate }) => { + onCreate((sub) => { + db.subscriptions.push(sub) + if (sub.porting) db.portings.push(sub.porting) + return sub + }) + + return { + object: 'subscription' as const, + id: `sub_${sequence}`, + porting: associations.porting || portingFactory.build(), + } + }, +) diff --git a/testing/http.ts b/testing/http.ts index 07d793b..bebe420 100644 --- a/testing/http.ts +++ b/testing/http.ts @@ -1,10 +1,46 @@ import { http, HttpHandler, HttpResponse } from 'msw' import { setupServer } from 'msw/node' +import { db } from './db' + export const handlers: HttpHandler[] = [ http.get('https://api.example.com/users/:id', () => { return HttpResponse.json({ name: 'George' }) }), + + http.get<{ project: string; id: string }>( + 'https://api.gigs.com/projects/:project/subscriptions/:id', + async ({ params }) => { + const sub = db.subscriptions.find((sub) => sub.id === params.id) + + if (!sub) { + return HttpResponse.json( + { object: 'error', type: 'notFound' }, + { status: 404 }, + ) + } + + return HttpResponse.json(sub) + }, + ), + + http.post( + 'https://connect.gigs.com/api/embeds/auth', + async ({ request }) => { + const body = await request.json() + if (!body.secret) { + return HttpResponse.json({ error: 'No secret.' }, { status: 422 }) + } + + if (body.secret === 'expired') { + return HttpResponse.json({ error: 'Invalid secret.' }, { status: 422 }) + } + + return HttpResponse.json({ + token: { access_token: `exchanged:${body.secret}` }, + }) + }, + ), ] export const server = setupServer(...handlers) diff --git a/tsconfig.json b/tsconfig.json index d7d61de..7701dd0 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -2,7 +2,7 @@ "compilerOptions": { "target": "ES2020", "useDefineForClassFields": true, - "lib": ["ES2020", "DOM", "DOM.Iterable"], + "lib": ["ES2022", "DOM", "DOM.Iterable"], "module": "ESNext", "skipLibCheck": true, "types": ["vitest/globals"], @@ -23,6 +23,7 @@ /* Linting */ "strict": true, + "strictNullChecks": true, "noUnusedLocals": true, "noUnusedParameters": true, "noFallthroughCasesInSwitch": true From d21e0ddc2f8d92b4b5e705691ecd2065c603e1bb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Timo=20M=C3=A4mecke?= Date: Mon, 19 Feb 2024 17:40:55 +0100 Subject: [PATCH 2/7] Replace old example story with PortingEmbed story --- .../__stories__/PortingEmbed.stories.tsx | 20 +++++++++++++++++++ .../__stories__/WelcomeEmbed.stories.tsx | 19 ------------------ 2 files changed, 20 insertions(+), 19 deletions(-) create mode 100644 lib/PortingEmbed/__stories__/PortingEmbed.stories.tsx delete mode 100644 lib/PortingEmbed/__stories__/WelcomeEmbed.stories.tsx diff --git a/lib/PortingEmbed/__stories__/PortingEmbed.stories.tsx b/lib/PortingEmbed/__stories__/PortingEmbed.stories.tsx new file mode 100644 index 0000000..b4ce8ca --- /dev/null +++ b/lib/PortingEmbed/__stories__/PortingEmbed.stories.tsx @@ -0,0 +1,20 @@ +import { portingFactory } from '@/testing/factories/porting' + +import { PortingEmbed } from '../PortingEmbed' + +export default { + title: 'Porting Embed/Base', + component: PortingEmbed, + tags: ['autodocs'], + argTypes: { + token: { control: 'text' }, + initialPorting: { control: 'object' }, + }, +} + +export const Primary = { + args: { + token: 'abc:123', + initialPorting: portingFactory.build(), + }, +} diff --git a/lib/PortingEmbed/__stories__/WelcomeEmbed.stories.tsx b/lib/PortingEmbed/__stories__/WelcomeEmbed.stories.tsx deleted file mode 100644 index c7aaefc..0000000 --- a/lib/PortingEmbed/__stories__/WelcomeEmbed.stories.tsx +++ /dev/null @@ -1,19 +0,0 @@ -import { WelcomeEmbed } from '../WelcomeEmbed' - -export default { - title: 'Example/WelcomeEmbed', - component: WelcomeEmbed, - tags: ['autodocs'], - argTypes: { - token: { control: 'text' }, - name: { control: 'text' }, - onCounterChange: { action: 'counterChange' }, - }, -} - -export const Primary = { - args: { - token: 'abc:123', - name: 'Jerry', - }, -} From 82af96a0873e51977d49b21893026b5a54830b05 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Timo=20M=C3=A4mecke?= Date: Mon, 19 Feb 2024 18:15:34 +0100 Subject: [PATCH 3/7] Adjust dev app --- lib/PortingEmbed/README.md | 22 ++++++++++++++ lib/PortingEmbed/index.tsx | 1 + lib/core/subscription.ts | 1 + lib/core/token.ts | 1 + lib/main.ts | 1 + src/App.css | 1 - src/App.tsx | 60 +++++++++++++++++++++++++------------- 7 files changed, 65 insertions(+), 22 deletions(-) create mode 100644 lib/PortingEmbed/README.md diff --git a/lib/PortingEmbed/README.md b/lib/PortingEmbed/README.md new file mode 100644 index 0000000..b2827be --- /dev/null +++ b/lib/PortingEmbed/README.md @@ -0,0 +1,22 @@ +# Porting Embed + +## Usage + +```ts +import { PortingEmbed } from '@gigs/gigs-embeds-js' + +// Obtain a ConnectSession from your own backend +const connectSession = await fetchConnectSession() + +const embed = await PortingEmbed(connectSession, { project: 'your-project' }) +embed.mount(document.getElementById('gigsEmbedMount')) + +// can also use a selector: +// embed.mount('#gigsEmbedMount') + +// Here be more dragons + +// --- +// index.html +
+``` diff --git a/lib/PortingEmbed/index.tsx b/lib/PortingEmbed/index.tsx index 257b370..7986cc1 100644 --- a/lib/PortingEmbed/index.tsx +++ b/lib/PortingEmbed/index.tsx @@ -1,6 +1,7 @@ import mitt from 'mitt' import { render } from 'preact' +import { assert } from '../core/assert' import { fetchSubscription } from '../core/subscription' import { exchangeSessionWithToken } from '../core/token' import { ConnectSession, PortingStatus } from '../types' diff --git a/lib/core/subscription.ts b/lib/core/subscription.ts index a47cbea..ab29cb9 100644 --- a/lib/core/subscription.ts +++ b/lib/core/subscription.ts @@ -1,4 +1,5 @@ import { Subscription } from '../types' +import { assert } from './assert' type FetchSubOptions = { /** Project id of the subscription. */ diff --git a/lib/core/token.ts b/lib/core/token.ts index 0b53af1..4a1a28b 100644 --- a/lib/core/token.ts +++ b/lib/core/token.ts @@ -1,4 +1,5 @@ import { ConnectSession } from '../types' +import { assert } from './assert' type Tokens = { access_token: string diff --git a/lib/main.ts b/lib/main.ts index d1b8cf0..d9b3287 100644 --- a/lib/main.ts +++ b/lib/main.ts @@ -1 +1,2 @@ export { WelcomeEmbed } from './WelcomeEmbed' +export { PortingEmbed } from './PortingEmbed' diff --git a/src/App.css b/src/App.css index b9d355d..74b17e3 100644 --- a/src/App.css +++ b/src/App.css @@ -2,7 +2,6 @@ max-width: 1280px; margin: 0 auto; padding: 2rem; - text-align: center; } .logo { diff --git a/src/App.tsx b/src/App.tsx index bf7e0c8..605683f 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,33 +1,51 @@ import './App.css' -import { useEffect, useRef } from 'preact/hooks' +import { useRef, useState } from 'preact/hooks' +import * as React from 'react' -import { WelcomeEmbed } from '../lib/main' +import { PortingEmbed } from '../lib/PortingEmbed' function App() { - const $welcomeEmbedEl = useRef(null) - - useEffect(() => { - async function main() { - const embed = await WelcomeEmbed('abc', { name: 'Jerry' }) - embed.mount($welcomeEmbedEl.current!) - - embed.on('count', (count) => { - if (count > 5) { - embed.update({ name: `Elaine ${count}` }) - } - }) + const $portingEmbedEl = useRef(null) + const [loading, setLoading] = useState<'idle' | 'loading' | 'loaded'>('idle') + + async function handleSubmit( + event: React.JSX.TargetedSubmitEvent, + ) { + event.preventDefault() + + try { + setLoading('loading') + const formData = new FormData(event.currentTarget) + const csn = formData.get('csn')!.toString() + const project = formData.get('project')!.toString() + + const embed = await PortingEmbed(JSON.parse(csn), { project }) + embed.mount($portingEmbedEl.current!) + } catch (error) { + console.error(error) + setLoading('idle') } - - main() - }, []) + } return ( <> -
-

WelcomeEmbed

-
-
+
+ +