Files
jobtrackingapp/job-tracker-ui/e2e/analytics-presentation.spec.ts

169 lines
14 KiB
TypeScript

import { expect, test, type APIRequestContext, type Page, type TestInfo } from '@playwright/test';
import AxeBuilder from '@axe-core/playwright';
import { translations } from '../src/i18n/translations';
const apiUrl = 'http://localhost:5302/api';
const password = 'E2ePassword123!';
async function login(api: APIRequestContext, email: string) {
expect((await api.post(`${apiUrl}/auth/login`, { data: { email, password } })).status()).toBe(200);
const me = await (await api.get(`${apiUrl}/auth/me`)).json();
const csrf = (await api.storageState()).cookies.find(cookie => cookie.name === 'XSRF-TOKEN')!.value;
return { me, headers: { 'X-CSRF-TOKEN': csrf, 'X-Session-Context': me.sessionContext as string } };
}
async function capture(page: Page, info: TestInfo, name: string) {
const path = info.outputPath(`${name}.png`);
await page.screenshot({ path, fullPage: true });
await info.attach(name, { path, contentType: 'image/png' });
}
test('[PRES-S01 S02 E07 E08 E09 J05 J09] Analytics native reports, direct entry, period, drill-down and owner isolation', async ({ page, playwright }, info) => {
test.setTimeout(240000);
const admin = await playwright.request.newContext();
const { headers: adminHeaders } = await login(admin, 'e2e@example.test');
const email = `analytics-${Date.now()}@example.test`;
expect((await admin.post(`${apiUrl}/users`, { headers: adminHeaders, data: { email, password, roles: [] } })).status()).toBe(200);
const { me, headers } = await login(page.request, email);
await page.route('**/*', route => ['localhost', '127.0.0.1'].includes(new URL(route.request().url()).hostname) ? route.continue() : route.abort());
const company = await page.request.post(`${apiUrl}/companies`, { headers, data: { name: 'Nordlys langsiktige karrieremuligheter æøå', source: 'NAV', location: 'Tromsø' } });
expect(company.status()).toBe(201); const companyId = (await company.json()).id;
const ids: number[] = [];
const now = new Date();
for (let index = 0; index < 6; index++) {
const date = new Date(now.getFullYear(), now.getMonth() - (index === 5 ? 14 : index % 3), 3).toISOString();
const data = { companyId, jobTitle: `Utvikler og faglig rådgiver med lang stillingstittel ${index}`, status: ['Applied', 'Interview', 'Offer', 'Faglig vurdering', 'Saved', 'Rejected'][index], dateApplied: index === 4 ? null : date, tags: index === 4 ? null : 'Azure, TypeScript, Drift, Test, Design, Data, Plattform, Sikkerhet, Analyse, Integrasjon, Norsk, Samarbeid', ...(index % 2 ? {} : { salaryMin: 600000, salaryMax: 800000, salaryCurrency: 'NOK', salaryPeriod: 'year' }) };
const created = await page.request.post(`${apiUrl}/jobapplications`, { headers, data });
expect(created.status()).toBe(201); ids.push((await created.json()).id);
if (index === 1) expect((await page.request.put(`${apiUrl}/jobapplications/${ids[index]}`, { headers, data: { ...data, responseReceived: true, responseDate: date } })).status()).toBe(204);
}
await page.setViewportSize({ width: 1440, height: 900 });
await page.goto('/analytics?months=6');
await expect(page.getByRole('heading', { name: 'Analytics', exact: true, level: 1 })).toBeVisible();
await expect(page.getByRole('button', { name: 'Analytics', exact: true })).toHaveAttribute('aria-current', 'page');
await expect.poll(() => page.getByRole('button', { name: 'Analytics', exact: true }).evaluate(element => {
const item = element.getBoundingClientRect();
for (let parent = element.parentElement; parent; parent = parent.parentElement) {
if (getComputedStyle(parent).overflowY === 'auto' && parent.scrollHeight > parent.clientHeight) {
const visible = parent.getBoundingClientRect();
if (item.top < visible.top - 1 || item.bottom > visible.bottom + 1) return false;
}
}
return true;
})).toBe(true);
await expect(page.getByRole('button', { name: '6 mo' })).toHaveAttribute('aria-pressed', 'true');
const activity = page.getByRole('region', { name: 'Application activity' });
const monthly = await (await page.request.get(`${apiUrl}/jobapplications/analytics?months=6`)).json();
const total = monthly.reduce((sum: number, point: { applied: number }) => sum + point.applied, 0);
const responses = monthly.reduce((sum: number, point: { responses: number }) => sum + point.responses, 0);
expect(total).toBe(4); expect(responses).toBe(1);
await expect(page.getByRole('region', { name: 'Response sources' }).getByText('1 of 6 non-deleted jobs have a recorded response.', { exact: true })).toBeVisible();
await expect(activity.getByText(`${responses} / ${total} recorded responses`, { exact: false })).toBeVisible();
for (const point of monthly) {
const month = new Intl.DateTimeFormat('en-GB', { month: 'long', year: 'numeric', timeZone: 'UTC' }).format(new Date(`${point.month}-01T12:00:00Z`));
await expect(activity.getByRole('row', { name: `${month} ${point.applied} ${point.responses}`, exact: true })).toBeVisible();
}
await activity.getByText('View monthly values', { exact: true }).focus();
await page.keyboard.press('Enter'); await expect(activity.getByRole('table')).toBeHidden();
await page.keyboard.press('Enter'); await expect(activity.getByRole('table')).toBeVisible();
expect(await activity.getByText('View monthly values', { exact: true }).evaluate(element => parseFloat(getComputedStyle(element).outlineWidth))).toBeGreaterThanOrEqual(3);
await capture(page, info, 'analytics-exact-values-keyboard');
await page.getByRole('button', { name: '24 mo' }).click();
await expect(page).toHaveURL(/\/analytics\?months=24$/);
await expect(activity.getByText('1 / 5 recorded responses', { exact: false })).toBeVisible();
await page.goBack(); await expect(page.getByRole('button', { name: '6 mo' })).toHaveAttribute('aria-pressed', 'true');
await page.reload(); await expect(activity.getByRole('table')).toBeVisible();
await page.getByRole('link', { name: 'Faglig vurdering', exact: true }).click();
await expect(page).toHaveURL(/\/jobs\?status=Faglig\+vurdering$/);
await expect(page.locator(`[data-job-row-id="${ids[3]}"]`)).toBeVisible();
await expect(page.locator('[data-job-row-id]')).toHaveCount(1);
await page.goBack(); await expect(page.getByRole('button', { name: '6 mo' })).toHaveAttribute('aria-pressed', 'true');
await page.getByRole('link', { name: 'Nordlys langsiktige karrieremuligheter æøå', exact: true }).click();
await expect(page).toHaveURL(new RegExp(`/jobs\\?companyId=${companyId}$`));
await expect(page.locator('[data-job-row-id]')).toHaveCount(6); await page.goBack();
// Retained Dashboard flags must not remove the independent report destination.
await page.evaluate(owner => localStorage.setItem(`dashboardPrefs:${owner}`, JSON.stringify({ cards: false, activity: false, funnel: false, companies: false, skills: false })), me.id);
await page.goto('/dashboard'); await expect(page.getByText('Your dashboard sections are hidden.')).toBeVisible();
await page.getByRole('button', { name: 'Analytics', exact: true }).click();
await expect(activity.getByRole('table')).toBeVisible();
await expect(page.getByRole('button', { name: 'Customize dashboard' })).toHaveCount(0);
for (const mode of ['light', 'dark'] as const) for (const language of ['en', 'nb'] as const) {
const t = translations[language];
await page.evaluate(({ mode, language }) => { localStorage.setItem('jobtracker.themeMode', mode); localStorage.setItem('uiLanguage', language); }, { mode, language });
await page.goto('about:blank');
expect((await page.request.put(`${apiUrl}/auth/preferences/language`, { headers, data: { language: language === 'nb' ? 'nb-NO' : 'en' } })).status()).toBe(204);
await page.goto('/analytics?months=6');
await expect(page.getByRole('heading', { name: t.analytics, exact: true, level: 1 })).toBeVisible();
await expect(page.locator('html')).toHaveAttribute('data-color-scheme', mode);
await expect(page.getByRole('region', { name: t.dashboardApplicationActivity }).getByRole('table')).toBeVisible();
for (const [width, height, name] of [[1440, 900, 'desktop'], [390, 844, 'phone']] as const) {
await page.setViewportSize({ width, height });
await expect.poll(() => page.evaluate(() => document.documentElement.scrollWidth <= innerWidth + 1)).toBe(true);
await capture(page, info, `analytics-${name}-${mode}-${language}`);
}
await page.getByRole('link', { name: t.goToDashboard, exact: true }).click();
await expect(page.getByText(t.dashboardAllHidden, { exact: true })).toBeVisible();
await page.getByRole('button', { name: t.openNavigation, exact: true }).click();
await page.getByRole('button', { name: t.analytics, exact: true }).click();
await expect(page.getByRole('heading', { name: t.analytics, exact: true, level: 1 })).toBeFocused();
if (language === 'en') {
const scan = await new AxeBuilder({ page }).withTags(['wcag2a', 'wcag2aa', 'wcag21a', 'wcag21aa']).analyze();
await info.attach(`analytics-${mode}-axe`, { body: JSON.stringify({ violations: scan.violations, incomplete: scan.incomplete }), contentType: 'application/json' });
expect(scan.violations).toEqual([]);
}
}
for (const [width, height] of [[320, 740], [768, 1024], [1920, 1080]]) {
await page.setViewportSize({ width, height });
await expect.poll(() => page.evaluate(() => document.documentElement.scrollWidth <= innerWidth + 1)).toBe(true);
await capture(page, info, `analytics-${width}-dark-nb`);
}
await page.setViewportSize({ width: 1440, height: 900 });
await page.getByRole('button', { name: translations.nb.collapseSidebar }).click();
await expect(page.getByRole('button', { name: translations.nb.analytics, exact: true })).toHaveAttribute('aria-current', 'page');
await page.getByRole('button', { name: translations.nb.analytics, exact: true }).click();
await expect(page.getByRole('heading', { name: translations.nb.analytics, exact: true, level: 1 })).toBeVisible();
// UI account switch disposes old report data; a fresh owner cannot retrieve A's report/job.
const emailB = `analytics-b-${Date.now()}@example.test`;
expect((await admin.post(`${apiUrl}/users`, { headers: adminHeaders, data: { email: emailB, password, roles: [] } })).status()).toBe(200);
await page.getByRole('button', { name: translations.nb.user, exact: true }).click();
await page.getByRole('menuitem', { name: translations.nb.signOut, exact: true }).click();
await expect(page).toHaveURL(/\/login\?sessionChanged=1$/);
await page.goto('about:blank');
const b = await login(page.request, emailB);
await page.goto('/analytics?months=6');
await expect(page.getByText('No tracked jobs to report yet.', { exact: true })).toBeVisible();
await expect(page.getByText('Nordlys langsiktige karrieremuligheter æøå', { exact: true })).toHaveCount(0);
expect((await page.request.get(`${apiUrl}/jobapplications/${ids[0]}`)).status()).toBe(404);
await capture(page, info, 'analytics-empty-new-owner');
const bCompany = await page.request.post(`${apiUrl}/companies`, { headers: b.headers, data: { name: 'First employer' } });
expect(bCompany.status()).toBe(201);
expect((await page.request.post(`${apiUrl}/jobapplications`, { headers: b.headers, data: { companyId: (await bCompany.json()).id, jobTitle: 'First application', status: 'Applied', dateApplied: now.toISOString() } })).status()).toBe(201);
await page.reload(); await expect(page.getByText('0 / 1 recorded responses', { exact: false })).toBeVisible();
await capture(page, info, 'analytics-one-application');
await admin.dispose();
});
test('[PRES-S01 E08] Analytics independent loading and failure states never masquerade as empty reports', async ({ page }, info) => {
await login(page.request, 'e2e@example.test');
let failSnapshot = false; let failTrends = true; let release: (() => void) | undefined;
const hold = new Promise<void>(resolve => { release = resolve; });
await page.route(`${apiUrl}/jobapplications/stats`, async route => { await hold; return failSnapshot ? route.fulfill({ status: 503, json: { message: 'Unavailable' } }) : route.fulfill({ json: { total: 4, active: 4, byStatus: { Applied: 4 } } }); });
await page.route(`${apiUrl}/jobapplications/analytics-overview`, route => route.fulfill({ json: { funnel: [{ label: 'Applied', count: 4 }], topCompanies: [], responseRateBySource: [] } }));
await page.route(`${apiUrl}/jobapplications/analytics?*`, async route => { await hold; return failTrends ? route.fulfill({ status: 503, json: { message: 'Unavailable' } }) : route.fulfill({ json: [{ month: '2026-08', applied: 0, responses: 0 }, { month: '2026-09', applied: 0, responses: 0 }] }); });
await page.goto('/analytics'); await expect(page.getByRole('progressbar').first()).toBeVisible();
await capture(page, info, 'analytics-loading'); release!();
await expect(page.getByText('Unable to load report trends')).toBeVisible();
await expect(page.getByRole('region', { name: 'Current pipeline' })).toBeVisible();
await capture(page, info, 'analytics-trends-error');
failTrends = false; await page.getByRole('button', { name: 'Retry', exact: true }).click();
await expect(page.getByText('No applications or dated responses in this period.')).toBeVisible();
await capture(page, info, 'analytics-zero-history');
failSnapshot = true; await page.reload();
await expect(page.getByText('Unable to load current reports')).toBeVisible();
await expect(page.getByText(translations.en.analyticsSnapshotUnavailableBody, { exact: true })).toBeVisible();
await expect(page.getByRole('region', { name: 'Application activity' })).toBeVisible();
await expect(page.getByText('No tracked jobs to report yet.')).toHaveCount(0);
await capture(page, info, 'analytics-snapshot-error');
failSnapshot = false; await page.getByRole('button', { name: 'Retry', exact: true }).click();
await expect(page.getByRole('region', { name: 'Current pipeline' })).toBeVisible();
});