test(redesign): expose native P5-15-01 audit failures

This commit is contained in:
cesnimda
2026-10-09 13:48:21 +02:00
parent 4c52b57123
commit 266e53c16a
5 changed files with 2312 additions and 0 deletions
@@ -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