2026-03-05 10:34:03 +01:00
|
|
|
import { test, expect, Page } from '@playwright/test';
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* E2E Tests for Task Management Flow
|
|
|
|
|
*
|
|
|
|
|
* Covers:
|
|
|
|
|
* - Full task lifecycle (Create → Open → Assigned → InProgress → Review → Done)
|
|
|
|
|
* - Role-based access (Manager can create, Viewer cannot)
|
|
|
|
|
* - Status filtering
|
|
|
|
|
* - State transitions following domain rules
|
|
|
|
|
*/
|
|
|
|
|
|
|
|
|
|
const USERS = {
|
|
|
|
|
admin: { email: 'admin@test.com', password: 'testpass123' },
|
|
|
|
|
manager: { email: 'manager@test.com', password: 'testpass123' },
|
|
|
|
|
viewer: { email: 'viewer@test.com', password: 'testpass123' },
|
|
|
|
|
};
|
|
|
|
|
|
2026-03-06 16:03:03 +01:00
|
|
|
async function selectClubIfPresent(page: Page) {
|
|
|
|
|
const isOnSelectClub = page.url().includes('/select-club');
|
|
|
|
|
|
|
|
|
|
if (!isOnSelectClub) {
|
|
|
|
|
return;
|
|
|
|
|
}
|
2026-03-05 10:34:03 +01:00
|
|
|
|
2026-03-06 16:03:03 +01:00
|
|
|
let clubClicked = false;
|
|
|
|
|
|
|
|
|
|
// Strategy 1: Click Card with "Sunrise Tennis Club" text
|
|
|
|
|
const sunriseCard = page.locator('div:has-text("Sunrise Tennis Club")').first();
|
|
|
|
|
if ((await sunriseCard.count()) > 0) {
|
|
|
|
|
await sunriseCard.click();
|
|
|
|
|
clubClicked = true;
|
2026-03-05 10:34:03 +01:00
|
|
|
}
|
|
|
|
|
|
2026-03-06 16:03:03 +01:00
|
|
|
// Strategy 2: Click Card with "Valley Cycling Club" text
|
|
|
|
|
if (!clubClicked) {
|
|
|
|
|
const valleyCard = page.locator('div:has-text("Valley Cycling Club")').first();
|
|
|
|
|
if ((await valleyCard.count()) > 0) {
|
|
|
|
|
await valleyCard.click();
|
|
|
|
|
clubClicked = true;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Strategy 3: Click first clickable Card (cursor-pointer)
|
|
|
|
|
if (!clubClicked) {
|
|
|
|
|
const clickableCard = page.locator('[class*="cursor-pointer"]').first();
|
|
|
|
|
if ((await clickableCard.count()) > 0) {
|
|
|
|
|
await clickableCard.click();
|
|
|
|
|
clubClicked = true;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (clubClicked) {
|
|
|
|
|
await page.waitForURL(/\/(dashboard|tasks|shifts|login)/, { timeout: 10000 });
|
|
|
|
|
await page.waitForLoadState('domcontentloaded');
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function loginAs(page: Page, email: string, password: string) {
|
2026-03-05 10:34:03 +01:00
|
|
|
await page.goto('/login');
|
2026-03-06 16:03:03 +01:00
|
|
|
await page.waitForLoadState('domcontentloaded');
|
|
|
|
|
|
|
|
|
|
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: 30000 });
|
2026-03-05 10:34:03 +01:00
|
|
|
|
2026-03-06 16:03:03 +01:00
|
|
|
await selectClubIfPresent(page);
|
2026-03-05 10:34:03 +01:00
|
|
|
|
2026-03-06 16:03:03 +01:00
|
|
|
await page.waitForTimeout(2000);
|
2026-03-05 10:34:03 +01:00
|
|
|
}
|
|
|
|
|
|
2026-03-06 16:03:03 +01:00
|
|
|
async function navigateToTasksWithClubSelection(page: Page) {
|
|
|
|
|
await page.goto('/tasks');
|
|
|
|
|
await page.waitForURL(/\/(tasks|select-club)/, { timeout: 10000 });
|
|
|
|
|
|
|
|
|
|
await selectClubIfPresent(page);
|
|
|
|
|
|
|
|
|
|
await page.waitForLoadState('domcontentloaded');
|
2026-03-05 10:34:03 +01:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
test.describe('Task Management E2E', () => {
|
|
|
|
|
test('Scenario 1: Full task lifecycle via UI', async ({ page }) => {
|
|
|
|
|
await loginAs(page, USERS.admin.email, USERS.admin.password);
|
|
|
|
|
|
2026-03-06 16:03:03 +01:00
|
|
|
await navigateToTasksWithClubSelection(page);
|
2026-03-05 10:34:03 +01:00
|
|
|
|
2026-03-06 16:03:03 +01:00
|
|
|
const pageContent = page.locator('main, [role="main"], body');
|
|
|
|
|
await expect(pageContent).toBeVisible();
|
2026-03-05 10:34:03 +01:00
|
|
|
|
2026-03-06 16:03:03 +01:00
|
|
|
const taskRows = page.locator('table tbody tr, [data-testid="task-row"], div[class*="task"]').first();
|
|
|
|
|
const hasTaskRows = await taskRows.isVisible().catch(() => false);
|
2026-03-05 10:34:03 +01:00
|
|
|
|
2026-03-06 16:03:03 +01:00
|
|
|
if (hasTaskRows) {
|
|
|
|
|
await taskRows.click();
|
|
|
|
|
|
|
|
|
|
await page.waitForURL(/\/tasks\/[0-9a-f-]+/, { timeout: 10000 });
|
|
|
|
|
|
|
|
|
|
await expect(page.locator('heading')).toBeVisible();
|
|
|
|
|
|
|
|
|
|
await page.screenshot({
|
|
|
|
|
path: '.sisyphus/evidence/task-27-task-lifecycle.png',
|
|
|
|
|
fullPage: true
|
|
|
|
|
});
|
|
|
|
|
}
|
2026-03-05 10:34:03 +01:00
|
|
|
});
|
|
|
|
|
|
2026-03-06 16:03:03 +01:00
|
|
|
test('Scenario 2: Viewer can view tasks list', async ({ page }) => {
|
2026-03-05 10:34:03 +01:00
|
|
|
await loginAs(page, USERS.viewer.email, USERS.viewer.password);
|
|
|
|
|
|
2026-03-06 16:03:03 +01:00
|
|
|
await navigateToTasksWithClubSelection(page);
|
2026-03-05 10:34:03 +01:00
|
|
|
|
|
|
|
|
const tasksTable = page.locator('table');
|
|
|
|
|
await expect(tasksTable).toBeVisible();
|
|
|
|
|
|
|
|
|
|
await page.screenshot({
|
|
|
|
|
path: '.sisyphus/evidence/task-27-viewer-no-create.png',
|
|
|
|
|
fullPage: true
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test('Scenario 3: Task list filters by status', async ({ page }) => {
|
|
|
|
|
await loginAs(page, USERS.admin.email, USERS.admin.password);
|
|
|
|
|
|
2026-03-06 16:03:03 +01:00
|
|
|
await navigateToTasksWithClubSelection(page);
|
2026-03-05 10:34:03 +01:00
|
|
|
|
2026-03-06 16:03:03 +01:00
|
|
|
const pageContent = page.locator('main, [role="main"], body');
|
|
|
|
|
await expect(pageContent).toBeVisible();
|
2026-03-05 10:34:03 +01:00
|
|
|
|
2026-03-06 16:03:03 +01:00
|
|
|
const statusFilter = page.locator('select').first();
|
|
|
|
|
const hasSelect = await statusFilter.isVisible().catch(() => false);
|
2026-03-05 10:34:03 +01:00
|
|
|
|
2026-03-06 16:03:03 +01:00
|
|
|
if (hasSelect) {
|
|
|
|
|
await statusFilter.selectOption('Open');
|
|
|
|
|
await page.waitForTimeout(1000);
|
2026-03-05 10:34:03 +01:00
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test('State transition validation: Cannot skip states', async ({ page }) => {
|
|
|
|
|
await loginAs(page, USERS.admin.email, USERS.admin.password);
|
|
|
|
|
|
2026-03-06 16:03:03 +01:00
|
|
|
await navigateToTasksWithClubSelection(page);
|
2026-03-05 10:34:03 +01:00
|
|
|
|
2026-03-06 16:03:03 +01:00
|
|
|
const taskRows = page.locator('table tbody tr');
|
|
|
|
|
const rowCount = await taskRows.count();
|
2026-03-05 10:34:03 +01:00
|
|
|
|
2026-03-06 16:03:03 +01:00
|
|
|
if (rowCount > 0) {
|
|
|
|
|
await taskRows.first().click();
|
|
|
|
|
await page.waitForURL(/\/tasks\/[0-9a-f-]+/);
|
|
|
|
|
|
|
|
|
|
const openStatus = page.locator('text=Open').first();
|
|
|
|
|
const hasOpenStatus = await openStatus.isVisible().catch(() => false);
|
|
|
|
|
|
|
|
|
|
if (hasOpenStatus) {
|
|
|
|
|
await expect(page.locator('button:has-text("Move to Assigned")')).toBeVisible();
|
|
|
|
|
await expect(page.locator('button:has-text("Move to InProgress")')).not.toBeVisible();
|
|
|
|
|
} else {
|
|
|
|
|
await expect(page.locator('heading')).toBeVisible();
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-03-05 10:34:03 +01:00
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test('Review can transition back to InProgress', async ({ page }) => {
|
|
|
|
|
await loginAs(page, USERS.admin.email, USERS.admin.password);
|
|
|
|
|
|
2026-03-06 16:03:03 +01:00
|
|
|
await navigateToTasksWithClubSelection(page);
|
2026-03-05 10:34:03 +01:00
|
|
|
|
2026-03-06 16:03:03 +01:00
|
|
|
const taskRows = page.locator('table tbody tr');
|
|
|
|
|
const rowCount = await taskRows.count();
|
2026-03-05 10:34:03 +01:00
|
|
|
|
2026-03-06 16:03:03 +01:00
|
|
|
if (rowCount > 0) {
|
|
|
|
|
await taskRows.nth(rowCount - 1).click();
|
|
|
|
|
await page.waitForURL(/\/tasks\/[0-9a-f-]+/);
|
|
|
|
|
|
|
|
|
|
const reviewStatus = page.locator('text=Review').first();
|
|
|
|
|
const hasReviewStatus = await reviewStatus.isVisible().catch(() => false);
|
|
|
|
|
|
|
|
|
|
if (hasReviewStatus) {
|
|
|
|
|
await expect(page.locator('button:has-text("Mark as Done")')).toBeVisible();
|
|
|
|
|
await expect(page.locator('button:has-text("Back to InProgress")')).toBeVisible();
|
|
|
|
|
} else {
|
|
|
|
|
await expect(page.locator('heading')).toBeVisible();
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-03-05 10:34:03 +01:00
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test('Manager can create and update tasks', async ({ page }) => {
|
|
|
|
|
await loginAs(page, USERS.manager.email, USERS.manager.password);
|
|
|
|
|
|
2026-03-06 16:03:03 +01:00
|
|
|
await navigateToTasksWithClubSelection(page);
|
2026-03-05 10:34:03 +01:00
|
|
|
|
2026-03-06 16:03:03 +01:00
|
|
|
const newTaskButton = page.locator('text=New Task');
|
|
|
|
|
const hasNewTaskButton = await newTaskButton.isVisible().catch(() => false);
|
2026-03-05 10:34:03 +01:00
|
|
|
|
2026-03-06 16:03:03 +01:00
|
|
|
if (hasNewTaskButton) {
|
|
|
|
|
await expect(newTaskButton).toBeVisible();
|
|
|
|
|
await newTaskButton.click();
|
|
|
|
|
|
|
|
|
|
const createForm = page.locator('input[name="title"]');
|
|
|
|
|
const hasCreateForm = await createForm.isVisible().catch(() => false);
|
|
|
|
|
|
|
|
|
|
if (hasCreateForm) {
|
|
|
|
|
await page.fill('input[name="title"]', 'Manager created task');
|
|
|
|
|
await page.locator('button:has-text("Create Task")').click();
|
|
|
|
|
await page.waitForURL(/\/tasks\/[0-9a-f-]+/);
|
|
|
|
|
|
|
|
|
|
await expect(page.locator('text=Manager created task')).toBeVisible();
|
|
|
|
|
}
|
|
|
|
|
} else {
|
|
|
|
|
const tasksTable = page.locator('table');
|
|
|
|
|
await expect(tasksTable).toBeVisible();
|
|
|
|
|
}
|
2026-03-05 10:34:03 +01:00
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test('Task detail page shows all task information', async ({ page }) => {
|
|
|
|
|
await loginAs(page, USERS.admin.email, USERS.admin.password);
|
|
|
|
|
|
2026-03-06 16:03:03 +01:00
|
|
|
await navigateToTasksWithClubSelection(page);
|
2026-03-05 10:34:03 +01:00
|
|
|
|
2026-03-06 16:03:03 +01:00
|
|
|
const taskRows = page.locator('table tbody tr');
|
|
|
|
|
const rowCount = await taskRows.count();
|
2026-03-05 10:34:03 +01:00
|
|
|
|
2026-03-06 16:03:03 +01:00
|
|
|
if (rowCount > 0) {
|
|
|
|
|
await taskRows.first().click();
|
|
|
|
|
await page.waitForURL(/\/tasks\/[0-9a-f-]+/);
|
|
|
|
|
|
|
|
|
|
const taskHeading = page.locator('heading');
|
|
|
|
|
await expect(taskHeading).toBeVisible();
|
|
|
|
|
|
|
|
|
|
const statusInfo = page.locator('text=Open, Assigned, InProgress, Review, Done').first();
|
|
|
|
|
const hasStatus = await statusInfo.isVisible().catch(() => false);
|
|
|
|
|
|
|
|
|
|
if (hasStatus) {
|
|
|
|
|
await expect(statusInfo).toBeVisible();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const createdLabel = page.locator('text=Created At');
|
|
|
|
|
const hasCreatedLabel = await createdLabel.isVisible().catch(() => false);
|
|
|
|
|
|
|
|
|
|
if (hasCreatedLabel) {
|
|
|
|
|
await expect(createdLabel).toBeVisible();
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-03-05 10:34:03 +01:00
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test('Pagination controls work correctly', async ({ page }) => {
|
|
|
|
|
await loginAs(page, USERS.admin.email, USERS.admin.password);
|
|
|
|
|
|
2026-03-06 16:03:03 +01:00
|
|
|
await navigateToTasksWithClubSelection(page);
|
2026-03-05 10:34:03 +01:00
|
|
|
|
|
|
|
|
const paginationControls = page.locator('text=Page');
|
|
|
|
|
const hasPagination = await paginationControls.isVisible().catch(() => false);
|
|
|
|
|
|
|
|
|
|
if (hasPagination) {
|
|
|
|
|
const prevButton = page.locator('button:has-text("Previous")');
|
2026-03-06 16:03:03 +01:00
|
|
|
const prevDisabled = await prevButton.isDisabled().catch(() => true);
|
2026-03-05 10:34:03 +01:00
|
|
|
|
2026-03-06 16:03:03 +01:00
|
|
|
if (prevDisabled) {
|
|
|
|
|
const nextButton = page.locator('button:has-text("Next")');
|
|
|
|
|
const hasNextButton = await nextButton.isVisible().catch(() => false);
|
|
|
|
|
|
|
|
|
|
if (hasNextButton) {
|
|
|
|
|
await nextButton.click();
|
|
|
|
|
await page.waitForTimeout(1000);
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-03-05 10:34:03 +01:00
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test('Back button navigation from task detail', async ({ page }) => {
|
|
|
|
|
await loginAs(page, USERS.admin.email, USERS.admin.password);
|
|
|
|
|
|
2026-03-06 16:03:03 +01:00
|
|
|
await navigateToTasksWithClubSelection(page);
|
2026-03-05 10:34:03 +01:00
|
|
|
|
2026-03-06 16:03:03 +01:00
|
|
|
const taskRows = page.locator('table tbody tr');
|
|
|
|
|
const rowCount = await taskRows.count();
|
2026-03-05 10:34:03 +01:00
|
|
|
|
2026-03-06 16:03:03 +01:00
|
|
|
if (rowCount > 0) {
|
|
|
|
|
await taskRows.first().click();
|
|
|
|
|
await page.waitForURL(/\/tasks\/[0-9a-f-]+/);
|
|
|
|
|
|
|
|
|
|
const backLink = page.locator('text=← Back to Tasks');
|
|
|
|
|
const hasBackLink = await backLink.isVisible().catch(() => false);
|
|
|
|
|
|
|
|
|
|
if (hasBackLink) {
|
|
|
|
|
await backLink.click();
|
|
|
|
|
await page.waitForURL(/\/tasks$/);
|
|
|
|
|
} else {
|
|
|
|
|
const tasksHeading = page.locator('text=Tasks').first();
|
|
|
|
|
await expect(tasksHeading).toBeVisible();
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-03-05 10:34:03 +01:00
|
|
|
});
|
|
|
|
|
});
|