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.
This commit is contained in:
giancarlo
2024-04-13 15:27:21 +08:00
parent 24e5c0debd
commit b6b9a9462f
15 changed files with 219 additions and 62 deletions

View File

@@ -17,7 +17,7 @@ export default defineConfig({
forbidOnly: !!process.env.CI, forbidOnly: !!process.env.CI,
retries: process.env.CI ? 3 : 1, retries: process.env.CI ? 3 : 1,
/* Limit parallel tests on CI. */ /* 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 to use. See https://playwright.dev/docs/test-reporters */
reporter: 'html', reporter: 'html',
/* Shared settings for all the projects below. See https://playwright.dev/docs/api/class-testoptions. */ /* 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', 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 */ /* Configure projects for major browsers */
projects: [ projects: [
{ {

View File

@@ -27,7 +27,7 @@ export class AuthPageObject {
email: string, email: string,
password: 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="email"]', params.email);
await this.page.fill('input[name="password"]', params.password); await this.page.fill('input[name="password"]', params.password);
@@ -39,7 +39,7 @@ export class AuthPageObject {
password: string, password: string,
repeatPassword: 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="email"]', params.email);
await this.page.fill('input[name="password"]', params.password); await this.page.fill('input[name="password"]', params.password);
@@ -47,7 +47,9 @@ export class AuthPageObject {
await this.page.click('button[type="submit"]'); await this.page.click('button[type="submit"]');
} }
async visitConfirmEmailLink(email: string) { async visitConfirmEmailLink(email: string, params?: {
deleteAfter: boolean
}) {
return expect(async() => { return expect(async() => {
const res = await this.mailbox.visitMailbox(email); const res = await this.mailbox.visitMailbox(email);

View File

@@ -1,4 +1,4 @@
import { Page } from '@playwright/test'; import { expect, Page } from '@playwright/test';
import { AuthPageObject } from '../authentication/auth.po'; import { AuthPageObject } from '../authentication/auth.po';
import { TeamAccountsPageObject } from '../team-accounts/team-accounts.po'; import { TeamAccountsPageObject } from '../team-accounts/team-accounts.po';
@@ -44,7 +44,7 @@ export class InvitationsPageObject {
await form.locator('button[type="submit"]').click(); await form.locator('button[type="submit"]').click();
} }
public navigateToMembers() { navigateToMembers() {
return this.page.locator('a', { return this.page.locator('a', {
hasText: 'Members', hasText: 'Members',
}).click(); }).click();
@@ -54,7 +54,47 @@ export class InvitationsPageObject {
await this.page.locator('[data-test="invite-members-form-trigger"]').click(); await this.page.locator('[data-test="invite-members-form-trigger"]').click();
} }
async getInvitations() {
return this.page.locator('[data-test="invitation-email"]');
}
private getInviteForm() { private getInviteForm() {
return this.page.locator('[data-test="invite-members-form"]'); 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';
})
}
} }

View File

@@ -1,4 +1,4 @@
import { Page, test } from '@playwright/test'; import { expect, Page, test } from '@playwright/test';
import { InvitationsPageObject } from './invitations.po'; import { InvitationsPageObject } from './invitations.po';
test.describe('Invitations', () => { test.describe('Invitations', () => {
@@ -12,7 +12,7 @@ test.describe('Invitations', () => {
await invitations.setup(); await invitations.setup();
}); });
test('user invite users', async ({page}) => { test('Full invite flow', async ({page}) => {
await page.waitForLoadState('networkidle'); await page.waitForLoadState('networkidle');
await invitations.navigateToMembers(); await invitations.navigateToMembers();
@@ -30,5 +30,80 @@ test.describe('Invitations', () => {
]; ];
await invitations.inviteMembers(invites); 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');
}); });
}); });

View File

@@ -18,7 +18,17 @@ export class TeamAccountsPageObject {
async getTeamFromSelector(teamSlug: string) { async getTeamFromSelector(teamSlug: string) {
await this.openAccountsSelector(); 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() { async goToSettings() {

View File

@@ -7,7 +7,9 @@ export class Mailbox {
) { ) {
} }
async visitMailbox(email: string) { async visitMailbox(email: string, params?: {
deleteAfter: boolean
}) {
const mailbox = email.split('@')[0]; const mailbox = email.split('@')[0];
console.log(`Visiting mailbox ${mailbox} ...`) console.log(`Visiting mailbox ${mailbox} ...`)
@@ -16,7 +18,7 @@ export class Mailbox {
throw new Error('Invalid email'); throw new Error('Invalid email');
} }
const json = await this.getInviteEmail(mailbox); const json = await this.getInviteEmail(mailbox, params);
if (!json.body) { if (!json.body) {
throw new Error('Email body was not found'); throw new Error('Email body was not found');

View File

@@ -126,6 +126,14 @@ create type public.subscription_item_type as ENUM(
'metered' '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 * Section: App Configuration
@@ -1949,13 +1957,9 @@ language plpgsql;
grant execute on function public.get_account_invitations(text) to grant execute on function public.get_account_invitations(text) to
authenticated, service_role; authenticated, service_role;
create type kit.invitation as (
email text,
role varchar( 50));
create or replace function create or replace function
public.add_invitations_to_account(account_slug text, invitations public.add_invitations_to_account(account_slug text, invitations
kit.invitation[]) public.invitation[])
returns public.invitations[] returns public.invitations[]
as $$ as $$
declare declare
@@ -1999,7 +2003,7 @@ $$
language plpgsql; language plpgsql;
grant execute on function public.add_invitations_to_account(text, grant execute on function public.add_invitations_to_account(text,
kit.invitation[]) to authenticated, service_role; public.invitation[]) to authenticated, service_role;
-- Storage -- Storage
-- Account Image -- Account Image

View File

@@ -188,7 +188,9 @@ export function AccountSelector({
> >
{(accounts ?? []).map((account) => ( {(accounts ?? []).map((account) => (
<CommandItem <CommandItem
data-test={'account-selector-team-' + account.value} data-test={'account-selector-team'}
data-name={account.label}
data-slug={account.value}
className={'group'} className={'group'}
key={account.value} key={account.value}
value={account.value ?? ''} value={account.value ?? ''}

View File

@@ -60,7 +60,11 @@ export function AcceptInvitationContainer(props: {
</div> </div>
<div className={'flex flex-col space-y-2.5'}> <div className={'flex flex-col space-y-2.5'}>
<form className={'w-full'} action={acceptInvitationAction}> <form
data-test={'join-team-form'}
className={'w-full'}
action={acceptInvitationAction}
>
<input type="hidden" name={'inviteToken'} value={props.inviteToken} /> <input type="hidden" name={'inviteToken'} value={props.inviteToken} />
<input <input

View File

@@ -65,7 +65,11 @@ export function AccountInvitationsTable({
placeholder={t(`searchInvitations`)} placeholder={t(`searchInvitations`)}
/> />
<DataTable columns={columns} data={filteredInvitations} /> <DataTable
data-cy={'invitations-table'}
columns={columns}
data={filteredInvitations}
/>
</div> </div>
); );
} }
@@ -87,7 +91,10 @@ function useGetColumns(permissions: {
const email = member.email; const email = member.email;
return ( return (
<span className={'flex items-center space-x-4 text-left'}> <span
data-test={'invitation-email'}
className={'flex items-center space-x-4 text-left'}
>
<span> <span>
<ProfileAvatar text={email} /> <ProfileAvatar text={email} />
</span> </span>
@@ -166,19 +173,28 @@ function ActionsDropdown({
<DropdownMenuContent> <DropdownMenuContent>
<If condition={permissions.canUpdateInvitation}> <If condition={permissions.canUpdateInvitation}>
<DropdownMenuItem onClick={() => setIsUpdatingRole(true)}> <DropdownMenuItem
data-test={'update-invitation-trigger'}
onClick={() => setIsUpdatingRole(true)}
>
<Trans i18nKey={'teams:updateInvitation'} /> <Trans i18nKey={'teams:updateInvitation'} />
</DropdownMenuItem> </DropdownMenuItem>
<If condition={getIsInviteExpired(invitation.expires_at)}> <If condition={getIsInviteExpired(invitation.expires_at)}>
<DropdownMenuItem onClick={() => setIsRenewingInvite(true)}> <DropdownMenuItem
data-test={'renew-invitation-trigger'}
onClick={() => setIsRenewingInvite(true)}
>
<Trans i18nKey={'teams:renewInvitation'} /> <Trans i18nKey={'teams:renewInvitation'} />
</DropdownMenuItem> </DropdownMenuItem>
</If> </If>
</If> </If>
<If condition={permissions.canRemoveInvitation}> <If condition={permissions.canRemoveInvitation}>
<DropdownMenuItem onClick={() => setIsDeletingInvite(true)}> <DropdownMenuItem
data-test={'remove-invitation-trigger'}
onClick={() => setIsDeletingInvite(true)}
>
<Trans i18nKey={'teams:removeInvitation'} /> <Trans i18nKey={'teams:removeInvitation'} />
</DropdownMenuItem> </DropdownMenuItem>
</If> </If>

View File

@@ -66,7 +66,7 @@ function DeleteInvitationForm({
}; };
return ( return (
<form action={onInvitationRemoved}> <form data-test={'delete-invitation-form'} action={onInvitationRemoved}>
<div className={'flex flex-col space-y-6'}> <div className={'flex flex-col space-y-6'}>
<p className={'text-muted-foreground text-sm'}> <p className={'text-muted-foreground text-sm'}>
<Trans i18nKey={'common:modalConfirmationQuestion'} /> <Trans i18nKey={'common:modalConfirmationQuestion'} />
@@ -82,7 +82,7 @@ function DeleteInvitationForm({
</AlertDialogCancel> </AlertDialogCancel>
<Button <Button
data-test={'confirm-delete-invitation'} type={'submit'}
variant={'destructive'} variant={'destructive'}
disabled={isSubmitting} disabled={isSubmitting}
> >

View File

@@ -9,7 +9,7 @@ export function InvitationSubmitButton(props: { accountName: string }) {
const { pending } = useFormStatus(); const { pending } = useFormStatus();
return ( return (
<Button className={'w-full'} disabled={pending}> <Button type={'submit'} className={'w-full'} disabled={pending}>
<Trans <Trans
i18nKey={pending ? 'teams:joiningTeam' : 'teams:joinTeam'} i18nKey={pending ? 'teams:joiningTeam' : 'teams:joinTeam'}
values={{ values={{

View File

@@ -25,7 +25,7 @@ import {
import { If } from '@kit/ui/if'; import { If } from '@kit/ui/if';
import { Trans } from '@kit/ui/trans'; import { Trans } from '@kit/ui/trans';
import { UpdateMemberRoleSchema } from '../../schema/update-member-role.schema'; import { RoleSchema } from '../../schema/update-member-role.schema';
import { updateInvitationAction } from '../../server/actions/team-invitations-server-actions'; import { updateInvitationAction } from '../../server/actions/team-invitations-server-actions';
import { MembershipRoleSelector } from '../members/membership-role-selector'; import { MembershipRoleSelector } from '../members/membership-role-selector';
import { RolesDataProvider } from '../members/roles-data-provider'; import { RolesDataProvider } from '../members/roles-data-provider';
@@ -47,37 +47,30 @@ export const UpdateInvitationDialog: React.FC<{
userRoleHierarchy, userRoleHierarchy,
account, account,
}) => { }) => {
return ( return (
<Dialog open={isOpen} onOpenChange={setIsOpen}> <Dialog open={isOpen} onOpenChange={setIsOpen}>
<DialogContent> <DialogContent>
<DialogHeader> <DialogHeader>
<DialogTitle> <DialogTitle>
<Trans i18nKey={'teams:updateMemberRoleModalHeading'} /> <Trans i18nKey={'teams:updateMemberRoleModalHeading'} />
</DialogTitle> </DialogTitle>
<DialogDescription> <DialogDescription>
<Trans i18nKey={'teams:updateMemberRoleModalDescription'} /> <Trans i18nKey={'teams:updateMemberRoleModalDescription'} />
</DialogDescription> </DialogDescription>
</DialogHeader> </DialogHeader>
<RolesDataProvider <UpdateInvitationForm
accountId={account} account={account}
maxRoleHierarchy={userRoleHierarchy} invitationId={invitationId}
> userRole={userRole}
{(roles) => ( userRoleHierarchy={userRoleHierarchy}
<UpdateInvitationForm setIsOpen={setIsOpen}
account={account} />
invitationId={invitationId} </DialogContent>
userRole={userRole} </Dialog>
userRoleHierarchy={roles.length} );
setIsOpen={setIsOpen} };
/>
)}
</RolesDataProvider>
</DialogContent>
</Dialog>
);
};
function UpdateInvitationForm({ function UpdateInvitationForm({
account, account,
@@ -113,7 +106,7 @@ function UpdateInvitationForm({
const form = useForm({ const form = useForm({
resolver: zodResolver( resolver: zodResolver(
UpdateMemberRoleSchema.refine( RoleSchema.refine(
(data) => { (data) => {
return data.role !== userRole; return data.role !== userRole;
}, },
@@ -133,6 +126,7 @@ function UpdateInvitationForm({
return ( return (
<Form {...form}> <Form {...form}>
<form <form
data-test={'update-invitation-form'}
onSubmit={form.handleSubmit(onSubmit)} onSubmit={form.handleSubmit(onSubmit)}
className={'flex flex-col space-y-6'} className={'flex flex-col space-y-6'}
> >
@@ -177,7 +171,7 @@ function UpdateInvitationForm({
}} }}
/> />
<Button data-test={'confirm-update-member-role'} disabled={pending}> <Button type={'submit'} disabled={pending}>
<Trans i18nKey={'teams:updateRoleSubmitLabel'} /> <Trans i18nKey={'teams:updateRoleSubmitLabel'} />
</Button> </Button>
</form> </form>

View File

@@ -17,7 +17,7 @@ export class AccountPerSeatBillingService {
logger.info( logger.info(
ctx, ctx,
`Getting per-seat subscription item for account ${accountId}...`, `Retrieving per-seat subscription item for account ${accountId}...`,
); );
const { data, error } = await this.client const { data, error } = await this.client
@@ -34,7 +34,7 @@ export class AccountPerSeatBillingService {
`, `,
) )
.eq('account_id', accountId) .eq('account_id', accountId)
.eq('subscription_items.type', 'per-seat') .eq('subscription_items.type', 'per_seat')
.maybeSingle(); .maybeSingle();
if (error) { if (error) {
@@ -52,7 +52,7 @@ export class AccountPerSeatBillingService {
if (!data?.subscription_items) { if (!data?.subscription_items) {
logger.info( logger.info(
ctx, ctx,
`No per-seat subscription item found for account ${accountId}. Exiting...`, `Account is not subscribed to a per-seat subscription. Exiting...`,
); );
return; return;

View File

@@ -59,6 +59,7 @@ export function DataTable<TData, TValue>({
table.getRowModel().rows.map((row) => ( table.getRowModel().rows.map((row) => (
<TableRow <TableRow
key={row.id} key={row.id}
data-row-id={row.id}
data-state={row.getIsSelected() && 'selected'} data-state={row.getIsSelected() && 'selected'}
> >
{row.getVisibleCells().map((cell) => ( {row.getVisibleCells().map((cell) => (