Phase 0.3: Update E2E tests for cleanup

- Delete counter.spec.ts and random-jobs.spec.ts
- Rewrite permissions.spec.ts for new permission structure
- Update scripts/e2e.sh: remove worker.py execution
- Update generated api.ts types
This commit is contained in:
counterweight 2025-12-22 18:13:24 +01:00
parent a5c1eccb4b
commit c89e0312fa
Signed by: counterweight
GPG key ID: 883EDBAA726BD96C
5 changed files with 72 additions and 816 deletions

View file

@ -84,126 +84,6 @@ export interface paths {
patch?: never; patch?: never;
trace?: never; trace?: never;
}; };
"/api/sum": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get?: never;
put?: never;
/**
* Calculate Sum
* @description Calculate the sum of two numbers and record it.
*/
post: operations["calculate_sum_api_sum_post"];
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/api/counter": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
/**
* Get Counter
* @description Get the current counter value.
*/
get: operations["get_counter_api_counter_get"];
put?: never;
post?: never;
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/api/counter/increment": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get?: never;
put?: never;
/**
* Increment Counter
* @description Increment the counter, record the action, and enqueue a random number job.
*/
post: operations["increment_counter_api_counter_increment_post"];
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/api/audit/counter": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
/**
* Get Counter Records
* @description Get paginated counter action records.
*/
get: operations["get_counter_records_api_audit_counter_get"];
put?: never;
post?: never;
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/api/audit/sum": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
/**
* Get Sum Records
* @description Get paginated sum action records.
*/
get: operations["get_sum_records_api_audit_sum_get"];
put?: never;
post?: never;
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/api/audit/random-jobs": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
/**
* Get Random Job Outcomes
* @description Get all random number job outcomes, newest first.
*/
get: operations["get_random_job_outcomes_api_audit_random_jobs_get"];
put?: never;
post?: never;
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/api/audit/price-history": { "/api/audit/price-history": {
parameters: { parameters: {
query?: never; query?: never;
@ -694,25 +574,6 @@ export interface components {
/** Target Dates */ /** Target Dates */
target_dates: string[]; target_dates: string[];
}; };
/**
* CounterRecordResponse
* @description Response model for a counter audit record.
*/
CounterRecordResponse: {
/** Id */
id: number;
/** User Email */
user_email: string;
/** Value Before */
value_before: number;
/** Value After */
value_after: number;
/**
* Created At
* Format: date-time
*/
created_at: string;
};
/** HTTPValidationError */ /** HTTPValidationError */
HTTPValidationError: { HTTPValidationError: {
/** Detail */ /** Detail */
@ -786,19 +647,6 @@ export interface components {
/** Total Pages */ /** Total Pages */
total_pages: number; total_pages: number;
}; };
/** PaginatedResponse[CounterRecordResponse] */
PaginatedResponse_CounterRecordResponse_: {
/** Records */
records: components["schemas"]["CounterRecordResponse"][];
/** Total */
total: number;
/** Page */
page: number;
/** Per Page */
per_page: number;
/** Total Pages */
total_pages: number;
};
/** PaginatedResponse[InviteResponse] */ /** PaginatedResponse[InviteResponse] */
PaginatedResponse_InviteResponse_: { PaginatedResponse_InviteResponse_: {
/** Records */ /** Records */
@ -812,25 +660,12 @@ export interface components {
/** Total Pages */ /** Total Pages */
total_pages: number; total_pages: number;
}; };
/** PaginatedResponse[SumRecordResponse] */
PaginatedResponse_SumRecordResponse_: {
/** Records */
records: components["schemas"]["SumRecordResponse"][];
/** Total */
total: number;
/** Page */
page: number;
/** Per Page */
per_page: number;
/** Total Pages */
total_pages: number;
};
/** /**
* Permission * Permission
* @description All available permissions in the system. * @description All available permissions in the system.
* @enum {string} * @enum {string}
*/ */
Permission: "view_counter" | "increment_counter" | "use_sum" | "view_audit" | "fetch_price" | "manage_own_profile" | "manage_invites" | "view_own_invites" | "book_appointment" | "view_own_appointments" | "cancel_own_appointment" | "manage_availability" | "view_all_appointments" | "cancel_any_appointment"; Permission: "view_audit" | "fetch_price" | "manage_own_profile" | "manage_invites" | "view_own_invites" | "book_appointment" | "view_own_appointments" | "cancel_own_appointment" | "manage_availability" | "view_all_appointments" | "cancel_any_appointment";
/** /**
* PriceHistoryResponse * PriceHistoryResponse
* @description Response model for a price history record. * @description Response model for a price history record.
@ -885,31 +720,6 @@ export interface components {
/** Nostr Npub */ /** Nostr Npub */
nostr_npub?: string | null; nostr_npub?: string | null;
}; };
/**
* RandomNumberOutcomeResponse
* @description Response model for a random number job outcome.
*/
RandomNumberOutcomeResponse: {
/** Id */
id: number;
/** Job Id */
job_id: number;
/** Triggered By User Id */
triggered_by_user_id: number;
/** Triggered By Email */
triggered_by_email: string;
/** Value */
value: number;
/** Duration Ms */
duration_ms: number;
/** Status */
status: string;
/**
* Created At
* Format: date-time
*/
created_at: string;
};
/** /**
* RegisterWithInvite * RegisterWithInvite
* @description Request model for registration with invite. * @description Request model for registration with invite.
@ -938,49 +748,6 @@ export interface components {
/** Slots */ /** Slots */
slots: components["schemas"]["TimeSlot"][]; slots: components["schemas"]["TimeSlot"][];
}; };
/**
* SumRecordResponse
* @description Response model for a sum audit record.
*/
SumRecordResponse: {
/** Id */
id: number;
/** User Email */
user_email: string;
/** A */
a: number;
/** B */
b: number;
/** Result */
result: number;
/**
* Created At
* Format: date-time
*/
created_at: string;
};
/**
* SumRequest
* @description Request model for sum calculation.
*/
SumRequest: {
/** A */
a: number;
/** B */
b: number;
};
/**
* SumResponse
* @description Response model for sum calculation.
*/
SumResponse: {
/** A */
a: number;
/** B */
b: number;
/** Result */
result: number;
};
/** /**
* TimeSlot * TimeSlot
* @description A single time slot (start and end time). * @description A single time slot (start and end time).
@ -1171,167 +938,6 @@ export interface operations {
}; };
}; };
}; };
calculate_sum_api_sum_post: {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
requestBody: {
content: {
"application/json": components["schemas"]["SumRequest"];
};
};
responses: {
/** @description Successful Response */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["SumResponse"];
};
};
/** @description Validation Error */
422: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["HTTPValidationError"];
};
};
};
};
get_counter_api_counter_get: {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
requestBody?: never;
responses: {
/** @description Successful Response */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": {
[key: string]: number;
};
};
};
};
};
increment_counter_api_counter_increment_post: {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
requestBody?: never;
responses: {
/** @description Successful Response */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": {
[key: string]: number;
};
};
};
};
};
get_counter_records_api_audit_counter_get: {
parameters: {
query?: {
page?: number;
per_page?: number;
};
header?: never;
path?: never;
cookie?: never;
};
requestBody?: never;
responses: {
/** @description Successful Response */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["PaginatedResponse_CounterRecordResponse_"];
};
};
/** @description Validation Error */
422: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["HTTPValidationError"];
};
};
};
};
get_sum_records_api_audit_sum_get: {
parameters: {
query?: {
page?: number;
per_page?: number;
};
header?: never;
path?: never;
cookie?: never;
};
requestBody?: never;
responses: {
/** @description Successful Response */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["PaginatedResponse_SumRecordResponse_"];
};
};
/** @description Validation Error */
422: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["HTTPValidationError"];
};
};
};
};
get_random_job_outcomes_api_audit_random_jobs_get: {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
requestBody?: never;
responses: {
/** @description Successful Response */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["RandomNumberOutcomeResponse"][];
};
};
};
};
get_price_history_api_audit_price_history_get: { get_price_history_api_audit_price_history_get: {
parameters: { parameters: {
query?: never; query?: never;

View file

@ -1,217 +0,0 @@
import { test, expect, Page, APIRequestContext } from "@playwright/test";
const API_BASE = "http://localhost:8000";
const ADMIN_EMAIL = "admin@example.com";
const ADMIN_PASSWORD = "admin123";
// Helper to generate unique email for each test
function uniqueEmail(): string {
return `counter-${Date.now()}-${Math.random().toString(36).substring(7)}@example.com`;
}
// Helper to create an invite via API
async function createInvite(request: APIRequestContext): Promise<string> {
const loginResp = await request.post(`${API_BASE}/api/auth/login`, {
data: { email: ADMIN_EMAIL, password: ADMIN_PASSWORD },
});
const cookies = loginResp.headers()["set-cookie"];
const meResp = await request.get(`${API_BASE}/api/auth/me`, {
headers: { Cookie: cookies },
});
const admin = await meResp.json();
const inviteResp = await request.post(`${API_BASE}/api/admin/invites`, {
data: { godfather_id: admin.id },
headers: { Cookie: cookies },
});
const invite = await inviteResp.json();
return invite.identifier;
}
// Helper to authenticate a user with invite-based signup
async function authenticate(page: Page, request: APIRequestContext): Promise<string> {
const email = uniqueEmail();
const inviteCode = await createInvite(request);
await page.context().clearCookies();
await page.goto("/signup");
// Enter invite code first
await page.fill("input#inviteCode", inviteCode);
// Click and wait for invite check API to complete
await Promise.all([
page.waitForResponse((resp) => resp.url().includes("/check") && resp.status() === 200),
page.click('button[type="submit"]'),
]);
// Wait for registration form
await expect(page.locator("h1")).toHaveText("Create account");
// Fill registration
await page.fill("input#email", email);
await page.fill("input#password", "password123");
await page.fill("input#confirmPassword", "password123");
await page.click('button[type="submit"]');
await expect(page).toHaveURL("/");
return email;
}
test.describe("Counter - Authenticated", () => {
test("displays counter value", async ({ page, request }) => {
await authenticate(page, request);
await expect(page.locator("h1")).toBeVisible();
// Counter should be a number (not loading state)
const text = await page.locator("h1").textContent();
expect(text).toMatch(/^\d+$/);
});
test("displays current count label", async ({ page, request }) => {
await authenticate(page, request);
await expect(page.getByText("Current Count")).toBeVisible();
});
test("clicking increment button increases counter", async ({ page, request }) => {
await authenticate(page, request);
await expect(page.locator("h1")).not.toHaveText("...");
const before = await page.locator("h1").textContent();
await page.click("text=Increment");
await expect(page.locator("h1")).toHaveText(String(Number(before) + 1));
});
test("clicking increment multiple times", async ({ page, request }) => {
await authenticate(page, request);
await expect(page.locator("h1")).not.toHaveText("...");
const before = Number(await page.locator("h1").textContent());
// Click increment and wait for each update to complete
await page.click("text=Increment");
await expect(page.locator("h1")).not.toHaveText(String(before));
const afterFirst = Number(await page.locator("h1").textContent());
await page.click("text=Increment");
await expect(page.locator("h1")).not.toHaveText(String(afterFirst));
const afterSecond = Number(await page.locator("h1").textContent());
await page.click("text=Increment");
await expect(page.locator("h1")).not.toHaveText(String(afterSecond));
// Final value should be at least 3 more than we started with
const final = Number(await page.locator("h1").textContent());
expect(final).toBeGreaterThanOrEqual(before + 3);
});
test("counter persists after page reload", async ({ page, request }) => {
await authenticate(page, request);
await expect(page.locator("h1")).not.toHaveText("...");
const before = await page.locator("h1").textContent();
await page.click("text=Increment");
const expected = String(Number(before) + 1);
await expect(page.locator("h1")).toHaveText(expected);
await page.reload();
await expect(page.locator("h1")).toHaveText(expected);
});
test("counter is shared between users", async ({ page, browser, request }) => {
// First user increments
await authenticate(page, request);
await expect(page.locator("h1")).not.toHaveText("...");
const initialValue = Number(await page.locator("h1").textContent());
await page.click("text=Increment");
await page.click("text=Increment");
// Wait for the counter to update (value should increase by 2 from what this user started with)
await expect(page.locator("h1")).not.toHaveText(String(initialValue));
const afterFirstUser = Number(await page.locator("h1").textContent());
expect(afterFirstUser).toBeGreaterThan(initialValue);
// Second user in new context sees the current value
const page2 = await browser.newPage();
await authenticate(page2, request);
await expect(page2.locator("h1")).not.toHaveText("...");
const page2InitialValue = Number(await page2.locator("h1").textContent());
// The value should be at least what user 1 saw (might be higher due to parallel tests)
expect(page2InitialValue).toBeGreaterThanOrEqual(afterFirstUser);
// Second user increments
await page2.click("text=Increment");
// Wait for counter to update - use >= because parallel tests may also increment
await expect(page2.locator("h1")).not.toHaveText(String(page2InitialValue));
const page2AfterIncrement = Number(await page2.locator("h1").textContent());
expect(page2AfterIncrement).toBeGreaterThanOrEqual(page2InitialValue + 1);
// First user reloads and sees the increment (value should be >= what page2 has)
await page.reload();
await expect(page.locator("h1")).not.toHaveText("...");
const page1Reloaded = Number(await page.locator("h1").textContent());
expect(page1Reloaded).toBeGreaterThanOrEqual(page2InitialValue + 1);
await page2.close();
});
});
test.describe("Counter - Unauthenticated", () => {
test("redirects to login when accessing counter without auth", async ({ page }) => {
await page.context().clearCookies();
await page.goto("/");
await expect(page).toHaveURL("/login");
});
test("shows login form when redirected", async ({ page }) => {
await page.context().clearCookies();
await page.goto("/");
await expect(page.locator("h1")).toHaveText("Welcome back");
});
});
test.describe("Counter - Session Integration", () => {
test("can access counter after login", async ({ page, request }) => {
const email = uniqueEmail();
const inviteCode = await createInvite(request);
// Sign up with invite
await page.goto("/signup");
await page.fill("input#inviteCode", inviteCode);
await page.click('button[type="submit"]');
await expect(page.locator("h1")).toHaveText("Create account");
await page.fill("input#email", email);
await page.fill("input#password", "password123");
await page.fill("input#confirmPassword", "password123");
await page.click('button[type="submit"]');
await expect(page).toHaveURL("/");
// Logout
await page.click("text=Sign out");
await expect(page).toHaveURL("/login");
// Login again
await page.fill('input[type="email"]', email);
await page.fill('input[type="password"]', "password123");
await page.click('button[type="submit"]');
await expect(page).toHaveURL("/");
// Counter should be visible - wait for it to load (not showing "...")
await expect(page.locator("h1")).toBeVisible();
await expect(page.locator("h1")).not.toHaveText("...");
const text = await page.locator("h1").textContent();
expect(text).toMatch(/^\d+$/);
});
test("counter API requires authentication", async ({ page }) => {
// Try to access counter API directly without auth
const response = await page.request.get("http://localhost:8000/api/counter");
expect(response.status()).toBe(401);
});
test("counter increment API requires authentication", async ({ page }) => {
const response = await page.request.post("http://localhost:8000/api/counter/increment");
expect(response.status()).toBe(401);
});
});

View file

@ -4,8 +4,8 @@ import { test, expect, Page } from "@playwright/test";
* Permission-based E2E tests * Permission-based E2E tests
* *
* These tests verify that: * These tests verify that:
* 1. Regular users can only access Counter and Sum pages * 1. Regular users can access booking and appointments pages
* 2. Admin users can only access the Audit page * 2. Admin users can access admin pages (invites, availability, appointments)
* 3. Users are properly redirected based on their permissions * 3. Users are properly redirected based on their permissions
* 4. API calls respect permission boundaries * 4. API calls respect permission boundaries
*/ */
@ -64,87 +64,44 @@ test.describe("Regular User Access", () => {
await loginUser(page, REGULAR_USER.email, REGULAR_USER.password); await loginUser(page, REGULAR_USER.email, REGULAR_USER.password);
}); });
test("can access counter page", async ({ page }) => { test("redirected from home to booking page", async ({ page }) => {
await page.goto("/"); await page.goto("/");
// Should stay on counter page // Should be redirected to booking page
await expect(page).toHaveURL("/"); await expect(page).toHaveURL("/booking");
// Should see counter UI
await expect(page.getByText("Current Count")).toBeVisible();
await expect(page.getByRole("button", { name: /increment/i })).toBeVisible();
}); });
test("can access sum page", async ({ page }) => { test("can access booking page", async ({ page }) => {
await page.goto("/sum"); await page.goto("/booking");
// Should stay on sum page // Should stay on booking page
await expect(page).toHaveURL("/sum"); await expect(page).toHaveURL("/booking");
// Should see sum UI // Should see booking UI
await expect(page.getByText("Sum Calculator")).toBeVisible(); await expect(page.getByText("Book an Appointment")).toBeVisible();
}); });
test("cannot access audit page - redirected to counter", async ({ page }) => { test("can access appointments page", async ({ page }) => {
await page.goto("/audit"); await page.goto("/appointments");
// Should be redirected to counter page (home) // Should stay on appointments page
await expect(page).toHaveURL("/"); await expect(page).toHaveURL("/appointments");
// Should see appointments UI
await expect(page.getByText("My Appointments")).toBeVisible();
}); });
test("navigation only shows Counter and Sum", async ({ page }) => { test("navigation shows booking and appointments", async ({ page }) => {
await page.goto("/"); await page.goto("/appointments");
// Should see Counter and Sum in nav // From appointments page, we can see the nav links
await expect(page.getByText("Counter")).toBeVisible(); // "Appointments" is the current page (shown as span, not link)
await expect(page.getByText("Sum")).toBeVisible(); // "Book" should be a link - use first() since there may be other booking links on page
await expect(page.locator('a[href="/booking"]').first()).toBeVisible();
// Should NOT see Audit in nav (for regular users) // Should NOT see admin links
const auditLinks = page.locator('a[href="/audit"]'); const availabilityLinks = page.locator('a[href="/admin/availability"]');
await expect(auditLinks).toHaveCount(0); await expect(availabilityLinks).toHaveCount(0);
});
test("can navigate between Counter and Sum", async ({ page }) => {
await page.goto("/");
// Go to Sum
await page.click('a[href="/sum"]');
await expect(page).toHaveURL("/sum");
// Go back to Counter
await page.click('a[href="/"]');
await expect(page).toHaveURL("/");
});
test("can use counter functionality", async ({ page }) => {
await page.goto("/");
// Get initial count (might be any number)
const countElement = page.locator("h1").first();
await expect(countElement).toBeVisible();
// Click increment
await page.click('button:has-text("Increment")');
// Wait for update
await page.waitForTimeout(500);
// Counter should have updated (we just verify no error occurred)
await expect(countElement).toBeVisible();
});
test("can use sum functionality", async ({ page }) => {
await page.goto("/sum");
// Fill in numbers
await page.fill('input[aria-label="First number"]', "5");
await page.fill('input[aria-label="Second number"]', "3");
// Calculate
await page.click('button:has-text("Calculate")');
// Should show result
await expect(page.getByText("8")).toBeVisible();
}); });
}); });
@ -154,53 +111,44 @@ test.describe("Admin User Access", () => {
await loginUser(page, ADMIN_USER.email, ADMIN_USER.password); await loginUser(page, ADMIN_USER.email, ADMIN_USER.password);
}); });
test("redirected from counter page to audit", async ({ page }) => { test("redirected from home to admin appointments", async ({ page }) => {
await page.goto("/"); await page.goto("/");
// Should be redirected to audit page // Should be redirected to admin appointments page
await expect(page).toHaveURL("/audit"); await expect(page).toHaveURL("/admin/appointments");
}); });
test("redirected from sum page to audit", async ({ page }) => { test("can access admin appointments page", async ({ page }) => {
await page.goto("/sum"); await page.goto("/admin/appointments");
// Should be redirected to audit page // Should stay on admin appointments page
await expect(page).toHaveURL("/audit"); await expect(page).toHaveURL("/admin/appointments");
// Should see appointments UI (use heading for specificity)
await expect(page.getByRole("heading", { name: "All Appointments" })).toBeVisible();
}); });
test("can access audit page", async ({ page }) => { test("can access admin availability page", async ({ page }) => {
await page.goto("/audit"); await page.goto("/admin/availability");
// Should stay on audit page // Should stay on availability page
await expect(page).toHaveURL("/audit"); await expect(page).toHaveURL("/admin/availability");
// Should see audit tables // Should see availability UI (use heading for specificity)
await expect(page.getByText("Counter Activity")).toBeVisible(); await expect(page.getByRole("heading", { name: "Availability" })).toBeVisible();
await expect(page.getByText("Sum Activity")).toBeVisible();
}); });
test("navigation only shows Audit", async ({ page }) => { test("navigation shows admin links", async ({ page }) => {
await page.goto("/audit"); await page.goto("/admin/appointments");
// Should see Audit as current // Should see admin nav items (use locator for nav links)
await expect(page.getByText("Audit")).toBeVisible(); await expect(page.locator('a[href="/admin/invites"]')).toBeVisible();
await expect(page.locator('a[href="/admin/availability"]')).toBeVisible();
await expect(page.locator('a[href="/admin/appointments"]')).toHaveCount(0); // Current page, shown as text not link
// Should NOT see Counter or Sum links (for admin users) // Should NOT see regular user links
const counterLinks = page.locator('a[href="/"]'); const bookLinks = page.locator('a[href="/booking"]');
const sumLinks = page.locator('a[href="/sum"]'); await expect(bookLinks).toHaveCount(0);
await expect(counterLinks).toHaveCount(0);
await expect(sumLinks).toHaveCount(0);
});
test("audit page shows records", async ({ page }) => {
await page.goto("/audit");
// Should see the tables
await expect(page.getByRole("table")).toHaveCount(2);
// Should see column headers (use first() since there are two tables with same headers)
await expect(page.getByRole("columnheader", { name: "User" }).first()).toBeVisible();
await expect(page.getByRole("columnheader", { name: "Date" }).first()).toBeVisible();
}); });
}); });
@ -209,24 +157,24 @@ test.describe("Unauthenticated Access", () => {
await clearAuth(page); await clearAuth(page);
}); });
test("counter page redirects to login", async ({ page }) => { test("home page redirects to login", async ({ page }) => {
await page.goto("/"); await page.goto("/");
await expect(page).toHaveURL("/login"); await expect(page).toHaveURL("/login");
}); });
test("sum page redirects to login", async ({ page }) => { test("booking page redirects to login", async ({ page }) => {
await page.goto("/sum"); await page.goto("/booking");
await expect(page).toHaveURL("/login"); await expect(page).toHaveURL("/login");
}); });
test("audit page redirects to login", async ({ page }) => { test("admin page redirects to login", async ({ page }) => {
await page.goto("/audit"); await page.goto("/admin/appointments");
await expect(page).toHaveURL("/login"); await expect(page).toHaveURL("/login");
}); });
}); });
test.describe("Permission Boundary via API", () => { test.describe("Permission Boundary via API", () => {
test("regular user API call to audit returns 403", async ({ page, request }) => { test("regular user API call to admin appointments returns 403", async ({ page, request }) => {
// Login as regular user // Login as regular user
await clearAuth(page); await clearAuth(page);
await loginUser(page, REGULAR_USER.email, REGULAR_USER.password); await loginUser(page, REGULAR_USER.email, REGULAR_USER.password);
@ -236,8 +184,8 @@ test.describe("Permission Boundary via API", () => {
const authCookie = cookies.find((c) => c.name === "auth_token"); const authCookie = cookies.find((c) => c.name === "auth_token");
if (authCookie) { if (authCookie) {
// Try to call audit API directly // Try to call admin appointments API directly
const response = await request.get(`${API_URL}/api/audit/counter`, { const response = await request.get(`${API_URL}/api/admin/appointments`, {
headers: { headers: {
Cookie: `auth_token=${authCookie.value}`, Cookie: `auth_token=${authCookie.value}`,
}, },
@ -247,7 +195,7 @@ test.describe("Permission Boundary via API", () => {
} }
}); });
test("admin user API call to counter returns 403", async ({ page, request }) => { test("admin user API call to booking slots returns 403", async ({ page, request }) => {
// Login as admin // Login as admin
await clearAuth(page); await clearAuth(page);
await loginUser(page, ADMIN_USER.email, ADMIN_USER.password); await loginUser(page, ADMIN_USER.email, ADMIN_USER.password);
@ -257,8 +205,12 @@ test.describe("Permission Boundary via API", () => {
const authCookie = cookies.find((c) => c.name === "auth_token"); const authCookie = cookies.find((c) => c.name === "auth_token");
if (authCookie) { if (authCookie) {
// Try to call counter API directly // Try to call booking slots API directly (requires regular user permission)
const response = await request.get(`${API_URL}/api/counter`, { const tomorrow = new Date();
tomorrow.setDate(tomorrow.getDate() + 1);
const dateStr = tomorrow.toISOString().split("T")[0];
const response = await request.get(`${API_URL}/api/booking/slots?date=${dateStr}`, {
headers: { headers: {
Cookie: `auth_token=${authCookie.value}`, Cookie: `auth_token=${authCookie.value}`,
}, },
@ -274,14 +226,14 @@ test.describe("Session and Logout", () => {
// Login // Login
await clearAuth(page); await clearAuth(page);
await loginUser(page, REGULAR_USER.email, REGULAR_USER.password); await loginUser(page, REGULAR_USER.email, REGULAR_USER.password);
await expect(page).toHaveURL("/"); await expect(page).toHaveURL("/booking");
// Logout // Logout
await page.click("text=Sign out"); await page.click("text=Sign out");
await expect(page).toHaveURL("/login"); await expect(page).toHaveURL("/login");
// Try to access counter // Try to access booking
await page.goto("/"); await page.goto("/booking");
await expect(page).toHaveURL("/login"); await expect(page).toHaveURL("/login");
}); });
@ -297,7 +249,7 @@ test.describe("Session and Logout", () => {
]); ]);
// Try to access protected page // Try to access protected page
await page.goto("/"); await page.goto("/booking");
// Should be redirected to login // Should be redirected to login
await expect(page).toHaveURL("/login"); await expect(page).toHaveURL("/login");

View file

@ -1,80 +0,0 @@
import { test, expect } from "@playwright/test";
import { clearAuth, loginUser, REGULAR_USER, ADMIN_USER } from "./helpers/auth";
test.describe("Random Jobs - E2E", () => {
test("counter increment creates random job outcome visible to admin", async ({ page }) => {
// Step 1: Login as regular user
await clearAuth(page);
await loginUser(page, REGULAR_USER.email, REGULAR_USER.password);
// Wait for counter page to load
await expect(page.locator("h1")).toBeVisible();
await expect(page.locator("h1")).not.toHaveText("...");
// Step 2: Click increment button
await page.click("text=Increment");
// Wait for the counter to update (value changes)
const counterValue = await page.locator("h1").textContent();
expect(counterValue).toMatch(/^\d+$/);
// Step 3: Logout
await page.click("text=Sign out");
await expect(page).toHaveURL("/login");
// Step 4: Login as admin
await loginUser(page, ADMIN_USER.email, ADMIN_USER.password);
// Admin should be on audit page by default
await expect(page).toHaveURL("/audit");
// Step 5: Navigate to Random Jobs page
await page.click('a[href="/admin/random-jobs"]');
await expect(page).toHaveURL("/admin/random-jobs");
// Step 6: Poll for job outcome to appear (worker may take time to process)
// Keep refreshing until we see the regular user's email in the table
await expect(async () => {
await page.reload();
await expect(page.locator("table tbody")).toContainText(REGULAR_USER.email);
}).toPass({ timeout: 15000, intervals: [500, 1000, 2000] });
// Verify the outcome has expected fields
// Value should be a number 0-100
const valueCell = page.locator("table tbody tr td").nth(3);
const valueText = await valueCell.textContent();
const value = Number(valueText);
expect(value).toBeGreaterThanOrEqual(0);
expect(value).toBeLessThanOrEqual(100);
// Status should be "completed"
await expect(page.locator("table tbody")).toContainText("completed");
});
test("admin can view empty random jobs list", async ({ page }) => {
// This test just verifies the page loads correctly
// In a fresh DB there might be no outcomes yet
await clearAuth(page);
await loginUser(page, ADMIN_USER.email, ADMIN_USER.password);
await page.goto("/admin/random-jobs");
await expect(page).toHaveURL("/admin/random-jobs");
// Page title should be visible
await expect(page.locator("h2")).toContainText("Random Number Job Outcomes");
// Table should exist
await expect(page.locator("table")).toBeVisible();
});
test("regular user cannot access random jobs page", async ({ page }) => {
await clearAuth(page);
await loginUser(page, REGULAR_USER.email, REGULAR_USER.password);
// Try to navigate directly to the admin page
await page.goto("/admin/random-jobs");
// Should be redirected away (to "/" since fallbackRedirect is "/")
await expect(page).toHaveURL("/");
});
});

View file

@ -6,7 +6,6 @@ cd "$(dirname "$0")/.."
# Cleanup function to kill background processes # Cleanup function to kill background processes
cleanup() { cleanup() {
kill $BACKEND_PID 2>/dev/null || true kill $BACKEND_PID 2>/dev/null || true
kill $WORKER_PID 2>/dev/null || true
} }
# Ensure cleanup runs on exit (normal, error, or interrupt) # Ensure cleanup runs on exit (normal, error, or interrupt)
@ -33,10 +32,6 @@ cd ..
cd backend cd backend
uv run uvicorn main:app --port 8000 --log-level warning & uv run uvicorn main:app --port 8000 --log-level warning &
BACKEND_PID=$! BACKEND_PID=$!
# Start worker for job processing
uv run python worker.py &
WORKER_PID=$!
cd .. cd ..
# Wait for backend # Wait for backend