Make PTO creation immediately approved, add PTO deletion, and ensure cache invalidation updates individual/team/revenue capacity consistently. Harden holiday duplicate handling (422), support PTO-day availability overrides without disabling edits, and align tests plus OpenSpec artifacts with the new behavior.
166 lines
5.8 KiB
TypeScript
166 lines
5.8 KiB
TypeScript
import { test, expect, type Page } from '@playwright/test';
|
|
|
|
const API_BASE = 'http://localhost:3000';
|
|
|
|
function unwrapData<T>(payload: unknown): T {
|
|
let current: unknown = payload;
|
|
|
|
while (current && typeof current === 'object' && 'data' in current) {
|
|
current = (current as { data: unknown }).data;
|
|
}
|
|
|
|
return current as T;
|
|
}
|
|
|
|
async function login(page: Page) {
|
|
await page.goto('/login');
|
|
await page.fill('input[type="email"]', 'superuser@headroom.test');
|
|
await page.fill('input[type="password"]', 'password');
|
|
await page.click('button[type="submit"]');
|
|
await page.waitForURL('/dashboard');
|
|
}
|
|
|
|
async function createTeamMember(page: Page, token: string) {
|
|
const response = await page.request.post(`${API_BASE}/api/team-members`, {
|
|
headers: {
|
|
Authorization: `Bearer ${token}`,
|
|
'Content-Type': 'application/json'
|
|
},
|
|
data: {
|
|
name: `Capacity Calendar Tester ${Date.now()}`,
|
|
role_id: 1,
|
|
hourly_rate: 150,
|
|
active: true
|
|
}
|
|
});
|
|
|
|
const body = unwrapData<{ id: string }>(await response.json());
|
|
return body.id;
|
|
}
|
|
|
|
async function createPto(page: Page, token: string, memberId: string, date: string) {
|
|
await page.request.post(`${API_BASE}/api/ptos`, {
|
|
headers: {
|
|
Authorization: `Bearer ${token}`,
|
|
'Content-Type': 'application/json'
|
|
},
|
|
data: {
|
|
team_member_id: memberId,
|
|
start_date: date,
|
|
end_date: date,
|
|
reason: 'Capacity calendar PTO test'
|
|
}
|
|
});
|
|
}
|
|
|
|
async function expectNoRawErrorAlerts(page: Page) {
|
|
await expect(page.locator('.alert.alert-error:has-text("<!DOCTYPE")')).toHaveCount(0);
|
|
await expect(page.locator('.alert.alert-error:has-text("SQLSTATE")')).toHaveCount(0);
|
|
}
|
|
|
|
test.describe('Capacity Calendar', () => {
|
|
let authToken: string;
|
|
let memberId: string | null = null;
|
|
|
|
test.beforeEach(async ({ page }) => {
|
|
await login(page);
|
|
const token = (await page.evaluate(() => localStorage.getItem('headroom_access_token'))) ?? '';
|
|
authToken = token;
|
|
memberId = await createTeamMember(page, authToken);
|
|
|
|
await page.goto('/capacity');
|
|
await page.waitForURL('/capacity');
|
|
await page.waitForResponse((response) => response.url().includes('/api/team-members') && response.status() === 200);
|
|
});
|
|
|
|
test.afterEach(async ({ page }) => {
|
|
if (memberId) {
|
|
await page.request.delete(`${API_BASE}/api/team-members/${memberId}`, {
|
|
headers: { Authorization: `Bearer ${authToken}` }
|
|
}).catch(() => null);
|
|
memberId = null;
|
|
}
|
|
});
|
|
|
|
test('should save availability change with success message', async ({ page }) => {
|
|
await expect(page.locator('select[aria-label="Select team member"]')).toBeVisible({ timeout: 10000 });
|
|
await expectNoRawErrorAlerts(page);
|
|
|
|
const teamMemberSelect = page.locator('select[aria-label="Select team member"]');
|
|
if (memberId) {
|
|
await teamMemberSelect.selectOption({ value: memberId });
|
|
}
|
|
|
|
await expect(page.locator('[data-testid="capacity-calendar"]')).toBeVisible({ timeout: 10000 });
|
|
|
|
const availabilitySelects = page
|
|
.locator('select[aria-label^="Availability for"]')
|
|
.locator(':scope:not([disabled])')
|
|
.filter({ has: page.locator('option[value="1"]') });
|
|
|
|
await expect(availabilitySelects.first()).toBeVisible({ timeout: 5000 });
|
|
|
|
const firstSelect = availabilitySelects.first();
|
|
const ariaLabel = await firstSelect.getAttribute('aria-label');
|
|
const targetDate = ariaLabel?.replace('Availability for ', '') ?? '';
|
|
expect(targetDate).toBeTruthy();
|
|
|
|
await firstSelect.selectOption('0.5');
|
|
|
|
await expect(page.locator('text=Saving availability...')).toBeVisible({ timeout: 5000 });
|
|
await expect(page.locator('text=Saving availability...')).not.toBeVisible({ timeout: 10000 });
|
|
await expect(page.locator('[data-testid="capacity-calendar"] .alert.alert-error')).toHaveCount(0);
|
|
await expectNoRawErrorAlerts(page);
|
|
|
|
if (memberId) {
|
|
const period =
|
|
(await page.evaluate(() => localStorage.getItem('headroom_selected_period'))) ?? '2026-02';
|
|
const response = await page.request.get(
|
|
`${API_BASE}/api/capacity?month=${period}&team_member_id=${memberId}`,
|
|
{
|
|
headers: { Authorization: `Bearer ${authToken}` }
|
|
}
|
|
);
|
|
const body = unwrapData<{ details: Array<{ date: string; availability: number }> }>(
|
|
await response.json()
|
|
);
|
|
const changedDetail = body.details.find(
|
|
(detail) => detail.date === targetDate
|
|
);
|
|
expect(changedDetail).toBeDefined();
|
|
expect(changedDetail?.availability).toBe(0.5);
|
|
}
|
|
});
|
|
|
|
test('should preselect availability and force blocked days to zero', async ({ page }) => {
|
|
await expect(page.locator('select[aria-label="Select team member"]')).toBeVisible({ timeout: 10000 });
|
|
|
|
const teamMemberSelect = page.locator('select[aria-label="Select team member"]');
|
|
if (!memberId) {
|
|
test.fail(true, 'memberId was not created');
|
|
return;
|
|
}
|
|
|
|
await createPto(page, authToken, memberId, '2026-02-10');
|
|
|
|
await teamMemberSelect.selectOption({ value: memberId });
|
|
await expect(page.locator('[data-testid="capacity-calendar"]')).toBeVisible({ timeout: 10000 });
|
|
|
|
const weekdaySelect = page.locator('select[aria-label="Availability for 2026-02-02"]');
|
|
await expect(weekdaySelect).toBeVisible();
|
|
await expect(weekdaySelect).not.toHaveValue('');
|
|
|
|
const weekendSelect = page.locator('select[aria-label="Availability for 2026-02-01"]');
|
|
await expect(weekendSelect).toHaveValue('0');
|
|
await expect(weekendSelect).toBeDisabled();
|
|
|
|
await page.reload();
|
|
await page.waitForURL('/capacity');
|
|
await teamMemberSelect.selectOption({ value: memberId });
|
|
|
|
const ptoSelect = page.locator('select[aria-label="Availability for 2026-02-10"]');
|
|
await expect(ptoSelect).toHaveValue('0');
|
|
await expect(ptoSelect).toBeEnabled();
|
|
});
|
|
});
|