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:
@@ -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: [
|
||||
{
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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';
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -188,7 +188,9 @@ export function AccountSelector({
|
||||
>
|
||||
{(accounts ?? []).map((account) => (
|
||||
<CommandItem
|
||||
data-test={'account-selector-team-' + account.value}
|
||||
data-test={'account-selector-team'}
|
||||
data-name={account.label}
|
||||
data-slug={account.value}
|
||||
className={'group'}
|
||||
key={account.value}
|
||||
value={account.value ?? ''}
|
||||
|
||||
@@ -60,7 +60,11 @@ export function AcceptInvitationContainer(props: {
|
||||
</div>
|
||||
|
||||
<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
|
||||
|
||||
@@ -65,7 +65,11 @@ export function AccountInvitationsTable({
|
||||
placeholder={t(`searchInvitations`)}
|
||||
/>
|
||||
|
||||
<DataTable columns={columns} data={filteredInvitations} />
|
||||
<DataTable
|
||||
data-cy={'invitations-table'}
|
||||
columns={columns}
|
||||
data={filteredInvitations}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -87,7 +91,10 @@ function useGetColumns(permissions: {
|
||||
const email = member.email;
|
||||
|
||||
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>
|
||||
<ProfileAvatar text={email} />
|
||||
</span>
|
||||
@@ -166,19 +173,28 @@ function ActionsDropdown({
|
||||
|
||||
<DropdownMenuContent>
|
||||
<If condition={permissions.canUpdateInvitation}>
|
||||
<DropdownMenuItem onClick={() => setIsUpdatingRole(true)}>
|
||||
<DropdownMenuItem
|
||||
data-test={'update-invitation-trigger'}
|
||||
onClick={() => setIsUpdatingRole(true)}
|
||||
>
|
||||
<Trans i18nKey={'teams:updateInvitation'} />
|
||||
</DropdownMenuItem>
|
||||
|
||||
<If condition={getIsInviteExpired(invitation.expires_at)}>
|
||||
<DropdownMenuItem onClick={() => setIsRenewingInvite(true)}>
|
||||
<DropdownMenuItem
|
||||
data-test={'renew-invitation-trigger'}
|
||||
onClick={() => setIsRenewingInvite(true)}
|
||||
>
|
||||
<Trans i18nKey={'teams:renewInvitation'} />
|
||||
</DropdownMenuItem>
|
||||
</If>
|
||||
</If>
|
||||
|
||||
<If condition={permissions.canRemoveInvitation}>
|
||||
<DropdownMenuItem onClick={() => setIsDeletingInvite(true)}>
|
||||
<DropdownMenuItem
|
||||
data-test={'remove-invitation-trigger'}
|
||||
onClick={() => setIsDeletingInvite(true)}
|
||||
>
|
||||
<Trans i18nKey={'teams:removeInvitation'} />
|
||||
</DropdownMenuItem>
|
||||
</If>
|
||||
|
||||
@@ -66,7 +66,7 @@ function DeleteInvitationForm({
|
||||
};
|
||||
|
||||
return (
|
||||
<form action={onInvitationRemoved}>
|
||||
<form data-test={'delete-invitation-form'} action={onInvitationRemoved}>
|
||||
<div className={'flex flex-col space-y-6'}>
|
||||
<p className={'text-muted-foreground text-sm'}>
|
||||
<Trans i18nKey={'common:modalConfirmationQuestion'} />
|
||||
@@ -82,7 +82,7 @@ function DeleteInvitationForm({
|
||||
</AlertDialogCancel>
|
||||
|
||||
<Button
|
||||
data-test={'confirm-delete-invitation'}
|
||||
type={'submit'}
|
||||
variant={'destructive'}
|
||||
disabled={isSubmitting}
|
||||
>
|
||||
|
||||
@@ -9,7 +9,7 @@ export function InvitationSubmitButton(props: { accountName: string }) {
|
||||
const { pending } = useFormStatus();
|
||||
|
||||
return (
|
||||
<Button className={'w-full'} disabled={pending}>
|
||||
<Button type={'submit'} className={'w-full'} disabled={pending}>
|
||||
<Trans
|
||||
i18nKey={pending ? 'teams:joiningTeam' : 'teams:joinTeam'}
|
||||
values={{
|
||||
|
||||
@@ -25,7 +25,7 @@ import {
|
||||
import { If } from '@kit/ui/if';
|
||||
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 { MembershipRoleSelector } from '../members/membership-role-selector';
|
||||
import { RolesDataProvider } from '../members/roles-data-provider';
|
||||
@@ -60,20 +60,13 @@ export const UpdateInvitationDialog: React.FC<{
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<RolesDataProvider
|
||||
accountId={account}
|
||||
maxRoleHierarchy={userRoleHierarchy}
|
||||
>
|
||||
{(roles) => (
|
||||
<UpdateInvitationForm
|
||||
account={account}
|
||||
invitationId={invitationId}
|
||||
userRole={userRole}
|
||||
userRoleHierarchy={roles.length}
|
||||
userRoleHierarchy={userRoleHierarchy}
|
||||
setIsOpen={setIsOpen}
|
||||
/>
|
||||
)}
|
||||
</RolesDataProvider>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
@@ -113,7 +106,7 @@ function UpdateInvitationForm({
|
||||
|
||||
const form = useForm({
|
||||
resolver: zodResolver(
|
||||
UpdateMemberRoleSchema.refine(
|
||||
RoleSchema.refine(
|
||||
(data) => {
|
||||
return data.role !== userRole;
|
||||
},
|
||||
@@ -133,6 +126,7 @@ function UpdateInvitationForm({
|
||||
return (
|
||||
<Form {...form}>
|
||||
<form
|
||||
data-test={'update-invitation-form'}
|
||||
onSubmit={form.handleSubmit(onSubmit)}
|
||||
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'} />
|
||||
</Button>
|
||||
</form>
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -59,6 +59,7 @@ export function DataTable<TData, TValue>({
|
||||
table.getRowModel().rows.map((row) => (
|
||||
<TableRow
|
||||
key={row.id}
|
||||
data-row-id={row.id}
|
||||
data-state={row.getIsSelected() && 'selected'}
|
||||
>
|
||||
{row.getVisibleCells().map((cell) => (
|
||||
|
||||
Reference in New Issue
Block a user