From b6b9a9462f4b61dde9e0b72184eae003a4a4ed1f Mon Sep 17 00:00:00 2001 From: giancarlo Date: Sat, 13 Apr 2024 15:27:21 +0800 Subject: [PATCH] Add data testing attributes and adapt tests for team account invitations This commit adds data testing attributes to key elements in the team account invitations features. It also modifies e2e tests to make use of these attributes. Additionally, it introduces minor tweaks to other parts of the system to better facilitate testing, such as adjustments in timeouts and update of some log messages. --- apps/e2e/playwright.config.ts | 9 ++- apps/e2e/tests/authentication/auth.po.ts | 8 +- apps/e2e/tests/invitations/invitations.po.ts | 44 ++++++++++- .../e2e/tests/invitations/invitations.spec.ts | 79 ++++++++++++++++++- .../tests/team-accounts/team-accounts.po.ts | 12 ++- apps/e2e/tests/utils/mailbox.ts | 6 +- .../migrations/20221215192558_schema.sql | 16 ++-- .../src/components/account-selector.tsx | 4 +- .../accept-invitation-container.tsx | 6 +- .../invitations/account-invitations-table.tsx | 26 ++++-- .../invitations/delete-invitation-dialog.tsx | 4 +- .../invitations/invitation-submit-button.tsx | 2 +- .../invitations/update-invitation-dialog.tsx | 58 ++++++-------- .../account-per-seat-billing.service.ts | 6 +- packages/ui/src/shadcn/data-table.tsx | 1 + 15 files changed, 219 insertions(+), 62 deletions(-) diff --git a/apps/e2e/playwright.config.ts b/apps/e2e/playwright.config.ts index 7f18f4e96..7298059ef 100644 --- a/apps/e2e/playwright.config.ts +++ b/apps/e2e/playwright.config.ts @@ -17,7 +17,7 @@ export default defineConfig({ forbidOnly: !!process.env.CI, retries: process.env.CI ? 3 : 1, /* Limit parallel tests on CI. */ - workers: process.env.CI ? 2 : undefined, + workers: process.env.CI ? 1 : undefined, /* Reporter to use. See https://playwright.dev/docs/test-reporters */ reporter: 'html', /* Shared settings for all the projects below. See https://playwright.dev/docs/api/class-testoptions. */ @@ -32,6 +32,13 @@ export default defineConfig({ trace: 'on-first-retry', }, + // test timeout set to 2 minutes + timeout: 2 * 60 * 1000, + expect: { + // expect timeout set to 10 seconds + timeout: 10 * 1000 + }, + /* Configure projects for major browsers */ projects: [ { diff --git a/apps/e2e/tests/authentication/auth.po.ts b/apps/e2e/tests/authentication/auth.po.ts index 09e92a7cb..6392ef654 100644 --- a/apps/e2e/tests/authentication/auth.po.ts +++ b/apps/e2e/tests/authentication/auth.po.ts @@ -27,7 +27,7 @@ export class AuthPageObject { email: string, password: string }) { - await this.page.waitForTimeout(100); + await this.page.waitForTimeout(1000); await this.page.fill('input[name="email"]', params.email); await this.page.fill('input[name="password"]', params.password); @@ -39,7 +39,7 @@ export class AuthPageObject { password: string, repeatPassword: string }) { - await this.page.waitForTimeout(100); + await this.page.waitForTimeout(1000); await this.page.fill('input[name="email"]', params.email); await this.page.fill('input[name="password"]', params.password); @@ -47,7 +47,9 @@ export class AuthPageObject { await this.page.click('button[type="submit"]'); } - async visitConfirmEmailLink(email: string) { + async visitConfirmEmailLink(email: string, params?: { + deleteAfter: boolean + }) { return expect(async() => { const res = await this.mailbox.visitMailbox(email); diff --git a/apps/e2e/tests/invitations/invitations.po.ts b/apps/e2e/tests/invitations/invitations.po.ts index 0d0a33ecb..cc73257ea 100644 --- a/apps/e2e/tests/invitations/invitations.po.ts +++ b/apps/e2e/tests/invitations/invitations.po.ts @@ -1,4 +1,4 @@ -import { Page } from '@playwright/test'; +import { expect, Page } from '@playwright/test'; import { AuthPageObject } from '../authentication/auth.po'; import { TeamAccountsPageObject } from '../team-accounts/team-accounts.po'; @@ -44,7 +44,7 @@ export class InvitationsPageObject { await form.locator('button[type="submit"]').click(); } - public navigateToMembers() { + navigateToMembers() { return this.page.locator('a', { hasText: 'Members', }).click(); @@ -54,7 +54,47 @@ export class InvitationsPageObject { await this.page.locator('[data-test="invite-members-form-trigger"]').click(); } + async getInvitations() { + return this.page.locator('[data-test="invitation-email"]'); + } + private getInviteForm() { return this.page.locator('[data-test="invite-members-form"]'); } + + async deleteInvitation(email: string) { + const actions = this.getInvitationRow(email).getByRole('button'); + + await actions.click(); + + await this.page.locator('[data-test="remove-invitation-trigger"]').click(); + + await this.page.click('[data-test="delete-invitation-form"] button[type="submit"]'); + } + + getInvitationRow(email: string) { + return this.page.getByRole('row', { name: email }); + } + + async updateInvitation(email: string, role: string) { + const row = this.getInvitationRow(email); + const actions = row.getByRole('button'); + + await actions.click(); + + await this.page.locator('[data-test="update-invitation-trigger"]').click(); + + await this.page.click(`[data-test="role-selector-trigger"]`); + await this.page.click(`[data-test="role-option-${role}"]`); + + await this.page.click('[data-test="update-invitation-form"] button[type="submit"]'); + } + + async acceptInvitation() { + await this.page.locator('[data-test="join-team-form"] button[type="submit"]').click(); + + await this.page.waitForResponse(response => { + return response.url().includes('/join') && response.request().method() === 'POST'; + }) + } } \ No newline at end of file diff --git a/apps/e2e/tests/invitations/invitations.spec.ts b/apps/e2e/tests/invitations/invitations.spec.ts index dc2202258..3fc150473 100644 --- a/apps/e2e/tests/invitations/invitations.spec.ts +++ b/apps/e2e/tests/invitations/invitations.spec.ts @@ -1,4 +1,4 @@ -import { Page, test } from '@playwright/test'; +import { expect, Page, test } from '@playwright/test'; import { InvitationsPageObject } from './invitations.po'; test.describe('Invitations', () => { @@ -12,7 +12,7 @@ test.describe('Invitations', () => { await invitations.setup(); }); - test('user invite users', async ({page}) => { + test('Full invite flow', async ({page}) => { await page.waitForLoadState('networkidle'); await invitations.navigateToMembers(); @@ -30,5 +30,80 @@ test.describe('Invitations', () => { ]; await invitations.inviteMembers(invites); + + const firstEmail = invites[0]!.email; + + await expect(await invitations.getInvitations()).toHaveCount(2) + + // sign out and sign in with the first email + await invitations.auth.signOut(); + + await invitations.auth.visitConfirmEmailLink(invites[0]!.email, { + deleteAfter: true + }); + + await invitations.auth.signUp({ + email: firstEmail, + password: 'password', + repeatPassword: 'password' + }); + + await invitations.auth.visitConfirmEmailLink(firstEmail); + + await invitations.acceptInvitation(); + + await invitations.teamAccounts.openAccountsSelector(); + + await expect(await invitations.teamAccounts.getTeams()).toHaveCount(1); + }); + + test('users can delete invites', async ({page}) => { + await page.waitForLoadState('networkidle'); + + await invitations.navigateToMembers(); + await invitations.openInviteForm(); + + const email = invitations.auth.createRandomEmail(); + + const invites = [ + { + email, + role: 'member' + }, + ]; + + await invitations.inviteMembers(invites); + + await expect(await invitations.getInvitations()).toHaveCount(1); + + await invitations.deleteInvitation(email); + + await expect(await invitations.getInvitations()).toHaveCount(0); + }); + + test('users can update invites', async ({page}) => { + await page.waitForLoadState('networkidle'); + + await invitations.navigateToMembers(); + await invitations.openInviteForm(); + + const email = invitations.auth.createRandomEmail(); + + const invites = [ + { + email, + role: 'member' + }, + ]; + + await invitations.inviteMembers(invites); + + await expect(await invitations.getInvitations()).toHaveCount(1); + + await invitations.updateInvitation(email, 'owner'); + + const row = invitations.getInvitationRow(email); + + await expect(row.locator('[data-test="member-role-badge"]')).toHaveText('owner'); }); }); diff --git a/apps/e2e/tests/team-accounts/team-accounts.po.ts b/apps/e2e/tests/team-accounts/team-accounts.po.ts index e494eda22..4e51d33bc 100644 --- a/apps/e2e/tests/team-accounts/team-accounts.po.ts +++ b/apps/e2e/tests/team-accounts/team-accounts.po.ts @@ -18,7 +18,17 @@ export class TeamAccountsPageObject { async getTeamFromSelector(teamSlug: string) { await this.openAccountsSelector(); - return this.page.locator(`[data-test="account-selector-team-${teamSlug}"]`); + return this.page.locator(`[data-test="account-selector-team"][data-value="${teamSlug}"]`); + } + + async selectAccount(teamName: string) { + await this.page.click(`[data-test="account-selector-team"][data-name="${teamName}"]`); + } + + async getTeams() { + await this.openAccountsSelector(); + + return this.page.locator('[data-test="account-selector-team"]'); } async goToSettings() { diff --git a/apps/e2e/tests/utils/mailbox.ts b/apps/e2e/tests/utils/mailbox.ts index 342cec84b..b7a250902 100644 --- a/apps/e2e/tests/utils/mailbox.ts +++ b/apps/e2e/tests/utils/mailbox.ts @@ -7,7 +7,9 @@ export class Mailbox { ) { } - async visitMailbox(email: string) { + async visitMailbox(email: string, params?: { + deleteAfter: boolean + }) { const mailbox = email.split('@')[0]; console.log(`Visiting mailbox ${mailbox} ...`) @@ -16,7 +18,7 @@ export class Mailbox { throw new Error('Invalid email'); } - const json = await this.getInviteEmail(mailbox); + const json = await this.getInviteEmail(mailbox, params); if (!json.body) { throw new Error('Email body was not found'); diff --git a/apps/web/supabase/migrations/20221215192558_schema.sql b/apps/web/supabase/migrations/20221215192558_schema.sql index 815b773b9..e0e43f54d 100644 --- a/apps/web/supabase/migrations/20221215192558_schema.sql +++ b/apps/web/supabase/migrations/20221215192558_schema.sql @@ -126,6 +126,14 @@ create type public.subscription_item_type as ENUM( 'metered' ); +/* +* Invitation Type +- We create the invitation type for the Supabase MakerKit. These types are used to manage the type of the invitation +*/ +create type public.invitation as ( + email text, + role varchar( 50)); + /* * ------------------------------------------------------- * Section: App Configuration @@ -1949,13 +1957,9 @@ language plpgsql; grant execute on function public.get_account_invitations(text) to authenticated, service_role; -create type kit.invitation as ( - email text, - role varchar( 50)); - create or replace function public.add_invitations_to_account(account_slug text, invitations - kit.invitation[]) + public.invitation[]) returns public.invitations[] as $$ declare @@ -1999,7 +2003,7 @@ $$ language plpgsql; grant execute on function public.add_invitations_to_account(text, - kit.invitation[]) to authenticated, service_role; + public.invitation[]) to authenticated, service_role; -- Storage -- Account Image diff --git a/packages/features/accounts/src/components/account-selector.tsx b/packages/features/accounts/src/components/account-selector.tsx index 85e64b38b..0591dffde 100644 --- a/packages/features/accounts/src/components/account-selector.tsx +++ b/packages/features/accounts/src/components/account-selector.tsx @@ -188,7 +188,9 @@ export function AccountSelector({ > {(accounts ?? []).map((account) => (
-
+ - +
); } @@ -87,7 +91,10 @@ function useGetColumns(permissions: { const email = member.email; return ( - + @@ -166,19 +173,28 @@ function ActionsDropdown({ - setIsUpdatingRole(true)}> + setIsUpdatingRole(true)} + > - setIsRenewingInvite(true)}> + setIsRenewingInvite(true)} + > - setIsDeletingInvite(true)}> + setIsDeletingInvite(true)} + > diff --git a/packages/features/team-accounts/src/components/invitations/delete-invitation-dialog.tsx b/packages/features/team-accounts/src/components/invitations/delete-invitation-dialog.tsx index 919780792..0f734e854 100644 --- a/packages/features/team-accounts/src/components/invitations/delete-invitation-dialog.tsx +++ b/packages/features/team-accounts/src/components/invitations/delete-invitation-dialog.tsx @@ -66,7 +66,7 @@ function DeleteInvitationForm({ }; return ( - +

@@ -82,7 +82,7 @@ function DeleteInvitationForm({ diff --git a/packages/features/team-accounts/src/server/services/account-per-seat-billing.service.ts b/packages/features/team-accounts/src/server/services/account-per-seat-billing.service.ts index 46d06bacd..74a956fd7 100644 --- a/packages/features/team-accounts/src/server/services/account-per-seat-billing.service.ts +++ b/packages/features/team-accounts/src/server/services/account-per-seat-billing.service.ts @@ -17,7 +17,7 @@ export class AccountPerSeatBillingService { logger.info( ctx, - `Getting per-seat subscription item for account ${accountId}...`, + `Retrieving per-seat subscription item for account ${accountId}...`, ); const { data, error } = await this.client @@ -34,7 +34,7 @@ export class AccountPerSeatBillingService { `, ) .eq('account_id', accountId) - .eq('subscription_items.type', 'per-seat') + .eq('subscription_items.type', 'per_seat') .maybeSingle(); if (error) { @@ -52,7 +52,7 @@ export class AccountPerSeatBillingService { if (!data?.subscription_items) { logger.info( ctx, - `No per-seat subscription item found for account ${accountId}. Exiting...`, + `Account is not subscribed to a per-seat subscription. Exiting...`, ); return; diff --git a/packages/ui/src/shadcn/data-table.tsx b/packages/ui/src/shadcn/data-table.tsx index 41b88f175..b5af19e7f 100644 --- a/packages/ui/src/shadcn/data-table.tsx +++ b/packages/ui/src/shadcn/data-table.tsx @@ -59,6 +59,7 @@ export function DataTable({ table.getRowModel().rows.map((row) => ( {row.getVisibleCells().map((cell) => (