Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 7 additions & 2 deletions tests/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
> For a full overview of all testing infrastructure, CI/CD, and general troubleshooting, see [docs/TESTING.md](../docs/TESTING.md).
> All E2E test coverage, debugging, troubleshooting, and next steps are documented here.

This directory contains end-to-end tests for the Boom Voter application using Playwright.
This directory contains end-to-end tests for the UpLine application using Playwright.

## 🚀 Quick Start

Expand Down Expand Up @@ -185,11 +185,16 @@ await testHelpers.takeScreenshot("test-name");
- Changing a vote updates the selection and vote counts instead of adding a second vote
- Removing a vote returns the set to the unvoted state
- Unauthenticated vote attempts surface the sign-in prompt instead of recording a vote
5. **Groups** (`groups-flow.spec.ts`)
- Creating a group lists it under My Groups
- The creator sees the member list and can generate an invite link
- A second user joins via the invite link and appears in the member list
- A non-member can't see the group's detail page or find it in their own list
- Leaving a group removes it from the member's list

### Planned Tests

- User registration
- Group management
- Schedule viewing
- Admin features
- Mobile responsiveness
Expand Down
172 changes: 172 additions & 0 deletions tests/e2e/groups-flow.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,172 @@
import {
test,
expect,
type Browser,
type BrowserContext,
type BrowserContextOptions,
type Locator,
type Page,
} from "@playwright/test";
import { signIn, usernameFromEmail } from "../utils/login";

test.describe("Group lifecycle", () => {
// Each stage (create -> invite -> join -> leave) depends on state built up
// by the previous one, so these must run in order and never concurrently.
test.describe.configure({ mode: "serial" });

let creatorContext: BrowserContext;
let creatorPage: Page;
let creatorEmail: string;

let joinerContext: BrowserContext;
let joinerPage: Page;
let joinerEmail: string;

let outsiderContext: BrowserContext;
let outsiderPage: Page;

let groupName: string;
let groupSlug: string;
let inviteToken: string;

test.beforeAll(async ({ browser, baseURL, storageState }) => {
[creatorContext, creatorPage, creatorEmail] = await newSignedInPage(
browser,
baseURL,
storageState,
);
[joinerContext, joinerPage, joinerEmail] = await newSignedInPage(
browser,
baseURL,
storageState,
);
[outsiderContext, outsiderPage] = await newSignedInPage(
browser,
baseURL,
storageState,
);
});

test.afterAll(async () => {
await creatorContext?.close();
await joinerContext?.close();
await outsiderContext?.close();
});

test("creates a group and lists it under My Groups", async () => {
groupName = `E2E Group ${Date.now()}`;

await creatorPage.goto("/groups");
await creatorPage.getByRole("button", { name: "Create Group" }).click();

const dialog = creatorPage.getByRole("dialog");
await dialog.getByLabel("Group Name").fill(groupName);
await dialog.getByRole("button", { name: "Create Group" }).click();

await expect(creatorPage).toHaveURL(/\/groups\/[^/]+$/);
groupSlug = creatorPage.url().split("/groups/")[1];

await creatorPage.goto("/groups");
await expect(groupCard(creatorPage, groupName)).toBeVisible();
});

test("creator sees the member list and generates an invite link", async () => {
await creatorPage.goto(`/groups/${groupSlug}`);

await expect(
creatorPage.getByRole("heading", { name: "Group Members (1)" }),
).toBeVisible();
await expect(
creatorPage.getByText(`${usernameFromEmail(creatorEmail)} (You)`),
).toBeVisible();

// No rename feature exists anywhere in the app (no UI, mutation hook, or
// API route — groups only support the archived-flag soft-delete), so
// "manage" here covers what's actually buildable: the member list and
// the invite link below.
await creatorPage.getByRole("tab", { name: "Invite Links" }).click();

const [inviteRequest] = await Promise.all([
creatorPage.waitForRequest(
(request) =>
request.url().includes("/rest/v1/group_invites") &&
request.method() === "POST",
),
creatorPage.getByRole("button", { name: "Generate Invite Link" }).click(),
]);

inviteToken = (inviteRequest.postDataJSON() as { invite_token: string })
.invite_token;
expect(inviteToken).toBeTruthy();

// The invite becomes accessible to the creator afterward: it shows up
// in Active Invites, badged "Active", instead of the "No active
// invites" empty state. (Asserting the clipboard content itself is
// flaky across browser projects, hence reading the token off the
// request above instead.)
await expect(
creatorPage.getByText("Active", { exact: true }),
).toBeVisible();
});

test("a second user joins via the invite link and then sees the group", async () => {
const acceptResponse = joinerPage.waitForResponse(
(response) =>
response.url().includes("/rest/v1/rpc/use_invite_token") &&
response.ok(),
);
await joinerPage.goto(`/invite?token=${inviteToken}`);
await acceptResponse;

await joinerPage.goto("/groups");
await expect(groupCard(joinerPage, groupName)).toBeVisible();

await creatorPage.goto(`/groups/${groupSlug}`);
await expect(
creatorPage.getByRole("heading", { name: "Group Members (2)" }),
).toBeVisible();
await expect(
creatorPage.getByText(usernameFromEmail(joinerEmail), { exact: true }),
).toBeVisible();
});

test("a non-member cannot see the group's data", async () => {
await outsiderPage.goto(`/groups/${groupSlug}`);
await expect(
outsiderPage.getByText("Group not found or you don't have access"),
).toBeVisible();

await outsiderPage.goto("/groups");
await expect(groupCard(outsiderPage, groupName)).toHaveCount(0);
});

test("leaving a group removes it from the member's list", async () => {
await joinerPage.goto("/groups");
const card = groupCard(joinerPage, groupName);
await expect(card).toBeVisible();

await card.getByRole("button", { name: "Leave" }).click();

const confirmDialog = joinerPage.getByRole("alertdialog");
await expect(confirmDialog).toBeVisible();
await confirmDialog.getByRole("button", { name: "Leave" }).click();

await expect(card).toHaveCount(0);
});
});

function groupCard(page: Page, name: string): Locator {
return page.locator('a[href^="/groups/"]').filter({ hasText: name });
}

// Opens a fresh, isolated browser context signed in as a new test user.
async function newSignedInPage(
browser: Browser,
baseURL: string | undefined,
storageState: BrowserContextOptions["storageState"],
): Promise<[BrowserContext, Page, string]> {
const context = await browser.newContext({ baseURL, storageState });
const page = await context.newPage();
const email = await signIn(page);
return [context, page, email];
}
7 changes: 6 additions & 1 deletion tests/utils/login.ts
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,11 @@ export function generateTestEmail(
return `${TEST_CONFIG.TEST_USER_EMAIL_BASE}-${suffix}@${TEST_CONFIG.TEST_USER_EMAIL_DOMAIN}`;
}

// The username a pre-onboarded test user gets, derived the same way createPreOnboardedUser derives it.
export function usernameFromEmail(email: string): string {
return email.split("@")[0];
}

const ADMIN_HEADERS = {
"Content-Type": "application/json",
apikey: TEST_CONFIG.SUPABASE_SERVICE_ROLE_KEY,
Expand All @@ -93,7 +98,7 @@ const ADMIN_HEADERS = {

// Pre-creates an already-onboarded voter via the admin API so OTP sign-in never shows onboarding.
async function createPreOnboardedUser(email: string): Promise<string> {
const username = email.split("@")[0];
const username = usernameFromEmail(email);

const createResponse = await fetch(
`${TEST_CONFIG.SUPABASE_URL}/auth/v1/admin/users`,
Expand Down
Loading