fix(capacity): stabilize PTO flows and calendar consistency
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.
This commit is contained in:
@@ -2,6 +2,16 @@ 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');
|
||||
@@ -24,8 +34,28 @@ async function createTeamMember(page: Page, token: string) {
|
||||
}
|
||||
});
|
||||
|
||||
const body = await response.json();
|
||||
return body.id as string;
|
||||
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', () => {
|
||||
@@ -54,6 +84,7 @@ test.describe('Capacity Calendar', () => {
|
||||
|
||||
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) {
|
||||
@@ -64,6 +95,7 @@ test.describe('Capacity Calendar', () => {
|
||||
|
||||
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 });
|
||||
@@ -77,21 +109,57 @@ test.describe('Capacity Calendar', () => {
|
||||
|
||||
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}`
|
||||
`${API_BASE}/api/capacity?month=${period}&team_member_id=${memberId}`,
|
||||
{
|
||||
headers: { Authorization: `Bearer ${authToken}` }
|
||||
}
|
||||
);
|
||||
const body = await response.json();
|
||||
const changedDetail = (body.details as Array<{ date: string; availability: number }>).find(
|
||||
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();
|
||||
});
|
||||
});
|
||||
|
||||
19
frontend/tests/unit/api-error-sanitization.spec.ts
Normal file
19
frontend/tests/unit/api-error-sanitization.spec.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { GENERIC_SERVER_ERROR_MESSAGE, sanitizeApiErrorMessage } from '$lib/services/api';
|
||||
|
||||
describe('sanitizeApiErrorMessage', () => {
|
||||
it('replaces HTML payloads with the generic server error message', () => {
|
||||
const htmlMessage = '<!DOCTYPE html><html><body>SQL error</body></html>';
|
||||
expect(sanitizeApiErrorMessage(htmlMessage)).toBe(GENERIC_SERVER_ERROR_MESSAGE);
|
||||
});
|
||||
|
||||
it('replaces SQLSTATE content with the generic server error message', () => {
|
||||
const sqlMessage = 'SQLSTATE[HY000]: General error: 1 Unknown column';
|
||||
expect(sanitizeApiErrorMessage(sqlMessage)).toBe(GENERIC_SERVER_ERROR_MESSAGE);
|
||||
});
|
||||
|
||||
it('returns short API messages unchanged', () => {
|
||||
const userMessage = 'User not found';
|
||||
expect(sanitizeApiErrorMessage(userMessage)).toBe(userMessage);
|
||||
});
|
||||
});
|
||||
40
frontend/tests/unit/capacity-api.spec.ts
Normal file
40
frontend/tests/unit/capacity-api.spec.ts
Normal file
@@ -0,0 +1,40 @@
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
|
||||
vi.mock('$lib/services/api', () => ({
|
||||
api: {
|
||||
get: vi.fn()
|
||||
}
|
||||
}));
|
||||
|
||||
import { getTeamCapacity } from '$lib/api/capacity';
|
||||
import { api } from '$lib/services/api';
|
||||
|
||||
describe('capacity api mapping', () => {
|
||||
it('maps legacy team capacity payload keys', async () => {
|
||||
vi.mocked(api.get).mockResolvedValueOnce({
|
||||
month: '2026-02',
|
||||
person_days: 10.5,
|
||||
hours: 84,
|
||||
members: []
|
||||
});
|
||||
|
||||
const payload = await getTeamCapacity('2026-02');
|
||||
|
||||
expect(payload.total_person_days).toBe(10.5);
|
||||
expect(payload.total_hours).toBe(84);
|
||||
});
|
||||
|
||||
it('maps new team capacity payload keys', async () => {
|
||||
vi.mocked(api.get).mockResolvedValueOnce({
|
||||
month: '2026-03',
|
||||
total_person_days: 12,
|
||||
total_hours: 96,
|
||||
members: []
|
||||
});
|
||||
|
||||
const payload = await getTeamCapacity('2026-03');
|
||||
|
||||
expect(payload.total_person_days).toBe(12);
|
||||
expect(payload.total_hours).toBe(96);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user