test(redesign): expose native P5-15-01 audit failures
This commit is contained in:
@@ -0,0 +1,28 @@
|
||||
# P5-15-01 deferred preference diagnostics
|
||||
|
||||
These native Playwright probes run separately from the ordinary `job-tracker-ui/e2e` suite. They assert the intended behavior and fail on a genuine HTTP 500, incorrect persisted readback, or lost unsaved rule draft. There are no success response mocks, expected failures, retries within the probes, real account credentials, or provider calls.
|
||||
|
||||
From `job-tracker-ui`, after the normal Release API build and frontend preparation:
|
||||
|
||||
```powershell
|
||||
rtk proxy node node_modules/@playwright/test/cli.js test --config=../docs/redesign/phase-5/p5-15/p5-15-01/diagnostics/playwright.config.ts
|
||||
```
|
||||
|
||||
The config imports the ordinary Playwright config: the same local ports, disposable API database, synthetic administrative bootstrap account, serial worker and retained failure trace apply. The working directory must remain `job-tracker-ui`. Use the ordinary static-export / prebuilt / server-reuse environment options only when the corresponding local servers or export have already been prepared. Do not run alongside the main suite on the shared ports.
|
||||
|
||||
The first probe edits a rule, switches to Notifications and returns without saving. Its attachment records the initial value, draft, displayed return value, API readback status/value and count of rule writes. The second probe uses two pages in one genuine session: language selection and notification Save run alongside four genuine report/notification reads, for three alternating language rounds. Each round records actual mutation/read statuses, persisted language/notification values, document language and alert DOM counts before the success assertions. The raw `errorsVisible` name is misleading: counts include the Next route announcer and do not establish visible errors. The portable summary labels them accordingly. Partial evidence is retained when a round fails. The final reload checks persisted UI state.
|
||||
|
||||
## Native results and source characterization
|
||||
|
||||
Fresh final execution on 2026-10-09: **one pass, one failure**, separate from the ordinary browser suite. [Portable browser evidence](../evidence/browser-summary.json) contains actual outcomes, commands, times and fingerprints. Rules started at 14, was edited to 5, then returned to 14 after the tab round trip; API readback remained 14, with zero rule writes. The desired draft-retention assertion fails. No application repair or expected-failure flag is included.
|
||||
|
||||
The three native overlap rounds passed: all language writes 204, notification writes 200, concurrent report/notification reads 200, persisted values correct, and final reload retained Bokmål plus disabled notification preference. This did **not** reproduce the recorded preference-save 500 and does **not** close it or establish concurrency/load safety. No exception/root cause for the historical 500 is inferred from this run.
|
||||
|
||||
- `SettingsView.tsx:43`: `TabPanel` returns `null` for an inactive tab, unmounting `RulesSettingsCard`.
|
||||
- `RulesSettingsCard.tsx:23`: rule drafts live in component state; mounting reads `/rules` again. A tab round trip has no draft owner above the unmounted card. The native failed assertion confirms that draft-loss risk.
|
||||
- `I18nProvider.tsx:33`: `setLanguage` applies local state/storage immediately and catches the language PUT rejection without displaying it. A visually translated page is not evidence of a persisted successful preference.
|
||||
- `AuthController.cs:549`: the language endpoint loads the local Identity user, sets `UiLanguage` and calls `UserManager.UpdateAsync`. Identity-result failure becomes 400; an uncaught storage failure can become 500. This source alone does not establish the historical 500's cause.
|
||||
- `NotificationSettingsController.cs:29`: notification PUT updates another property of the same Identity user through `UpdateAsync`; unsuccessful Identity results explicitly become 500. Concurrent full-user writes, optimistic concurrency and SQLite contention remain hypotheses until response/server evidence identifies the failure.
|
||||
- `SettingsView.tsx:70`: language-dependent notification reads can overlap the writes. The diagnostic exercises that real caller behavior, rather than substituting successful responses.
|
||||
|
||||
No application fix is included. An all-success bounded run cannot establish that an intermittent concurrency defect is resolved; preserve the observed statuses and report that limit. A failed run remains failed, with a separately recorded defect for follow-up ownership.
|
||||
@@ -0,0 +1,13 @@
|
||||
import { defineConfig } from '../../../../../../job-tracker-ui/node_modules/@playwright/test';
|
||||
import main from '../../../../../../job-tracker-ui/playwright.config';
|
||||
import path from 'node:path';
|
||||
|
||||
// Playwright otherwise defaults webServer cwd to this diagnostic config's directory.
|
||||
const workspace = path.resolve(__dirname, '../../../../../../job-tracker-ui');
|
||||
const servers = Array.isArray(main.webServer) ? main.webServer : [main.webServer!];
|
||||
export default defineConfig({
|
||||
...main,
|
||||
testDir: __dirname,
|
||||
testMatch: 'preferences.spec.ts',
|
||||
webServer: servers.map(server => ({ ...server, cwd: workspace })),
|
||||
});
|
||||
@@ -0,0 +1,110 @@
|
||||
import { expect, test as base, type APIRequestContext } from '../../../../../../job-tracker-ui/node_modules/@playwright/test';
|
||||
import { translations } from '../../../../../../job-tracker-ui/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 api.get(`${apiUrl}/auth/me`);
|
||||
expect(me.status()).toBe(200);
|
||||
const csrf = (await api.storageState()).cookies.find(cookie => cookie.name === 'XSRF-TOKEN')!.value;
|
||||
return { 'X-CSRF-TOKEN': csrf, 'X-Session-Context': (await me.json()).sessionContext as string };
|
||||
}
|
||||
const test = base.extend<{ headers: Record<string, string> }>({
|
||||
headers: async ({ playwright, context, page }, provide) => {
|
||||
const admin = await playwright.request.newContext();
|
||||
try {
|
||||
const adminHeaders = await login(admin, 'e2e@example.test');
|
||||
const email = `preference-diagnostic-${Date.now()}-${Math.random().toString(36).slice(2)}@example.test`;
|
||||
expect((await admin.post(`${apiUrl}/users`, { headers: adminHeaders, data: { email, password, roles: [] } })).status()).toBe(200);
|
||||
await context.addInitScript(() => localStorage.setItem('uiLanguage', 'en'));
|
||||
await context.route('**/*', route => {
|
||||
const url = new URL(route.request().url());
|
||||
return ['localhost', '127.0.0.1'].includes(url.hostname) || ['data:', 'blob:', 'about:'].includes(url.protocol) ? route.continue() : route.abort();
|
||||
});
|
||||
await provide(await login(page.request, email));
|
||||
} finally { await admin.dispose(); }
|
||||
},
|
||||
});
|
||||
|
||||
test('[P5-15-01 diagnostic] an unsaved Rules draft survives switching to Notifications and back', async ({ page, headers }, info) => {
|
||||
expect((await page.request.get(`${apiUrl}/auth/me`, { headers })).status()).toBe(200);
|
||||
const before = await page.request.get(`${apiUrl}/rules`);
|
||||
expect(before.status()).toBe(200);
|
||||
const original = (await before.json()).appliedFollowUpDays as number;
|
||||
const draft = original === 5 ? 6 : 5;
|
||||
let ruleWrites = 0;
|
||||
page.on('request', request => { if (new URL(request.url()).pathname === '/api/rules' && request.method() === 'PUT') ruleWrites++; });
|
||||
await page.goto('/settings');
|
||||
await page.getByRole('tab', { name: 'Follow-ups', exact: true }).click();
|
||||
await page.getByLabel('Applied: follow-up days', { exact: true }).fill(String(draft));
|
||||
await page.getByRole('tab', { name: 'Notifications', exact: true }).click();
|
||||
await expect(page.getByLabel('Email reminders for follow-ups', { exact: true })).toBeVisible();
|
||||
await page.getByRole('tab', { name: 'Follow-ups', exact: true }).click();
|
||||
const field = page.getByLabel('Applied: follow-up days', { exact: true });
|
||||
await expect(field).toBeVisible();
|
||||
// Wait for the remounted card's genuine read, without treating the original value as success.
|
||||
await expect(field).not.toHaveValue('');
|
||||
const readback = await page.request.get(`${apiUrl}/rules`);
|
||||
expect(readback.status()).toBe(200);
|
||||
const persisted = (await readback.json()).appliedFollowUpDays;
|
||||
const visibleDraft = await field.inputValue();
|
||||
await info.attach('unsaved-rule-tab-retention', { body: JSON.stringify({ original, draft, visibleDraft, persisted, ruleWrites, readbackStatus: readback.status() }), contentType: 'application/json' });
|
||||
expect(ruleWrites).toBe(0);
|
||||
expect(persisted).toBe(original);
|
||||
expect(visibleDraft).toBe(String(draft));
|
||||
});
|
||||
|
||||
test('[P5-15-01 diagnostic] native language and notification saves persist during concurrent report reads', async ({ page, context, headers }, info) => {
|
||||
test.setTimeout(90_000);
|
||||
const notification = await context.newPage();
|
||||
const evidence: unknown[] = [];
|
||||
try {
|
||||
await page.goto('/settings');
|
||||
await notification.goto('/settings');
|
||||
await notification.getByRole('tab', { name: 'Notifications', exact: true }).click();
|
||||
// Three alternating rounds reproduce concurrency; this is bounded diagnosis, not a load test.
|
||||
for (const language of ['nb', 'en', 'nb'] as const) {
|
||||
const current = language === 'nb' ? translations.en : translations.nb;
|
||||
const target = translations[language];
|
||||
const enabled = language === 'en';
|
||||
await notification.getByLabel(current.settingsNotificationsFollowUpReminders, { exact: true }).setChecked(enabled);
|
||||
await page.getByRole('combobox', { name: current.settingsPreferredLanguage, exact: true }).click();
|
||||
const languageResponse = page.waitForResponse(response => response.url() === `${apiUrl}/auth/preferences/language` && response.request().method() === 'PUT');
|
||||
const notificationResponse = notification.waitForResponse(response => response.url() === `${apiUrl}/notification-settings` && response.request().method() === 'PUT');
|
||||
const readPaths = ['/jobapplications/stats', '/jobapplications/analytics-overview', '/jobapplications/analytics?months=6', '/notifications?limit=10'];
|
||||
const reads = readPaths.map(path => page.request.get(`${apiUrl}${path}`, { headers }));
|
||||
await Promise.all([
|
||||
page.getByRole('option', { name: current[language === 'nb' ? 'settingsNorwegian' : 'settingsEnglish'], exact: true }).click(),
|
||||
notification.getByRole('button', { name: new RegExp(`^(${translations.en.settingsNotificationsSave}|${translations.nb.settingsNotificationsSave})$`) }).click(),
|
||||
]);
|
||||
const [savedLanguage, savedNotification, ...readResponses] = await Promise.all([languageResponse, notificationResponse, ...reads]);
|
||||
const me = await page.request.get(`${apiUrl}/auth/me`, { headers });
|
||||
const prefs = await page.request.get(`${apiUrl}/notification-settings`, { headers });
|
||||
const languageReadback = me.ok() ? (await me.json()).uiLanguage : undefined;
|
||||
const notificationReadback = prefs.ok() ? (await prefs.json()).emailFollowUpRemindersEnabled : undefined;
|
||||
const errorsVisible = { language: await page.getByRole('alert').count(), notification: await notification.getByRole('alert').count() };
|
||||
evidence.push({ requestedLanguage: language === 'nb' ? 'nb-NO' : 'en', requestedNotifications: enabled,
|
||||
languageStatus: savedLanguage.status(), notificationStatus: savedNotification.status(),
|
||||
reads: readPaths.map((path, index) => ({ path, status: readResponses[index].status() })),
|
||||
readback: { meStatus: me.status(), notificationStatus: prefs.status(), language: languageReadback, notifications: notificationReadback },
|
||||
localLanguage: await page.locator('html').getAttribute('lang'), errorsVisible });
|
||||
// Capture genuine 500s and persistence before asserting; no expected-failure or success mock.
|
||||
await info.attach(`preference-round-${evidence.length}`, { body: JSON.stringify(evidence[evidence.length - 1], null, 2), contentType: 'application/json' });
|
||||
expect(savedLanguage.status()).toBe(204);
|
||||
expect(savedNotification.status()).toBe(200);
|
||||
for (const response of readResponses) expect(response.status()).toBe(200);
|
||||
expect(me.status()).toBe(200); expect(prefs.status()).toBe(200);
|
||||
expect(languageReadback).toBe(language === 'nb' ? 'nb-NO' : 'en');
|
||||
expect(notificationReadback).toBe(enabled);
|
||||
await expect(page.getByRole('combobox', { name: target.settingsPreferredLanguage, exact: true })).toBeVisible();
|
||||
}
|
||||
await page.reload(); await notification.reload();
|
||||
await expect(page.locator('html')).toHaveAttribute('lang', 'nb-NO');
|
||||
await notification.getByRole('tab', { name: translations.nb.settingsTabNotifications, exact: true }).click();
|
||||
await expect(notification.getByLabel(translations.nb.settingsNotificationsFollowUpReminders, { exact: true })).not.toBeChecked();
|
||||
} finally {
|
||||
await info.attach('native-preference-concurrency', { body: JSON.stringify(evidence, null, 2), contentType: 'application/json' });
|
||||
await notification.close();
|
||||
}
|
||||
});
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,256 @@
|
||||
import { expect, test, type APIRequestContext, type Page } from '@playwright/test';
|
||||
import { translations } from '../src/i18n/translations';
|
||||
|
||||
const apiUrl = 'http://localhost:5302/api';
|
||||
const password = 'E2ePassword123!';
|
||||
const t = translations.en;
|
||||
|
||||
// Same disposable local account / CSRF / session-context setup as preservation tests.
|
||||
async function login(api: APIRequestContext, email: string) {
|
||||
expect((await api.post(`${apiUrl}/auth/login`, { data: { email, password } })).status()).toBe(200);
|
||||
const me = await api.get(`${apiUrl}/auth/me`);
|
||||
expect(me.status()).toBe(200);
|
||||
const csrf = (await api.storageState()).cookies.find(cookie => cookie.name === 'XSRF-TOKEN')!.value;
|
||||
return { 'X-CSRF-TOKEN': csrf, 'X-Session-Context': (await me.json()).sessionContext as string };
|
||||
}
|
||||
|
||||
async function createAccount(admin: APIRequestContext, headers: Record<string, string>, roles: string[] = []) {
|
||||
const email = `acceptance-${Date.now()}-${Math.random().toString(36).slice(2)}@example.test`;
|
||||
expect((await admin.post(`${apiUrl}/users`, { headers, data: { email, password, roles } })).status()).toBe(200);
|
||||
return email;
|
||||
}
|
||||
|
||||
type RouteCase = { pattern: string; path: string; final?: string; heading?: string; endpoint?: string; status?: number; public?: boolean };
|
||||
function routes(jobId: number, cvId: number): RouteCase[] {
|
||||
return [
|
||||
{ pattern: '/', path: '/', public: true },
|
||||
{ pattern: '/login', path: '/login', heading: t.signInTitle, public: true },
|
||||
{ pattern: '/register', path: '/register', heading: t.createAccount, public: true },
|
||||
{ pattern: '/forgot-password', path: '/forgot-password', heading: t.forgotPasswordTitle, public: true },
|
||||
{ pattern: '/reset-password', path: '/reset-password', heading: t.resetPasswordTitle, public: true },
|
||||
{ pattern: '/verify-email', path: '/verify-email', heading: t.verifyEmailTitle, public: true },
|
||||
{ pattern: '/confirm-email-change', path: '/confirm-email-change', heading: t.confirmEmailChangeTitle, public: true },
|
||||
{ pattern: '/microsoft-legacy-relink', path: '/microsoft-legacy-relink', heading: t.microsoftLegacyRelinkTitle, public: true },
|
||||
{ pattern: '/cv/:slug', path: '/cv/acceptance-unpublished-link', endpoint: '/public-cv/acceptance-unpublished-link', status: 404, public: true },
|
||||
{ pattern: '/dashboard', path: '/dashboard', heading: t.dashboard, endpoint: '/jobapplications/stats' },
|
||||
{ pattern: '/analytics', path: '/analytics?months=6', heading: t.analytics, endpoint: '/jobapplications/stats' },
|
||||
{ pattern: '/discover', path: '/discover', heading: t.discoverJobs, endpoint: '/job-discovery/saved-searches' },
|
||||
{ pattern: '/jobs', path: '/jobs?q=Acceptance', heading: t.jobApplications, endpoint: '/jobapplications' },
|
||||
{ pattern: '/jobs/:id', path: `/jobs/${jobId}?section=documents`, heading: t.jobDetails, endpoint: `/jobapplications/${jobId}/workspace` },
|
||||
{ pattern: '/reminders', path: '/reminders', heading: t.reminders, endpoint: '/jobapplications/reminders' },
|
||||
{ pattern: '/operations', path: '/operations', heading: t.operations, endpoint: '/operations' },
|
||||
{ pattern: '/kanban', path: '/kanban?q=Acceptance&status=Saved#board', final: '/jobs?q=Acceptance&status=Saved&view=board#board', heading: t.jobApplications, endpoint: '/jobapplications/board' },
|
||||
{ pattern: '/companies', path: '/companies', heading: t.companies, endpoint: '/companies' },
|
||||
{ pattern: '/offers', path: '/offers', heading: t.offerComparisonTitle, endpoint: '/jobapplications' },
|
||||
{ pattern: '/correspondence', path: '/correspondence', heading: t.correspondenceInboxTitle, endpoint: '/correspondence/page' },
|
||||
{ pattern: '/correspondence/review', path: '/correspondence/review?route=all#review', final: '/correspondence?view=review', heading: t.correspondenceInboxTitle, endpoint: '/gmail/review-candidates', status: 409 },
|
||||
{ pattern: '/profile', path: '/profile', heading: t.profile, endpoint: '/auth/me' },
|
||||
{ pattern: '/applications/:id', path: `/applications/${jobId}?section=documents&followMode=waiting-update#workspace`, final: `/jobs/${jobId}?section=documents&followMode=waiting-update`, heading: t.jobDetails, endpoint: `/jobapplications/${jobId}/workspace` },
|
||||
{ pattern: '/career', path: '/career?section=profile', heading: t.careerWorkspace, endpoint: '/career/profile' },
|
||||
{ pattern: '/career/builder', path: '/career/builder', heading: t.cvBuilder, endpoint: '/cv/variants' },
|
||||
{ pattern: '/career/builder/:id', path: `/career/builder/${cvId}`, heading: t.cvBuilder, endpoint: `/cv/variants/${cvId}` },
|
||||
{ pattern: '/admin/audit', path: '/admin/audit', heading: t.auditLog, endpoint: '/admin/audit' },
|
||||
{ pattern: '/admin/users', path: '/admin/users', heading: t.users, endpoint: '/users' },
|
||||
{ pattern: '/admin/system', path: '/admin/system', heading: t.systemStatus, endpoint: '/admin/system' },
|
||||
{ pattern: '/trash', path: '/trash', heading: t.trash, endpoint: '/jobapplications' },
|
||||
{ pattern: '/settings', path: '/settings', heading: t.settings, endpoint: '/notification-settings' },
|
||||
{ pattern: '/settings/connected-accounts', path: '/settings/connected-accounts', heading: t.connectedAccounts, endpoint: '/gmail/status' },
|
||||
];
|
||||
}
|
||||
|
||||
async function publicState(page: Page, row: RouteCase) {
|
||||
if (row.pattern === '/') await expect(page.getByRole('heading', { name: 'Two clear plans' })).toBeVisible();
|
||||
else if (row.pattern === '/cv/:slug') await expect(page.getByText(/This CV isn.t available/)).toBeVisible();
|
||||
else {
|
||||
await expect(page.getByRole('heading', { name: row.heading!, exact: true })).toBeVisible();
|
||||
const missingLinks: Record<string, string> = {
|
||||
'/reset-password': t.missingResetLinkInfo,
|
||||
'/verify-email': t.missingVerifyLinkInfo,
|
||||
'/confirm-email-change': t.missingEmailChangeLinkInfo,
|
||||
'/microsoft-legacy-relink': t.microsoftLegacyRelinkMissing,
|
||||
};
|
||||
const missingLink = missingLinks[row.pattern];
|
||||
if (missingLink) {
|
||||
await expect(page.getByRole('alert').filter({ hasText: missingLink })).toBeVisible();
|
||||
}
|
||||
if (row.pattern === '/register') await expect(page.getByRole('button', { name: t.createAccount, exact: true })).toBeDisabled();
|
||||
}
|
||||
}
|
||||
|
||||
test.beforeEach(async ({ context }) => {
|
||||
await context.addInitScript(() => localStorage.setItem('uiLanguage', 'en'));
|
||||
await context.route('**/*', route => {
|
||||
const url = new URL(route.request().url());
|
||||
return ['localhost', '127.0.0.1'].includes(url.hostname) || ['data:', 'blob:', 'about:'].includes(url.protocol) ? route.continue() : route.abort();
|
||||
});
|
||||
});
|
||||
|
||||
for (const identity of ['anonymous', 'normal', 'admin'] as const) {
|
||||
test(`[P5-15-01 E02 E07 A01] all 32 routes retain native entry, refresh and ${identity} permissions`, async ({ page, playwright }, info) => {
|
||||
test.setTimeout(300_000);
|
||||
const admin = await playwright.request.newContext();
|
||||
const evidence: { pattern: string; mode: string; destination: string; status?: number }[] = [];
|
||||
try {
|
||||
const adminHeaders = await login(admin, 'e2e@example.test');
|
||||
const email = await createAccount(admin, adminHeaders, identity === 'admin' ? ['Admin'] : []);
|
||||
let jobId = 2147483647;
|
||||
let cvId = 2147483647;
|
||||
if (identity !== 'anonymous') {
|
||||
const headers = await login(page.request, email);
|
||||
const company = await page.request.post(`${apiUrl}/companies`, { headers, data: { name: 'Acceptance company' } });
|
||||
expect(company.status()).toBe(201);
|
||||
const job = await page.request.post(`${apiUrl}/jobapplications`, { headers, data: { companyId: (await company.json()).id, jobTitle: 'Acceptance engineer', status: 'Saved' } });
|
||||
expect(job.status()).toBe(201); jobId = (await job.json()).id;
|
||||
const cv = await page.request.post(`${apiUrl}/cv/variants`, { headers, data: { name: 'Acceptance CV' } });
|
||||
expect(cv.status()).toBe(200); cvId = (await cv.json()).id;
|
||||
}
|
||||
const census = routes(jobId, cvId);
|
||||
expect(new Set(census.map(row => row.pattern)).size).toBe(32);
|
||||
for (const row of census) {
|
||||
await test.step(row.pattern, async () => {
|
||||
for (const mode of ['entry', 'refresh']) {
|
||||
const protectedAnonymous = identity === 'anonymous' && !row.public;
|
||||
const endpoint = protectedAnonymous ? undefined : row.endpoint;
|
||||
const response = endpoint ? page.waitForResponse(response => new URL(response.url()).pathname === `/api${endpoint}` && response.request().method() === 'GET', { timeout: 20_000 }) : undefined;
|
||||
if (mode === 'entry') await page.goto(row.path); else await page.reload();
|
||||
const destination = protectedAnonymous ? '/' : row.pattern === '/' && identity !== 'anonymous' ? '/dashboard' : row.final ?? row.path;
|
||||
await expect(page).toHaveURL(url => `${url.pathname}${url.search}${url.hash}` === destination);
|
||||
const native = response ? await response : undefined;
|
||||
evidence.push({ pattern: row.pattern, mode, destination, status: native?.status() });
|
||||
if (native) {
|
||||
const denied = identity === 'normal' && row.pattern.startsWith('/admin/');
|
||||
// Finish the route census even when one native read exposes a defect.
|
||||
expect.soft(native.status()).toBe(denied ? 403 : row.status ?? 200);
|
||||
if (row.pattern === '/correspondence/review') {
|
||||
expect(await native.json()).toMatchObject({ code: 'gmail_not_connected' });
|
||||
await expect(page.getByRole('alert').filter({ hasText: 'Connect Gmail before using the Gmail review queue.' })).toBeVisible();
|
||||
}
|
||||
if (denied) {
|
||||
await expect(page.getByRole('alert').first()).toBeVisible();
|
||||
await expect(page.getByText('e2e@example.test', { exact: true })).toHaveCount(0);
|
||||
}
|
||||
}
|
||||
if (protectedAnonymous) {
|
||||
await publicState(page, census[0]);
|
||||
await expect(page.getByRole('main')).toHaveCount(0);
|
||||
} else if (row.public && !(row.pattern === '/' && identity !== 'anonymous')) await publicState(page, row);
|
||||
else {
|
||||
await expect(page.getByRole('heading', { level: 1, name: row.pattern === '/' ? t.dashboard : row.heading!, exact: true })).toBeVisible();
|
||||
if (['/jobs/:id', '/applications/:id'].includes(row.pattern)) await expect(page.getByRole('heading', { name: 'Acceptance engineer', exact: true })).toBeVisible();
|
||||
if (row.pattern === '/career/builder/:id') await expect(page.getByLabel('CV name', { exact: true })).toHaveValue('Acceptance CV');
|
||||
if (row.pattern === '/jobs') await expect(page.locator(`[data-job-row-id="${jobId}"]`)).toBeVisible();
|
||||
if (row.pattern === '/kanban') await expect(page.locator(`[data-kanban-card-id="${jobId}"]`)).toBeVisible();
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
if (identity === 'anonymous') {
|
||||
await page.goto('/analytics?months=6');
|
||||
await expect(page).toHaveURL(/\/$/);
|
||||
await page.getByRole('button', { name: 'Sign in', exact: true }).first().click();
|
||||
await page.getByLabel('Username or email', { exact: true }).fill(email);
|
||||
await page.getByLabel('Current password', { exact: true }).fill(password);
|
||||
await page.getByRole('button', { name: 'Sign in', exact: true }).click();
|
||||
await expect(page).toHaveURL(/\/analytics\?months=6$/);
|
||||
await expect(page.getByRole('button', { name: '6 mo', exact: true })).toHaveAttribute('aria-pressed', 'true');
|
||||
} else {
|
||||
await page.goto('/dashboard');
|
||||
await page.getByRole('button', { name: t.jobApplications, exact: true }).click();
|
||||
await expect(page).toHaveURL(/\/jobs$/);
|
||||
await page.goBack(); await expect(page).toHaveURL(/\/dashboard$/);
|
||||
await page.goForward(); await expect(page).toHaveURL(/\/jobs$/);
|
||||
await expect(page.getByTestId('admin-version-badge')).toHaveCount(identity === 'admin' ? 1 : 0);
|
||||
}
|
||||
} finally {
|
||||
await info.attach(`route-census-${identity}`, { body: JSON.stringify(evidence, null, 2), contentType: 'application/json' });
|
||||
await admin.dispose();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
test('[P5-15-01 W01 C07 A01] foreign and missing workspace deep links deny data through entry and refresh', async ({ page, playwright }, info) => {
|
||||
test.setTimeout(90_000);
|
||||
const admin = await playwright.request.newContext();
|
||||
const owner = await playwright.request.newContext();
|
||||
const evidence: { kind: string; mode: string; status: number }[] = [];
|
||||
try {
|
||||
const adminHeaders = await login(admin, 'e2e@example.test');
|
||||
const ownerHeaders = await login(owner, await createAccount(admin, adminHeaders));
|
||||
const company = await owner.post(`${apiUrl}/companies`, { headers: ownerHeaders, data: { name: 'Foreign acceptance company' } });
|
||||
expect(company.status()).toBe(201);
|
||||
const job = await owner.post(`${apiUrl}/jobapplications`, { headers: ownerHeaders, data: { companyId: (await company.json()).id, jobTitle: 'Foreign acceptance engineer', status: 'Saved' } });
|
||||
expect(job.status()).toBe(201);
|
||||
const jobId = (await job.json()).id;
|
||||
const cv = await owner.post(`${apiUrl}/cv/variants`, { headers: ownerHeaders, data: { name: 'Foreign acceptance CV' } });
|
||||
expect(cv.status()).toBe(200);
|
||||
const cvId = (await cv.json()).id;
|
||||
await login(page.request, await createAccount(admin, adminHeaders));
|
||||
for (const [kind, id, editor] of [['foreign-job', jobId, false], ['missing-job', 2147483647, false], ['foreign-cv', cvId, true], ['missing-cv', 2147483647, true]] as const) {
|
||||
for (const mode of ['entry', 'refresh']) {
|
||||
const endpoint = editor ? `/cv/variants/${id}` : `/jobapplications/${id}/workspace`;
|
||||
const response = page.waitForResponse(response => new URL(response.url()).pathname === `/api${endpoint}` && response.request().method() === 'GET');
|
||||
if (mode === 'entry') await page.goto(editor ? `/career/builder/${id}` : `/applications/${id}?section=documents&followMode=waiting-update`); else await page.reload();
|
||||
const status = (await response).status();
|
||||
evidence.push({ kind, mode, status });
|
||||
expect(status).toBe(404);
|
||||
await expect(page.getByRole('alert').first()).toBeVisible();
|
||||
await expect(page.getByText('Foreign acceptance engineer', { exact: true })).toHaveCount(0);
|
||||
if (editor) await expect(page.getByLabel('CV name', { exact: true })).toHaveCount(0);
|
||||
else {
|
||||
await expect(page).toHaveURL(new RegExp(`/jobs/${id}\\?section=documents&followMode=waiting-update$`));
|
||||
await expect(page.getByRole('button', { name: 'Back to applications', exact: true })).toBeVisible();
|
||||
}
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
await info.attach('foreign-missing-route-denial', { body: JSON.stringify(evidence, null, 2), contentType: 'application/json' });
|
||||
await owner.dispose(); await admin.dispose();
|
||||
}
|
||||
});
|
||||
|
||||
test('[P5-15-01 J05 J07] a finite 250-job Board preserves the complete matching Table population', async ({ page, playwright }, info) => {
|
||||
test.setTimeout(240_000);
|
||||
const admin = await playwright.request.newContext();
|
||||
try {
|
||||
const adminHeaders = await login(admin, 'e2e@example.test');
|
||||
const headers = await login(page.request, await createAccount(admin, adminHeaders));
|
||||
const company = await page.request.post(`${apiUrl}/companies`, { headers, data: { name: 'Finite Board acceptance company' } });
|
||||
expect(company.status()).toBe(201);
|
||||
const companyId = (await company.json()).id;
|
||||
const seededIds: number[] = [];
|
||||
for (let index = 0; index < 250; index++) {
|
||||
const created = await page.request.post(`${apiUrl}/jobapplications`, { headers, data: { companyId, jobTitle: `Finite Board ${String(index).padStart(3, '0')}`, status: 'Saved' } });
|
||||
expect(created.status()).toBe(201);
|
||||
seededIds.push((await created.json()).id);
|
||||
}
|
||||
// Reuse the existing Board/Table read contract: full Board vs Table's normal 15-row pages.
|
||||
const query = 'q=Finite%20Board&status=Saved';
|
||||
const tableIds: number[] = [];
|
||||
for (let number = 1; tableIds.length < seededIds.length; number++) {
|
||||
const response = await page.request.get(`${apiUrl}/jobapplications?${query}&page=${number}&pageSize=15`);
|
||||
expect(response.status()).toBe(200);
|
||||
const table = await response.json();
|
||||
expect(table.total).toBe(seededIds.length);
|
||||
expect(table.items.length).toBeGreaterThan(0);
|
||||
tableIds.push(...table.items.map((item: { id: number }) => item.id));
|
||||
}
|
||||
expect(new Set(tableIds).size).toBe(250);
|
||||
expect([...tableIds].sort((a, b) => a - b)).toEqual([...seededIds].sort((a, b) => a - b));
|
||||
let boardRequests = 0;
|
||||
page.on('request', request => { if (new URL(request.url()).pathname === '/api/jobapplications/board' && request.method() === 'GET') boardRequests++; });
|
||||
const read = page.waitForResponse(response => new URL(response.url()).pathname === '/api/jobapplications/board' && response.request().method() === 'GET');
|
||||
const started = Date.now();
|
||||
await page.goto(`/jobs?${query}&view=board`);
|
||||
const boardResponse = await read;
|
||||
expect(boardResponse.status()).toBe(200);
|
||||
const bytes = await boardResponse.body();
|
||||
const board = JSON.parse(bytes.toString()) as { id: number }[];
|
||||
expect(board.map(item => item.id).sort((a, b) => a - b)).toEqual([...tableIds].sort((a, b) => a - b));
|
||||
await expect(page.locator('[data-kanban-card-id]')).toHaveCount(250);
|
||||
const renderedIds = await page.locator('[data-kanban-card-id]').evaluateAll(cards => cards.map(card => Number(card.getAttribute('data-kanban-card-id'))));
|
||||
expect(renderedIds.sort((a, b) => a - b)).toEqual([...tableIds].sort((a, b) => a - b));
|
||||
await info.attach('finite-250-board-observation', { body: JSON.stringify({ fixtureRows: 250, matchedRows: board.length, tableRows: tableIds.length,
|
||||
renderedRows: renderedIds.length, boardRequests, responseBytes: bytes.length, cardPopulationReadyMs: Date.now() - started,
|
||||
limitation: 'Finite functional observation only; no capacity or latency acceptance threshold, and full-population O(N) debt remains.' }, null, 2), contentType: 'application/json' });
|
||||
} finally { await admin.dispose(); }
|
||||
});
|
||||
Reference in New Issue
Block a user