169 lines
14 KiB
TypeScript
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();
|
|
});
|