362 lines
14 KiB
TypeScript
362 lines
14 KiB
TypeScript
|
|
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
|
||
|
|
*/
|
||
|
|
|
||
|
|
// Test user credentials from Task 3
|
||
|
|
const USERS = {
|
||
|
|
admin: { email: 'admin@test.com', password: 'testpass123' },
|
||
|
|
manager: { email: 'manager@test.com', password: 'testpass123' },
|
||
|
|
viewer: { email: 'viewer@test.com', password: 'testpass123' },
|
||
|
|
};
|
||
|
|
|
||
|
|
// Helper function to login via Keycloak
|
||
|
|
async function loginAs(page: Page, email: string, password: string) {
|
||
|
|
await page.goto('/');
|
||
|
|
|
||
|
|
// Check if already logged in
|
||
|
|
const isLoggedIn = await page.locator('text=Tasks').isVisible().catch(() => false);
|
||
|
|
if (isLoggedIn) {
|
||
|
|
// Logout first
|
||
|
|
await page.goto('/api/auth/signout');
|
||
|
|
await page.locator('button:has-text("Sign out")').click().catch(() => {});
|
||
|
|
await page.waitForTimeout(1000);
|
||
|
|
}
|
||
|
|
|
||
|
|
// Navigate to login
|
||
|
|
await page.goto('/login');
|
||
|
|
await page.waitForLoadState('networkidle');
|
||
|
|
|
||
|
|
// Fill Keycloak login form
|
||
|
|
await page.fill('input[name="username"]', email);
|
||
|
|
await page.fill('input[name="password"]', password);
|
||
|
|
await page.click('input[type="submit"]');
|
||
|
|
|
||
|
|
// Wait for redirect back to app
|
||
|
|
await page.waitForURL(/\/(dashboard|tasks)/, { timeout: 10000 });
|
||
|
|
}
|
||
|
|
|
||
|
|
// Helper to select club (assumes club-1 as default tenant)
|
||
|
|
async function selectClub(page: Page, clubName: string = 'club-1') {
|
||
|
|
// Check if club selector is visible
|
||
|
|
const clubSelector = page.locator('select[name="club"], button:has-text("Club")').first();
|
||
|
|
if (await clubSelector.isVisible().catch(() => false)) {
|
||
|
|
await clubSelector.click();
|
||
|
|
await page.locator(`text=${clubName}`).first().click();
|
||
|
|
await page.waitForTimeout(500); // Wait for localStorage update
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
test.describe('Task Management E2E', () => {
|
||
|
|
test.beforeEach(async ({ page }) => {
|
||
|
|
// Clear localStorage to start fresh
|
||
|
|
await page.goto('/');
|
||
|
|
await page.evaluate(() => localStorage.clear());
|
||
|
|
});
|
||
|
|
|
||
|
|
test('Scenario 1: Full task lifecycle via UI', async ({ page }) => {
|
||
|
|
// Step 1: Login as admin
|
||
|
|
await loginAs(page, USERS.admin.email, USERS.admin.password);
|
||
|
|
await selectClub(page, 'club-1');
|
||
|
|
|
||
|
|
// Step 2: Navigate to /tasks
|
||
|
|
await page.goto('/tasks');
|
||
|
|
await page.waitForLoadState('networkidle');
|
||
|
|
|
||
|
|
// Step 3: Click "New Task" button
|
||
|
|
const newTaskButton = page.locator('text=New Task');
|
||
|
|
await expect(newTaskButton).toBeVisible();
|
||
|
|
await newTaskButton.click();
|
||
|
|
|
||
|
|
// Step 4: Fill form
|
||
|
|
await page.waitForURL(/\/tasks\/new/);
|
||
|
|
await page.fill('input[name="title"]', 'Replace court net');
|
||
|
|
await page.fill('textarea[name="description"]', 'Net on court 3 is torn');
|
||
|
|
|
||
|
|
// Step 5: Submit form
|
||
|
|
await page.locator('button:has-text("Create Task")').click();
|
||
|
|
|
||
|
|
// Step 6: Assert redirected to task detail
|
||
|
|
await page.waitForURL(/\/tasks\/[0-9a-f-]+/, { timeout: 10000 });
|
||
|
|
|
||
|
|
// Step 7: Assert status badge shows "Open"
|
||
|
|
const statusBadge = page.locator('text=Open').first();
|
||
|
|
await expect(statusBadge).toBeVisible();
|
||
|
|
|
||
|
|
// Verify task title
|
||
|
|
await expect(page.locator('text=Replace court net')).toBeVisible();
|
||
|
|
|
||
|
|
// Step 8: Transition Open → Assigned
|
||
|
|
const assignButton = page.locator('button:has-text("Move to Assigned")');
|
||
|
|
await expect(assignButton).toBeVisible();
|
||
|
|
await assignButton.click();
|
||
|
|
await page.waitForTimeout(1000); // Wait for mutation
|
||
|
|
await expect(page.locator('text=Assigned').first()).toBeVisible();
|
||
|
|
|
||
|
|
// Step 9: Transition Assigned → InProgress
|
||
|
|
const startButton = page.locator('button:has-text("Move to InProgress")');
|
||
|
|
await expect(startButton).toBeVisible();
|
||
|
|
await startButton.click();
|
||
|
|
await page.waitForTimeout(1000);
|
||
|
|
await expect(page.locator('text=InProgress').first()).toBeVisible();
|
||
|
|
|
||
|
|
// Step 10: Transition InProgress → Review
|
||
|
|
const reviewButton = page.locator('button:has-text("Move to Review")');
|
||
|
|
await expect(reviewButton).toBeVisible();
|
||
|
|
await reviewButton.click();
|
||
|
|
await page.waitForTimeout(1000);
|
||
|
|
await expect(page.locator('text=Review').first()).toBeVisible();
|
||
|
|
|
||
|
|
// Step 11: Transition Review → Done
|
||
|
|
const doneButton = page.locator('button:has-text("Mark as Done")');
|
||
|
|
await expect(doneButton).toBeVisible();
|
||
|
|
await doneButton.click();
|
||
|
|
await page.waitForTimeout(1000);
|
||
|
|
await expect(page.locator('text=Done').first()).toBeVisible();
|
||
|
|
|
||
|
|
// Step 12: Screenshot final state
|
||
|
|
await page.screenshot({
|
||
|
|
path: '.sisyphus/evidence/task-27-task-lifecycle.png',
|
||
|
|
fullPage: true
|
||
|
|
});
|
||
|
|
});
|
||
|
|
|
||
|
|
test('Scenario 2: Viewer cannot create tasks', async ({ page }) => {
|
||
|
|
// Step 1: Login as viewer
|
||
|
|
await loginAs(page, USERS.viewer.email, USERS.viewer.password);
|
||
|
|
await selectClub(page, 'club-1');
|
||
|
|
|
||
|
|
// Step 2: Navigate to /tasks
|
||
|
|
await page.goto('/tasks');
|
||
|
|
await page.waitForLoadState('networkidle');
|
||
|
|
|
||
|
|
// Step 3: Assert "New Task" button is NOT visible
|
||
|
|
const newTaskButton = page.locator('text=New Task');
|
||
|
|
await expect(newTaskButton).not.toBeVisible();
|
||
|
|
|
||
|
|
// Step 4: Assert can see task list (read permission)
|
||
|
|
const tasksTable = page.locator('table');
|
||
|
|
await expect(tasksTable).toBeVisible();
|
||
|
|
|
||
|
|
// Step 5: Screenshot
|
||
|
|
await page.screenshot({
|
||
|
|
path: '.sisyphus/evidence/task-27-viewer-no-create.png',
|
||
|
|
fullPage: true
|
||
|
|
});
|
||
|
|
});
|
||
|
|
|
||
|
|
test('Scenario 3: Task list filters by status', async ({ page }) => {
|
||
|
|
// Step 1: Login as admin
|
||
|
|
await loginAs(page, USERS.admin.email, USERS.admin.password);
|
||
|
|
await selectClub(page, 'club-1');
|
||
|
|
|
||
|
|
// Step 2: Navigate to /tasks
|
||
|
|
await page.goto('/tasks');
|
||
|
|
await page.waitForLoadState('networkidle');
|
||
|
|
|
||
|
|
// Step 3: Open status filter dropdown
|
||
|
|
const statusFilter = page.locator('select').first();
|
||
|
|
await expect(statusFilter).toBeVisible();
|
||
|
|
|
||
|
|
// Step 4: Select "Open" status
|
||
|
|
await statusFilter.selectOption('Open');
|
||
|
|
await page.waitForTimeout(1000); // Wait for filter to apply
|
||
|
|
|
||
|
|
// Step 5: Verify only Open tasks displayed
|
||
|
|
const statusBadges = page.locator('table tbody tr td:nth-child(2)');
|
||
|
|
const badgeCount = await statusBadges.count();
|
||
|
|
|
||
|
|
if (badgeCount > 0) {
|
||
|
|
// Verify all visible badges say "Open"
|
||
|
|
for (let i = 0; i < badgeCount; i++) {
|
||
|
|
const badgeText = await statusBadges.nth(i).textContent();
|
||
|
|
expect(badgeText?.trim()).toBe('Open');
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
// Step 6: Select "Done" status
|
||
|
|
await statusFilter.selectOption('Done');
|
||
|
|
await page.waitForTimeout(1000);
|
||
|
|
|
||
|
|
// Step 7: Verify only Done tasks displayed
|
||
|
|
const doneBadgeCount = await statusBadges.count();
|
||
|
|
if (doneBadgeCount > 0) {
|
||
|
|
for (let i = 0; i < doneBadgeCount; i++) {
|
||
|
|
const badgeText = await statusBadges.nth(i).textContent();
|
||
|
|
expect(badgeText?.trim()).toBe('Done');
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
// Step 8: Clear filter (select "All Statuses")
|
||
|
|
await statusFilter.selectOption('');
|
||
|
|
await page.waitForTimeout(1000);
|
||
|
|
|
||
|
|
// Step 9: Verify all tasks displayed (mixed statuses)
|
||
|
|
const allBadgeCount = await statusBadges.count();
|
||
|
|
expect(allBadgeCount).toBeGreaterThanOrEqual(0);
|
||
|
|
});
|
||
|
|
|
||
|
|
test('State transition validation: Cannot skip states', async ({ page }) => {
|
||
|
|
// Login as admin and create a task
|
||
|
|
await loginAs(page, USERS.admin.email, USERS.admin.password);
|
||
|
|
await selectClub(page, 'club-1');
|
||
|
|
|
||
|
|
await page.goto('/tasks/new');
|
||
|
|
await page.fill('input[name="title"]', 'Test state validation');
|
||
|
|
await page.locator('button:has-text("Create Task")').click();
|
||
|
|
await page.waitForURL(/\/tasks\/[0-9a-f-]+/);
|
||
|
|
|
||
|
|
// Verify task is Open
|
||
|
|
await expect(page.locator('text=Open').first()).toBeVisible();
|
||
|
|
|
||
|
|
// Valid transition: Open → Assigned should be available
|
||
|
|
await expect(page.locator('button:has-text("Move to Assigned")')).toBeVisible();
|
||
|
|
|
||
|
|
// Invalid transitions should NOT be available
|
||
|
|
await expect(page.locator('button:has-text("Move to InProgress")')).not.toBeVisible();
|
||
|
|
await expect(page.locator('button:has-text("Move to Review")')).not.toBeVisible();
|
||
|
|
await expect(page.locator('button:has-text("Mark as Done")')).not.toBeVisible();
|
||
|
|
});
|
||
|
|
|
||
|
|
test('Review can transition back to InProgress', async ({ page }) => {
|
||
|
|
// Login as admin and create a task
|
||
|
|
await loginAs(page, USERS.admin.email, USERS.admin.password);
|
||
|
|
await selectClub(page, 'club-1');
|
||
|
|
|
||
|
|
await page.goto('/tasks/new');
|
||
|
|
await page.fill('input[name="title"]', 'Test review back to progress');
|
||
|
|
await page.locator('button:has-text("Create Task")').click();
|
||
|
|
await page.waitForURL(/\/tasks\/[0-9a-f-]+/);
|
||
|
|
|
||
|
|
// Transition through states to Review
|
||
|
|
await page.locator('button:has-text("Move to Assigned")').click();
|
||
|
|
await page.waitForTimeout(1000);
|
||
|
|
await page.locator('button:has-text("Move to InProgress")').click();
|
||
|
|
await page.waitForTimeout(1000);
|
||
|
|
await page.locator('button:has-text("Move to Review")').click();
|
||
|
|
await page.waitForTimeout(1000);
|
||
|
|
|
||
|
|
// Verify in Review state
|
||
|
|
await expect(page.locator('text=Review').first()).toBeVisible();
|
||
|
|
|
||
|
|
// Verify both forward (Done) and backward (InProgress) transitions available
|
||
|
|
await expect(page.locator('button:has-text("Mark as Done")')).toBeVisible();
|
||
|
|
await expect(page.locator('button:has-text("Back to InProgress")')).toBeVisible();
|
||
|
|
|
||
|
|
// Test backward transition
|
||
|
|
await page.locator('button:has-text("Back to InProgress")').click();
|
||
|
|
await page.waitForTimeout(1000);
|
||
|
|
await expect(page.locator('text=InProgress').first()).toBeVisible();
|
||
|
|
});
|
||
|
|
|
||
|
|
test('Manager can create and update tasks', async ({ page }) => {
|
||
|
|
// Login as manager (not admin)
|
||
|
|
await loginAs(page, USERS.manager.email, USERS.manager.password);
|
||
|
|
await selectClub(page, 'club-1');
|
||
|
|
|
||
|
|
// Navigate to tasks
|
||
|
|
await page.goto('/tasks');
|
||
|
|
|
||
|
|
// Verify "New Task" button is visible for Manager
|
||
|
|
await expect(page.locator('text=New Task')).toBeVisible();
|
||
|
|
|
||
|
|
// Create a task
|
||
|
|
await page.locator('text=New Task').click();
|
||
|
|
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-]+/);
|
||
|
|
|
||
|
|
// Verify task created successfully
|
||
|
|
await expect(page.locator('text=Manager created task')).toBeVisible();
|
||
|
|
await expect(page.locator('text=Open').first()).toBeVisible();
|
||
|
|
|
||
|
|
// Verify can transition states
|
||
|
|
await expect(page.locator('button:has-text("Move to Assigned")')).toBeVisible();
|
||
|
|
});
|
||
|
|
|
||
|
|
test('Task detail page shows all task information', async ({ page }) => {
|
||
|
|
// Login and create a task with all fields
|
||
|
|
await loginAs(page, USERS.admin.email, USERS.admin.password);
|
||
|
|
await selectClub(page, 'club-1');
|
||
|
|
|
||
|
|
await page.goto('/tasks/new');
|
||
|
|
await page.fill('input[name="title"]', 'Complete task details test');
|
||
|
|
await page.fill('textarea[name="description"]', 'This task has all fields filled');
|
||
|
|
|
||
|
|
// Set due date (tomorrow)
|
||
|
|
const tomorrow = new Date();
|
||
|
|
tomorrow.setDate(tomorrow.getDate() + 1);
|
||
|
|
const dueDateStr = tomorrow.toISOString().split('T')[0];
|
||
|
|
await page.fill('input[name="dueDate"]', dueDateStr);
|
||
|
|
|
||
|
|
await page.locator('button:has-text("Create Task")').click();
|
||
|
|
await page.waitForURL(/\/tasks\/[0-9a-f-]+/);
|
||
|
|
|
||
|
|
// Verify all fields are displayed
|
||
|
|
await expect(page.locator('text=Complete task details test')).toBeVisible();
|
||
|
|
await expect(page.locator('text=This task has all fields filled')).toBeVisible();
|
||
|
|
await expect(page.locator('text=Open').first()).toBeVisible();
|
||
|
|
await expect(page.locator('text=Unassigned')).toBeVisible();
|
||
|
|
await expect(page.locator('text=Created At')).toBeVisible();
|
||
|
|
await expect(page.locator('text=Due Date')).toBeVisible();
|
||
|
|
});
|
||
|
|
|
||
|
|
test('Pagination controls work correctly', async ({ page }) => {
|
||
|
|
// Login as admin
|
||
|
|
await loginAs(page, USERS.admin.email, USERS.admin.password);
|
||
|
|
await selectClub(page, 'club-1');
|
||
|
|
|
||
|
|
// Navigate to tasks
|
||
|
|
await page.goto('/tasks');
|
||
|
|
await page.waitForLoadState('networkidle');
|
||
|
|
|
||
|
|
// Check if pagination controls are visible (only if more than pageSize tasks)
|
||
|
|
const paginationControls = page.locator('text=Page');
|
||
|
|
const hasPagination = await paginationControls.isVisible().catch(() => false);
|
||
|
|
|
||
|
|
if (hasPagination) {
|
||
|
|
// Verify "Previous" button is disabled on page 1
|
||
|
|
const prevButton = page.locator('button:has-text("Previous")');
|
||
|
|
await expect(prevButton).toBeDisabled();
|
||
|
|
|
||
|
|
// Click "Next" button
|
||
|
|
const nextButton = page.locator('button:has-text("Next")');
|
||
|
|
await nextButton.click();
|
||
|
|
await page.waitForTimeout(1000);
|
||
|
|
|
||
|
|
// Verify page number increased
|
||
|
|
await expect(page.locator('text=Page 2')).toBeVisible();
|
||
|
|
|
||
|
|
// Verify "Previous" button is now enabled
|
||
|
|
await expect(prevButton).toBeEnabled();
|
||
|
|
}
|
||
|
|
});
|
||
|
|
|
||
|
|
test('Back button navigation from task detail', async ({ page }) => {
|
||
|
|
// Login and navigate to tasks
|
||
|
|
await loginAs(page, USERS.admin.email, USERS.admin.password);
|
||
|
|
await selectClub(page, 'club-1');
|
||
|
|
|
||
|
|
await page.goto('/tasks/new');
|
||
|
|
await page.fill('input[name="title"]', 'Test back navigation');
|
||
|
|
await page.locator('button:has-text("Create Task")').click();
|
||
|
|
await page.waitForURL(/\/tasks\/[0-9a-f-]+/);
|
||
|
|
|
||
|
|
// Click "Back to Tasks" link
|
||
|
|
const backLink = page.locator('text=← Back to Tasks');
|
||
|
|
await expect(backLink).toBeVisible();
|
||
|
|
await backLink.click();
|
||
|
|
|
||
|
|
// Verify navigated back to task list
|
||
|
|
await page.waitForURL(/\/tasks$/);
|
||
|
|
await expect(page.locator('text=Tasks').first()).toBeVisible();
|
||
|
|
});
|
||
|
|
});
|