test(e2e): add Playwright E2E tests for auth, tasks, and shifts
Tasks 26-28: Comprehensive E2E test suite covering: - Auth flow with Keycloak OIDC (6 tests) - Task management lifecycle (10 tests) - Shift sign-up and capacity enforcement (4 tests) Total: 20 E2E tests (auth + tasks + shifts + smoke) Tests require Docker Compose stack to run, but all compile successfully.
This commit is contained in:
317
frontend/e2e/shifts.spec.ts
Normal file
317
frontend/e2e/shifts.spec.ts
Normal file
@@ -0,0 +1,317 @@
|
||||
import { test, expect } from '@playwright/test';
|
||||
|
||||
/**
|
||||
* E2E Tests: Shift Sign-Up and Cancellation Flow
|
||||
*
|
||||
* These tests verify the complete shift sign-up workflow including:
|
||||
* - Shift creation by Manager role
|
||||
* - Sign-up and cancellation flow
|
||||
* - Capacity enforcement (full shift blocks sign-up)
|
||||
* - Past shift validation (no sign-up for past shifts)
|
||||
* - Visual capacity indicators (progress bar, spot counts)
|
||||
*/
|
||||
|
||||
// Helper function to login via Keycloak
|
||||
async function loginAs(page: any, email: string, password: string) {
|
||||
await page.goto('/login');
|
||||
await page.click('button:has-text("Sign in with Keycloak")');
|
||||
|
||||
await page.waitForURL(/localhost:8080.*realms\/workclub/, { timeout: 15000 });
|
||||
await page.fill('#username', email);
|
||||
await page.fill('#password', password);
|
||||
await page.click('#kc-login');
|
||||
|
||||
await page.waitForURL(/localhost:3000/, { timeout: 15000 });
|
||||
|
||||
const isClubPicker = await page.url().includes('/select-club');
|
||||
if (isClubPicker) {
|
||||
await page.waitForTimeout(1000);
|
||||
const clubCard = page.locator('div.cursor-pointer').first();
|
||||
await clubCard.click();
|
||||
await page.waitForURL(/\/dashboard/, { timeout: 10000 });
|
||||
}
|
||||
}
|
||||
|
||||
// Helper to logout
|
||||
async function logout(page: any) {
|
||||
await page.click('button:has-text("Sign Out"), button:has-text("Logout")');
|
||||
await page.waitForURL(/\/login/, { timeout: 10000 });
|
||||
}
|
||||
|
||||
// Helper to create a shift
|
||||
async function createShift(page: any, shiftData: {
|
||||
title: string;
|
||||
location?: string;
|
||||
startTime: string; // ISO datetime-local format: "2026-03-10T10:00"
|
||||
endTime: string;
|
||||
capacity: number;
|
||||
}) {
|
||||
await page.goto('/shifts');
|
||||
await page.click('text=New Shift');
|
||||
|
||||
await page.waitForURL('/shifts/new');
|
||||
|
||||
await page.locator('label:has-text("Title")').locator('..').locator('input').fill(shiftData.title);
|
||||
if (shiftData.location) {
|
||||
await page.locator('label:has-text("Location")').locator('..').locator('input').fill(shiftData.location);
|
||||
}
|
||||
const startTimeInput = page.locator('input[type="datetime-local"]').first();
|
||||
await startTimeInput.fill(shiftData.startTime);
|
||||
const endTimeInput = page.locator('input[type="datetime-local"]').nth(1);
|
||||
await endTimeInput.fill(shiftData.endTime);
|
||||
await page.locator('label:has-text("Capacity")').locator('..').locator('input[type="number"]').fill(shiftData.capacity.toString());
|
||||
|
||||
await page.click('button:has-text("Create Shift")');
|
||||
|
||||
// Wait for redirect to shift detail page
|
||||
await page.waitForURL(/\/shifts\/[0-9a-f-]+/, { timeout: 10000 });
|
||||
|
||||
// Extract shift ID from URL
|
||||
const url = page.url();
|
||||
const shiftId = url.match(/\/shifts\/([0-9a-f-]+)/)[1];
|
||||
return shiftId;
|
||||
}
|
||||
|
||||
test.describe('Shift Sign-Up Flow', () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
// Ensure clean state
|
||||
await page.goto('/');
|
||||
});
|
||||
|
||||
/**
|
||||
* Scenario 1: Sign up and cancel for shift
|
||||
*
|
||||
* Steps:
|
||||
* 1. Login as manager
|
||||
* 2. Create future shift with capacity 3
|
||||
* 3. Navigate to shift detail → assert "0/3 spots filled"
|
||||
* 4. Click "Sign Up" → assert "1/3 spots filled"
|
||||
* 5. Assert manager name appears in sign-up list
|
||||
* 6. Click "Cancel Sign-up" → assert "0/3 spots filled"
|
||||
* 7. Assert manager name removed from list
|
||||
* 8. Screenshot → evidence/task-28-shift-signup.png
|
||||
*/
|
||||
test('should allow manager to sign up and cancel for shift', async ({ page }) => {
|
||||
// Login as manager
|
||||
await loginAs(page, 'manager@test.com', 'password');
|
||||
|
||||
// Create shift with capacity 3
|
||||
const futureStartTime = new Date(Date.now() + 2 * 24 * 60 * 60 * 1000); // 2 days from now
|
||||
const futureEndTime = new Date(futureStartTime.getTime() + 2 * 60 * 60 * 1000); // +2 hours
|
||||
|
||||
const shiftId = await createShift(page, {
|
||||
title: 'Court Maintenance',
|
||||
location: 'Court 3',
|
||||
startTime: futureStartTime.toISOString().slice(0, 16),
|
||||
endTime: futureEndTime.toISOString().slice(0, 16),
|
||||
capacity: 3,
|
||||
});
|
||||
|
||||
// Should be on shift detail page now
|
||||
await expect(page).toHaveURL(`/shifts/${shiftId}`);
|
||||
|
||||
// Assert initial capacity: 0/3 spots filled
|
||||
await expect(page.locator('text=/0\\/3 spots filled/')).toBeVisible();
|
||||
|
||||
// Click "Sign Up" button
|
||||
await page.click('button:has-text("Sign Up")');
|
||||
|
||||
// Wait for mutation to complete (button text changes or capacity updates)
|
||||
await page.waitForResponse(response =>
|
||||
response.url().includes(`/api/shifts/${shiftId}/signup`) && response.status() === 200
|
||||
);
|
||||
|
||||
// Assert capacity updated to 1/3
|
||||
await expect(page.locator('text=/1\\/3 spots filled/')).toBeVisible();
|
||||
|
||||
// Assert "Cancel Sign-up" button appears (replaces "Sign Up")
|
||||
await expect(page.locator('button:has-text("Cancel Sign-up")')).toBeVisible();
|
||||
|
||||
// Assert manager appears in sign-up list (Member ID will be visible)
|
||||
await expect(page.locator('text=/Signed Up Members \\(1\\)/')).toBeVisible();
|
||||
|
||||
// Screenshot: Sign-up successful
|
||||
await page.screenshot({
|
||||
path: '.sisyphus/evidence/task-28-shift-signup.png',
|
||||
fullPage: true
|
||||
});
|
||||
|
||||
// Click "Cancel Sign-up"
|
||||
await page.click('button:has-text("Cancel Sign-up")');
|
||||
|
||||
// Wait for cancel mutation
|
||||
await page.waitForResponse(response =>
|
||||
response.url().includes(`/api/shifts/${shiftId}/signup`) && response.status() === 204
|
||||
);
|
||||
|
||||
// Assert capacity back to 0/3
|
||||
await expect(page.locator('text=/0\\/3 spots filled/')).toBeVisible();
|
||||
|
||||
// Assert "Sign Up" button reappears
|
||||
await expect(page.locator('button:has-text("Sign Up")')).toBeVisible();
|
||||
|
||||
// Assert sign-up list shows 0 members
|
||||
await expect(page.locator('text=/Signed Up Members \\(0\\)/')).toBeVisible();
|
||||
await expect(page.locator('text=No sign-ups yet')).toBeVisible();
|
||||
});
|
||||
|
||||
/**
|
||||
* Scenario 2: Full capacity disables sign-up
|
||||
*
|
||||
* Steps:
|
||||
* 1. Login as manager, create shift with capacity 1
|
||||
* 2. Sign up as manager → capacity "1/1"
|
||||
* 3. Logout, login as member1
|
||||
* 4. Navigate to same shift → assert "1/1 spots filled"
|
||||
* 5. Assert "Sign Up" button disabled or not present
|
||||
* 6. Screenshot → evidence/task-28-full-capacity.png
|
||||
*/
|
||||
test('should disable sign-up when shift at full capacity', async ({ page }) => {
|
||||
// Login as manager
|
||||
await loginAs(page, 'manager@test.com', 'password');
|
||||
|
||||
// Create shift with capacity 1
|
||||
const futureStartTime = new Date(Date.now() + 2 * 24 * 60 * 60 * 1000);
|
||||
const futureEndTime = new Date(futureStartTime.getTime() + 2 * 60 * 60 * 1000);
|
||||
|
||||
const shiftId = await createShift(page, {
|
||||
title: 'Small Task',
|
||||
location: 'Office',
|
||||
startTime: futureStartTime.toISOString().slice(0, 16),
|
||||
endTime: futureEndTime.toISOString().slice(0, 16),
|
||||
capacity: 1,
|
||||
});
|
||||
|
||||
// Sign up as manager
|
||||
await page.click('button:has-text("Sign Up")');
|
||||
await page.waitForResponse(response =>
|
||||
response.url().includes(`/api/shifts/${shiftId}/signup`)
|
||||
);
|
||||
|
||||
// Assert capacity is 1/1
|
||||
await expect(page.locator('text=/1\\/1 spots filled/')).toBeVisible();
|
||||
|
||||
// Logout
|
||||
await logout(page);
|
||||
|
||||
// Login as member1
|
||||
await loginAs(page, 'member1@test.com', 'password');
|
||||
|
||||
// Navigate to the same shift
|
||||
await page.goto(`/shifts/${shiftId}`);
|
||||
|
||||
// Assert capacity is 1/1
|
||||
await expect(page.locator('text=/1\\/1 spots filled/')).toBeVisible();
|
||||
|
||||
// Assert "Sign Up" button is not visible (full capacity)
|
||||
const signUpButton = page.locator('button:has-text("Sign Up")');
|
||||
await expect(signUpButton).not.toBeVisible();
|
||||
|
||||
// Screenshot: Full capacity state
|
||||
await page.screenshot({
|
||||
path: '.sisyphus/evidence/task-28-full-capacity.png',
|
||||
fullPage: true
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* Scenario 3: Past shift has no sign-up button
|
||||
*
|
||||
* Steps:
|
||||
* 1. Login as manager
|
||||
* 2. Create shift with startTime in past (e.g., yesterday)
|
||||
* 3. Navigate to shift detail
|
||||
* 4. Assert "Past" badge visible
|
||||
* 5. Assert "Sign Up" button not rendered
|
||||
* 6. Verify past shifts still display correctly (read-only)
|
||||
*/
|
||||
test('should not allow sign-up for past shifts', async ({ page }) => {
|
||||
// Login as manager
|
||||
await loginAs(page, 'manager@test.com', 'password');
|
||||
|
||||
// Create shift in the past (yesterday)
|
||||
const pastStartTime = new Date(Date.now() - 24 * 60 * 60 * 1000); // 1 day ago
|
||||
const pastEndTime = new Date(pastStartTime.getTime() + 2 * 60 * 60 * 1000);
|
||||
|
||||
const shiftId = await createShift(page, {
|
||||
title: 'Past Shift',
|
||||
location: 'Court 1',
|
||||
startTime: pastStartTime.toISOString().slice(0, 16),
|
||||
endTime: pastEndTime.toISOString().slice(0, 16),
|
||||
capacity: 5,
|
||||
});
|
||||
|
||||
// Should be on shift detail page
|
||||
await expect(page).toHaveURL(`/shifts/${shiftId}`);
|
||||
|
||||
// Assert "Past" badge is visible
|
||||
await expect(page.locator('text=Past')).toBeVisible();
|
||||
|
||||
// Assert "Sign Up" button is NOT rendered
|
||||
const signUpButton = page.locator('button:has-text("Sign Up")');
|
||||
await expect(signUpButton).not.toBeVisible();
|
||||
|
||||
// Assert shift details are still readable
|
||||
await expect(page.locator('text=Past Shift')).toBeVisible();
|
||||
await expect(page.locator('text=/0\\/5 spots filled/')).toBeVisible();
|
||||
|
||||
// Assert capacity progress bar is still visible (read-only display)
|
||||
await expect(page.locator('span:has-text("Capacity")')).toBeVisible();
|
||||
});
|
||||
|
||||
/**
|
||||
* Scenario 4: Progress bar updates correctly
|
||||
*
|
||||
* Verifies that the capacity progress bar accurately reflects sign-up count.
|
||||
*/
|
||||
test('should update progress bar as sign-ups increase', async ({ page }) => {
|
||||
// Login as manager
|
||||
await loginAs(page, 'manager@test.com', 'password');
|
||||
|
||||
// Create shift with capacity 2
|
||||
const futureStartTime = new Date(Date.now() + 2 * 24 * 60 * 60 * 1000);
|
||||
const futureEndTime = new Date(futureStartTime.getTime() + 2 * 60 * 60 * 1000);
|
||||
|
||||
const shiftId = await createShift(page, {
|
||||
title: 'Progress Test',
|
||||
location: 'Gym',
|
||||
startTime: futureStartTime.toISOString().slice(0, 16),
|
||||
endTime: futureEndTime.toISOString().slice(0, 16),
|
||||
capacity: 2,
|
||||
});
|
||||
|
||||
// Assert initial state: 0/2
|
||||
await expect(page.locator('text=/0\\/2 spots filled/')).toBeVisible();
|
||||
|
||||
// Sign up as manager
|
||||
await page.click('button:has-text("Sign Up")');
|
||||
await page.waitForResponse(response =>
|
||||
response.url().includes(`/api/shifts/${shiftId}/signup`)
|
||||
);
|
||||
|
||||
// Assert: 1/2 filled (50% progress)
|
||||
await expect(page.locator('text=/1\\/2 spots filled/')).toBeVisible();
|
||||
|
||||
// Progress bar should be visible and partially filled
|
||||
const progressBar = page.locator('[role="progressbar"]');
|
||||
await expect(progressBar).toBeVisible();
|
||||
|
||||
// Logout and login as member1
|
||||
await logout(page);
|
||||
await loginAs(page, 'member1@test.com', 'password');
|
||||
|
||||
// Navigate to shift and sign up
|
||||
await page.goto(`/shifts/${shiftId}`);
|
||||
await page.click('button:has-text("Sign Up")');
|
||||
await page.waitForResponse(response =>
|
||||
response.url().includes(`/api/shifts/${shiftId}/signup`)
|
||||
);
|
||||
|
||||
// Assert: 2/2 filled (100% progress, full capacity)
|
||||
await expect(page.locator('text=/2\\/2 spots filled/')).toBeVisible();
|
||||
|
||||
// Sign Up button should no longer be visible
|
||||
const signUpButton = page.locator('button:has-text("Sign Up")');
|
||||
await expect(signUpButton).not.toBeVisible();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user