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
1 change: 1 addition & 0 deletions src/components/layout/AppHeader/UserActions.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@ export function UserActions({
<Button
onClick={() => showAuthDialog()}
size={isMobile ? "sm" : "default"}
data-testid="sign-in-button"
className="bg-purple-600 hover:bg-purple-700 text-white font-medium rounded-full px-6"
>
<LogIn className="h-4 w-4" />
Expand Down
1 change: 1 addition & 0 deletions src/components/layout/AppHeader/UserMenu.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@ export function UserMenu({
<Button
variant="ghost"
size={isMobile ? "sm" : "default"}
data-testid="user-menu-trigger"
className="flex items-center gap-2 rounded-full hover:bg-purple-600/10 transition-colors"
>
<UserAvatar
Expand Down
25 changes: 19 additions & 6 deletions src/pages/EditionView/tabs/ArtistsTab/SetCard/SetVotingButtons.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,10 @@
import { Button } from "@/components/ui/button";
import { VOTE_CONFIG, VOTES_TYPES, type VoteConfig } from "@/lib/voteConfig";
import {
VOTE_CONFIG,
VOTES_TYPES,
type VoteConfig,
type VoteType,
} from "@/lib/voteConfig";
import { useFestivalSet } from "../FestivalSetContext";
import { useUserVotes } from "@/api/voting/useUserVotes";
import { useVote } from "@/api/voting/useVote";
Expand Down Expand Up @@ -27,19 +32,19 @@ export function SetVotingButtons({
const containerClass =
layout === "horizontal" ? "flex items-center gap-2" : "space-y-3";

const voteButtons = VOTES_TYPES.map((voteType) => VOTE_CONFIG[voteType]);

return (
<div className={containerClass}>
{userVotesQuery.isLoading && (
<div
className={`h-4 w-4 animate-spin rounded-full border-2 border-t-transparent`}
/>
)}
{voteButtons.map((config) => {
{VOTES_TYPES.map((voteType) => {
const config = VOTE_CONFIG[voteType];
return (
<VoteButton
key={config.value}
key={voteType}
voteType={voteType}
config={config}
isSelected={userVoteForSet === config.value}
onClick={() => handleVote(config.value)}
Expand Down Expand Up @@ -79,6 +84,7 @@ export function SetVotingButtons({
}

function VoteButton({
voteType,
config,
layout,
isSelected,
Expand All @@ -87,6 +93,7 @@ function VoteButton({
voteCount,
isVoting,
}: {
voteType: VoteType;
isSelected: boolean;
config: VoteConfig;
size?: "sm" | "default";
Expand All @@ -105,13 +112,19 @@ function VoteButton({
size={size}
onClick={() => onClick()}
disabled={isVoting}
aria-pressed={isSelected}
data-testid={`vote-button-${voteType}`}
className={`${buttonClass} ${
isSelected ? config.buttonSelected : config.buttonUnselected
}`}
title={config.label}
>
<IconComponent className="h-4 w-4 mr-2" />
{layout === "horizontal" ? voteCount : `${config.label} (${voteCount})`}
<span data-testid={`vote-count-${voteType}`}>
{layout === "horizontal"
? voteCount
: `${config.label} (${voteCount})`}
</span>
Comment on lines +123 to +127
</Button>
</div>
);
Expand Down
24 changes: 20 additions & 4 deletions tests/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -75,9 +75,20 @@ The tests use the following environment variables:

- `TEST_SUPABASE_URL`: Local Supabase URL (default: `http://localhost:54321`)
- `TEST_SUPABASE_ANON_KEY`: Local Supabase anon key
- `PLAYWRIGHT_BASE_URL`: App base URL (default: `http://localhost:5173`)
- `TEST_USER_EMAIL`: Test user email (default: `test@example.com`)
- `TEST_USER_PASSWORD`: Test user password (default: `testpassword123`)
- `PLAYWRIGHT_BASE_URL`: App base URL (default: `http://localhost:8080`)
- `TEST_USER_EMAIL`: Base email used for the passwordless test voter (default: `e2e-voter`)
- `TEST_USER_EMAIL_DOMAIN`: Domain appended to the base email (default: `example.com`)
- `TEST_MAILPIT_URL`: Local Supabase's Mailpit inbox API, used to read OTP codes (default: `http://127.0.0.1:54324`)

### Authentication in tests

The app only supports passwordless sign-in (magic link + 6-digit OTP), so
`TestHelpers.signIn()` drives the real flow end-to-end: it submits an email,
polls the local Supabase stack's Mailpit inbox (`TEST_MAILPIT_URL`) for the
OTP email, extracts the code, and completes it. Each call defaults to a
per-process email (`generateTestEmail()`) so parallel workers never race over
the same Mailpit inbox or OTP code. New accounts get the onboarding dialog's
username step auto-completed so it doesn't block later interactions.

### Test Data

Expand Down Expand Up @@ -154,11 +165,16 @@ await testHelpers.takeScreenshot("test-name");
- Filtering functionality
- Artist detail navigation
- Empty state handling
4. **Voting** (`voting.spec.ts`)
- Casting each vote type (Must Go / Interested / Won't Go) and reflecting the selection
- Votes persisting across a reload
- 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

### Planned Tests

- User registration
- Voting functionality
- Group management
- Schedule viewing
- Admin features
Expand Down
10 changes: 7 additions & 3 deletions tests/config/test-env.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,10 @@
// Test environment configuration
export const TEST_CONFIG = {
// Test user credentials
TEST_USER_EMAIL: process.env.TEST_USER_EMAIL || "test@example.com",
TEST_USER_PASSWORD: process.env.TEST_USER_PASSWORD || "testpassword123",
// Base email for the passwordless test voter. Each signIn() call appends a
// per-worker suffix so parallel workers never race over the same OTP inbox.
TEST_USER_EMAIL_BASE: process.env.TEST_USER_EMAIL || "e2e-voter",
TEST_USER_EMAIL_DOMAIN: process.env.TEST_USER_EMAIL_DOMAIN || "example.com",
// Mailpit is the local Supabase stack's email testing service. Its REST
// API is used to read the OTP delivered to the test voter's inbox.
MAILPIT_URL: process.env.TEST_MAILPIT_URL || "http://127.0.0.1:54324",
};
168 changes: 168 additions & 0 deletions tests/e2e/voting.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,168 @@
import {
test,
expect,
type BrowserContext,
type Locator,
type Page,
} from "@playwright/test";
import { TestHelpers } from "../utils/test-helpers";

// Seeded via supabase/seed.sql: "Test festival" edition "Boom Festival 2025".
const EDITION_SETS_PATH = "/festivals/test/editions/2025/sets";

// Each scenario below targets a distinct seeded set so that parallel runs
// (and parallel Playwright projects) never race over the same votes row.
test.describe("Voting on a set", () => {
let context: BrowserContext;
let page: Page;
Comment on lines +15 to +17

test.beforeAll(async ({ browser }) => {
context = await browser.newContext();
page = await context.newPage();
await new TestHelpers(page).signIn();
});
Comment on lines +19 to +23

test.afterAll(async () => {
await context.close();
});

test("casts a Must Go vote and reflects the selected state", async () => {
const setCard = await goToSet(page, "Maya Jane Coles");
const mustGo = voteButton(setCard, "mustGo");

await mustGo.click();

await expect(mustGo).toHaveAttribute("aria-pressed", "true");
await expect(voteButton(setCard, "interested")).toHaveAttribute(
"aria-pressed",
"false",
);
await expect(voteButton(setCard, "wontGo")).toHaveAttribute(
"aria-pressed",
"false",
);
});

test("casts an Interested vote and reflects the selected state", async () => {
const setCard = await goToSet(page, "Ben Böhmer");
const interested = voteButton(setCard, "interested");

await interested.click();

await expect(interested).toHaveAttribute("aria-pressed", "true");
});

test("casts a Won't Go vote and reflects the selected state", async () => {
const setCard = await goToSet(page, "Kiara Scuro");
const wontGo = voteButton(setCard, "wontGo");

await wontGo.click();

await expect(wontGo).toHaveAttribute("aria-pressed", "true");
});

test("persists a vote across a reload", async () => {
const setCard = await goToSet(page, "Nils Frahm");
const mustGo = voteButton(setCard, "mustGo");

await mustGo.click();
await expect(mustGo).toHaveAttribute("aria-pressed", "true");

await page.reload();
await page.waitForLoadState("networkidle");

const setCardAfterReload = page
.getByTestId("artist-item")
.filter({ hasText: "Nils Frahm" });
await expect(voteButton(setCardAfterReload, "mustGo")).toHaveAttribute(
"aria-pressed",
"true",
);
});

test("changes a vote from one type to another instead of adding a second vote", async () => {
const setCard = await goToSet(page, "Charlotte de Witte");
const mustGo = voteButton(setCard, "mustGo");
const interested = voteButton(setCard, "interested");
const mustGoCount = voteCount(setCard, "mustGo");
const interestedCount = voteCount(setCard, "interested");

await mustGo.click();
await expect(mustGo).toHaveAttribute("aria-pressed", "true");

const mustGoCountAfterFirstVote = await readCount(mustGoCount);
const interestedCountBeforeSwitch = await readCount(interestedCount);

await interested.click();

await expect(interested).toHaveAttribute("aria-pressed", "true");
await expect(mustGo).toHaveAttribute("aria-pressed", "false");
await expect(mustGoCount).toHaveText(String(mustGoCountAfterFirstVote - 1));
await expect(interestedCount).toHaveText(
String(interestedCountBeforeSwitch + 1),
);
});

test("removes a vote and returns the set to the unvoted state", async () => {
const setCard = await goToSet(page, "Four Tet");
const mustGo = voteButton(setCard, "mustGo");
const mustGoCount = voteCount(setCard, "mustGo");

await mustGo.click();
await expect(mustGo).toHaveAttribute("aria-pressed", "true");
const countAfterVote = await readCount(mustGoCount);

await mustGo.click();

await expect(mustGo).toHaveAttribute("aria-pressed", "false");
await expect(mustGoCount).toHaveText(String(countAfterVote - 1));
});
});

test.describe("Voting without authentication", () => {
test("an unauthenticated vote attempt surfaces the sign-in prompt", async ({
page,
}) => {
const testHelpers = new TestHelpers(page);
await testHelpers.navigateTo(EDITION_SETS_PATH);

const setCard = page
.getByTestId("artist-item")
.filter({ hasText: "Moderat" });
const mustGo = voteButton(setCard, "mustGo");

await mustGo.click();

await expect(page.getByRole("dialog")).toBeVisible();
await expect(mustGo).toHaveAttribute("aria-pressed", "false");
});
});

async function goToSet(page: Page, setName: string): Promise<Locator> {
await page.goto(EDITION_SETS_PATH);
await page.waitForLoadState("networkidle");

const setCard = page.getByTestId("artist-item").filter({ hasText: setName });
await expect(setCard).toBeVisible();
return setCard;
}

function voteButton(
setCard: Locator,
voteType: "mustGo" | "interested" | "wontGo",
): Locator {
return setCard.locator(`[data-testid="vote-button-${voteType}"]:visible`);
}

function voteCount(
setCard: Locator,
voteType: "mustGo" | "interested" | "wontGo",
): Locator {
return setCard.locator(`[data-testid="vote-count-${voteType}"]:visible`);
}

async function readCount(locator: Locator): Promise<number> {
const text = (await locator.textContent()) ?? "";
const match = text.match(/\d+/);
return match ? parseInt(match[0], 10) : 0;
}
Loading
Loading