fix(ui): preserve inbox context and review recovery

This commit is contained in:
cesnimda
2026-10-14 07:14:50 +02:00
parent 4c3254c472
commit a791e353b0
19 changed files with 2852 additions and 227 deletions
@@ -0,0 +1,318 @@
import AxeBuilder from '@axe-core/playwright';
import { expect, test as base, type APIRequestContext, type APIResponse, type Locator, type Page, type Response, type TestInfo } from '@playwright/test';
import { translations } from '../src/i18n/translations';
import type { CorrespondenceInboxItem } from '../src/views/CorrespondenceInboxPage';
import type { GmailReviewQueueResponse, GmailReviewThread, GmailSuggestedJobsResponse } from '../src/types';
const apiUrl = 'http://localhost:5302/api';
const password = 'E2ePassword123!';
type Owner = { id: string; headers: Record<string, string> };
type Job = { id: number; companyId: number; jobTitle: string };
type Message = { id: number; jobApplicationId: number; subject: string; content: string; provider: string; externalMessageId: string | null; externalThreadId: string | null };
type Inbox = { items: CorrespondenceInboxItem[]; page: number; pageSize: number; total: number; totalPages: number };
type Detail = { id: string; threadId: string; subject: string; from: string; to: string; snippet: string; bodyText: string; labels: string[]; attachments: { fileName: string }[] };
type Sample = { language: 'en' | 'nb'; theme: 'light' | 'dark'; width: number; height: number };
type RequestRecord = { method: string; path: string; query: Record<string, string>; body: unknown };
type Session = { owner: Owner; administrator: Owner; admin: APIRequestContext; requests: RequestRecord[] };
const desktop: Sample = { language: 'en', theme: 'light', width: 1440, height: 900 };
const boundary = 'Actual owner correspondence POST/page/message/job reads and local nonlinked review-decision POST use disposable isolated SQLite. Provider status/live-copy and paired Gmail queue reads are explicitly controlled local HTTP fixtures; no Google/Outlook/IMAP connection, provider import, generation/model, sync, create-suggested-job, unlink, compose/draft/save/send or delivery request is executed. Manual correspondence DTOs remain provider=manual on the real server. One provider-copy case changes only the browser projection discriminator for one actual-owned row to exercise live-copy fallback; stored body/detail/readback remain actual owner HTTP. Review queue projections are not provider qualification or independent persistence readback. Free accounts and explicit runtime worker disables prevent model/delivery. No raw SQL/schema/production writes; request receipts exclude credentials/CSRF/session headers. Finite all-enabled axe/target-size/keyboard images do not replace human reader, device, real zoom, font/photo or production evidence.';
function interpolate(template: string, params: Record<string, string | number>) { return template.replace(/\{(\w+)\}/g, (_, key: string) => String(params[key] ?? `{${key}}`)); }
function hold() { let release!: () => void; const pending = new Promise<void>(resolve => { release = resolve; }); return { pending, release }; }
async function json<T>(response: APIResponse | Response, status = 200): Promise<T> { expect(response.status(), response.url()).toBe(status); return response.json(); }
function waitResponse(page: Page, method: string, path: string) { return page.waitForResponse(response => response.request().method() === method && new URL(response.url()).pathname === path); }
function count(session: Session, method: string, path: string) { return session.requests.filter(request => request.method === method && request.path === path); }
function readRequests(session: Session) { return session.requests.filter(request => request.method === 'GET' && /^\/api\/(?:correspondence|email\/providers|email\/message|gmail\/review-candidates|gmail\/suggested-jobs)(?:\/|$)/.test(request.path)); }
function noDelivery(session: Session) { expect(session.requests.filter(request => request.method !== 'GET' && /^\/api\/(?:email\/|gmail\/(?:manual-sync|create-suggested-job|unlink-thread)|ai(?:\/|$))/.test(request.path))).toEqual([]); }
async function keyboard(page: Page, locator: Locator) { await locator.focus(); await expect(locator).toBeFocused(); await page.keyboard.press('Enter'); }
function inbox(page: Page, language: Sample['language'] = 'en') { return page.getByRole('region', { name: translations[language].correspondenceInbox, exact: true }); }
function row(page: Page, subject: string) { return page.getByRole('article', { name: subject, exact: true }); }
function review(page: Page, language: Sample['language'] = 'en') { return page.getByRole('region', { name: translations[language].gmailReviewEmbeddedTitle, exact: true }); }
function detail(page: Page, subject: string, language: Sample['language'] = 'en') { return row(page, subject).getByRole('group', { name: translations[language].emailInboxMessage, exact: true }); }
function labelPrefix(label: string) { return new RegExp(`^${label.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}(?:\\s|$)`); }
async function select(page: Page, label: string, option: string) { await keyboard(page, page.getByRole('combobox', { name: labelPrefix(label) })); await keyboard(page, page.getByRole('option', { name: option, exact: true })); }
async function pageOwnedFocus(section: Locator) {
await expect.poll(() => section.evaluate(node => node.contains(document.activeElement) && document.activeElement !== document.body && document.activeElement?.isConnected === true)).toBe(true);
}
async function login(api: APIRequestContext, email: string): Promise<Owner> {
expect((await api.post(`${apiUrl}/auth/login`, { data: { email, password } })).status()).toBe(200);
const me = await json<{ id: string; sessionContext: string }>(await api.get(`${apiUrl}/auth/me`));
const token = (await api.storageState()).cookies.find(cookie => cookie.name === 'XSRF-TOKEN'); expect(token).toBeDefined();
return { id: me.id, headers: { 'X-CSRF-TOKEN': token!.value, 'X-Session-Context': me.sessionContext } };
}
async function account(admin: APIRequestContext, owner: Owner) {
const email = `p706-read-${Date.now()}-${Math.random().toString(36).slice(2)}@example.test`;
expect((await admin.post(`${apiUrl}/users`, { headers: owner.headers, data: { email, password, roles: [] } })).status()).toBe(200); return email;
}
const test = base.extend<{ session: Session }>({
session: async ({ page, context, playwright }, provide, info) => {
expect(process.env.PLAYWRIGHT_REUSE_SERVERS).not.toBe('true'); expect(info.config.metadata.isolatedDataRoot).toEqual(expect.any(String));
const admin = await playwright.request.newContext(); const requests: RequestRecord[] = []; const blocked: string[] = [];
page.on('request', request => {
const url = new URL(request.url());
if (!/^\/api\/(?:correspondence|email|gmail|ai|jobapplications)(?:\/|$)/.test(url.pathname)) return;
requests.push({ method: request.method(), path: url.pathname, query: Object.fromEntries(url.searchParams), body: request.postData() ? request.postDataJSON() : null });
});
try {
const administrator = await login(admin, 'e2e@example.test'); const owner = await login(page.request, await account(admin, administrator));
expect((await json<{ entitlements: { ai: boolean } }>(await page.request.get(`${apiUrl}/auth/me`))).entitlements.ai).toBe(false);
await context.route('**/*', route => { const url = new URL(route.request().url());
if (['localhost', '127.0.0.1'].includes(url.hostname) || ['blob:', 'data:', 'about:'].includes(url.protocol)) return route.continue();
blocked.push(url.origin + url.pathname); return route.abort();
});
await provide({ owner, administrator, admin, requests });
} finally { await info.attach('correspondence-read-boundary', { body: JSON.stringify({ boundary, requests, blockedExternalRequests: blocked }), contentType: 'application/json' }); await admin.dispose(); }
},
});
async function job(api: APIRequestContext, owner: Owner, title = 'Recorded correspondence job — æøå', ownedCompanyId?: number) {
const companyId = ownedCompanyId ?? (await json<{ id: number }>(await api.post(`${apiUrl}/companies`, { headers: owner.headers, data: { name: 'Recorded correspondence company — æøå' } }), 201)).id;
return json<Job>(await api.post(`${apiUrl}/jobapplications`, { headers: owner.headers, data: { companyId, jobTitle: title, status: 'Saved' } }), 201);
}
async function message(api: APIRequestContext, owner: Owner, jobId: number, subject: string, index = 0) {
const content = `Saved full plain-text body for ${subject} — æøå\n` + ('Recorded owner text; preserve exact saved copy and line breaks.\n').repeat(8) + '<script>fixture marker stays plain text</script>';
return json<Message>(await api.post(`${apiUrl}/correspondence`, { headers: owner.headers, data: {
jobApplicationId: jobId, from: index % 3 === 0 ? 'Company' : 'Me', subject, content, channel: 'Email',
date: '2026-10-13T08:00:00Z', direction: ['inbound', 'outbound', 'internal'][index % 3],
externalMessageId: index % 2 === 0 ? `recorded-message-${jobId}-${index}` : null, externalThreadId: index % 2 === 0 ? `recorded-thread-${jobId}-${index}` : null,
externalFrom: 'Recorded recruiter <recruiter@example.test>', externalTo: 'recorded-owner@example.test',
externalLabelsJson: JSON.stringify(['RECORDED', 'Owner label']), attachmentMetadataJson: JSON.stringify([{ FileName: 'Recorded metadata — æøå.txt', MimeType: 'text/plain', SizeBytes: 64, Inline: false }]),
} }), 201);
}
async function seed(page: Page, owner: Owner, number = 3, prefix = 'Recorded message') {
const parent = await job(page.request, owner); const messages: Message[] = [];
for (let index = 0; index < number; index++) messages.push(await message(page.request, owner, parent.id, `${prefix} ${String(index).padStart(2, '0')} — æøå`, index));
return { job: parent, messages };
}
async function pageRead(api: APIRequestContext, params: Record<string, string | number> = {}) { return json<Inbox>(await api.get(`${apiUrl}/correspondence/page`, { params: { page: 1, pageSize: 50, ...params } })); }
async function enter(page: Page, owner: Owner, sample: Sample = desktop, path = '/correspondence?source=phase7-read&marker=preserved#inbox') {
expect((await page.request.put(`${apiUrl}/auth/preferences/language`, { headers: owner.headers, data: { language: sample.language === 'nb' ? 'nb-NO' : 'en' } })).status()).toBe(204);
await page.addInitScript(({ language, theme }) => { if (localStorage.getItem('uiLanguage') === null) localStorage.setItem('uiLanguage', language);
if (localStorage.getItem('jobtracker.themeMode') === null) localStorage.setItem('jobtracker.themeMode', theme); }, sample);
await page.setViewportSize({ width: sample.width, height: sample.height }); await page.emulateMedia({ reducedMotion: 'reduce' }); await page.goto(path);
const stored = await page.evaluate(() => ({ language: localStorage.getItem('uiLanguage'), theme: localStorage.getItem('jobtracker.themeMode') }));
if (stored.language !== sample.language || stored.theme !== sample.theme) { await page.evaluate(({ language, theme }) => { localStorage.setItem('uiLanguage', language); localStorage.setItem('jobtracker.themeMode', theme); }, sample); await page.reload(); }
await expect(page.locator('html')).toHaveAttribute('lang', sample.language === 'nb' ? 'nb-NO' : 'en'); await expect(page.locator('html')).toHaveAttribute('data-color-scheme', sample.theme);
await expect(page.getByRole('heading', { level: 1 })).toHaveCount(1); await expect(inbox(page, sample.language).getByRole('heading', { level: 2, name: translations[sample.language].correspondenceInbox, exact: true })).toBeVisible();
}
async function show(page: Page, value: Message, language: Sample['language'] = 'en') { await keyboard(page, row(page, value.subject).getByRole('button', { name: translations[language].emailInboxViewMessage, exact: true })); }
async function capture(page: Page, info: TestInfo, sample: Sample, state: string) {
await page.evaluate(async () => { await document.fonts.ready; await new Promise<void>(resolve => requestAnimationFrame(() => requestAnimationFrame(() => resolve()))); });
const geometry = await page.evaluate(() => ({ width: innerWidth, height: innerHeight, rootOverflow: Math.max(0, document.documentElement.scrollWidth - document.documentElement.clientWidth),
focused: { tag: document.activeElement?.tagName, role: document.activeElement?.getAttribute('role'), name: document.activeElement?.getAttribute('aria-label') } }));
const axe = await new AxeBuilder({ page }).options({ rules: { 'target-size': { enabled: true } } }).analyze();
const name = `p706-read-${sample.language}-${sample.theme}-${sample.width}-${state}`;
await info.attach(`${name}-audit`, { body: JSON.stringify({ sample, state, geometry, boundary, ariaSnapshot: await page.locator('body').ariaSnapshot(),
axe: { scope: 'All default-enabled rules plus target-size, no exclusions', violations: axe.violations, incomplete: axe.incomplete, passes: axe.passes.map(rule => ({ id: rule.id, nodes: rule.nodes.length })) },
qualifications: ['Finite isolated Chromium states, not all language/theme/width/state combinations.', 'All incomplete vectors retained; no human AT, physical touch/software keyboard, real zoom or precise screenshot contrast acceptance.',
'Full-page images retain context but may be auto-resized and do not prove real viewport focus visibility.', 'Provider/review fixtures are expressly local projections; no live provider/model/send qualification.'],
}), contentType: 'application/json' });
await info.attach(name, { body: await page.screenshot({ fullPage: true, animations: 'disabled' }), contentType: 'image/png' });
expect(geometry.rootOverflow).toBe(0); expect(axe.violations).toEqual([]);
}
function reviewFixture(parent: Job, long = false) {
const threads: GmailReviewThread[] = ['review', 'unmatched'].map((routing, index) => ({
threadId: `recorded-review-${parent.id}-${index}`, subject: long ? `Lang kontrolltråd om samfunnskritiske tjenester og inkluderende arbeidsprosesser Æ Ø Å ${index}` : `Recorded review thread ${index} — æøå`,
latestDate: '2026-10-13T08:00:00Z', messageCount: 2, routing, hasImportedMessages: false, decisionNote: `Previously recorded review note ${index}`,
matchedQueries: ['Recorded source query; no provider search'], jobCandidates: [{ jobApplicationId: parent.id, jobTitle: parent.jobTitle, companyName: 'Recorded correspondence company — æøå', score: 24, confidence: 'medium', reasons: [] }], messages: [],
}));
const queue: GmailReviewQueueResponse = { queries: ['Recorded source query; no provider search'], candidateThreadCount: 2, autoLinkThreadCount: 0, reviewThreadCount: 1, unmatchedThreadCount: 1, threads };
const suggestions: GmailSuggestedJobsResponse = { count: 0, items: [] };
return { queue, suggestions, threads };
}
async function controlledReview(page: Page, fixture: ReturnType<typeof reviewFixture>) {
await page.route(`${apiUrl}/gmail/review-candidates`, route => route.fulfill({ status: 200, json: fixture.queue }));
await page.route(`${apiUrl}/gmail/suggested-jobs`, route => route.fulfill({ status: 200, json: fixture.suggestions }));
}
test('[P7-06 read] actual owner page50, filters, ordering and job Back preserve saved-message context and ownership', async ({ page, session, playwright }, info) => {
test.setTimeout(180_000); const seeded = await seed(page, session.owner, 55);
const removedJob = await job(page.request, session.owner, 'Deleted-parent messages stay absent', seeded.job.companyId); const removed = await message(page.request, session.owner, removedJob.id, 'Deleted-parent private body');
expect((await page.request.delete(`${apiUrl}/jobapplications/${removedJob.id}`, { headers: session.owner.headers })).status()).toBe(204);
const foreign = await playwright.request.newContext(); const anonymous = await playwright.request.newContext();
try {
const other = await login(foreign, await account(session.admin, session.administrator)); const otherJob = await job(foreign, other, 'Other owner job'); const otherMessage = await message(foreign, other, otherJob.id, 'Other owner private body');
expect((await page.request.get(`${apiUrl}/correspondence/message/${otherMessage.id}`)).status()).toBe(404); expect((await foreign.get(`${apiUrl}/correspondence/message/${seeded.messages[0].id}`)).status()).toBe(404); expect((await anonymous.get(`${apiUrl}/correspondence/page`)).status()).toBe(401);
// Trashing an owned job preserves its saved correspondence; the existing
// owner-only message/page contract does not filter the parent's IsDeleted.
const ownedIds = [...seeded.messages.map(value => value.id), removed.id].sort((a, b) => b - a);
expect(await json<Detail>(await page.request.get(`${apiUrl}/correspondence/message/${removed.id}`))).toMatchObject({ bodyText: removed.content, subject: removed.subject });
const first = await pageRead(page.request); expect(first.total).toBe(56); expect(first.pageSize).toBe(50); expect(first.items.map(value => value.id)).toEqual(ownedIds.slice(0, 50));
expect(first.items.every(value => value.provider === 'manual')).toBe(true);
await enter(page, session.owner); await expect(inbox(page).getByRole('article')).toHaveCount(50); await expect(page.getByText(interpolate(translations.en.emailInboxItems, { count: 56 }), { exact: true })).toBeVisible();
await expect(row(page, removed.subject)).toBeVisible(); await expect(row(page, otherMessage.subject)).toHaveCount(0);
const pageTwo = waitResponse(page, 'GET', '/api/correspondence/page'); await keyboard(page, page.getByRole('navigation', { name: translations.en.emailInboxPages, exact: true }).getByRole('button', { name: 'Go to page 2', exact: true }));
const second = await json<Inbox>(await pageTwo); expect(second.page).toBe(2); expect(second.total).toBe(56); expect(second.items.map(value => value.id)).toEqual(ownedIds.slice(50)); expect(second.items).toHaveLength(6); await expect(inbox(page).getByRole('article')).toHaveCount(6);
await expect(page.getByText(interpolate(translations.en.emailInboxLinkedOnPage, { count: second.items.filter(value => value.externalThreadId).length }), { exact: true })).toBeVisible();
const selected = second.items[0]; const loaded = waitResponse(page, 'GET', `/api/jobapplications/${selected.jobApplicationId}/workspace`);
await keyboard(page, row(page, selected.subject!).getByRole('button', { name: translations.en.emailInboxOpenJob, exact: true }));
await expect(page).toHaveURL(new RegExp(`/jobs/${selected.jobApplicationId}\\?section=communication`)); expect(await json<{ id: number }>(await loaded)).toMatchObject({ id: selected.jobApplicationId });
await page.goBack(); await expect(inbox(page).getByRole('article')).toHaveCount(6);
expect(new URL(page.url()).searchParams.get('source')).toBe('phase7-read'); expect(new URL(page.url()).searchParams.get('marker')).toBe('preserved'); expect(new URL(page.url()).hash).toBe('#inbox');
await expect(row(page, selected.subject!)).toBeVisible();
await page.getByRole('textbox', { name: translations.en.correspondenceSearch, exact: true }).fill('Recorded message');
await select(page, translations.en.emailInboxDirection, translations.en.emailInboxOutbound); await select(page, translations.en.emailInboxLinkState, translations.en.emailInboxLinkedThreads);
const filtered = await pageRead(page.request, { q: 'Recorded message', direction: 'outbound', linkState: 'linked' });
await expect(inbox(page).getByRole('article')).toHaveCount(filtered.items.length);
expect(count(session, 'GET', '/api/correspondence/page').at(-1)?.query).toMatchObject({ q: 'Recorded message', direction: 'outbound', linkState: 'linked', page: '1', pageSize: '50' });
for (const value of filtered.items) await expect(row(page, value.subject!)).toBeVisible();
await capture(page, info, desktop, 'real-filtered-owner-page'); noDelivery(session);
await info.attach('correspondence-real-page', { body: JSON.stringify({ boundary, first: { total: first.total, pageSize: first.pageSize, ids: first.items.map(value => value.id) }, second: { page: second.page, ids: second.items.map(value => value.id) },
filtered: { total: filtered.total, ids: filtered.items.map(value => value.id) }, qualification: 'Actual saved manual metadata and SQL page/filter/order boundaries. Linked/inbound chips describe the currently shown page; provider=manual is not a live mailbox.' }), contentType: 'application/json' });
} finally { await foreign.dispose(); await anonymous.dispose(); }
});
test('[P7-06 read] initial503 Retry and held latest query remain distinct from true empty results', async ({ page, session }, info) => {
test.setTimeout(120_000); const seeded = await seed(page, session.owner, 2, 'Latest query'); const firstGate = hold(); const olderGate = hold(); const newerGate = hold(); let initialReads = 0;
await page.route(`${apiUrl}/correspondence/page?**`, async route => {
const q = new URL(route.request().url()).searchParams.get('q');
if (q === 'Latest query 00') { await olderGate.pending; return route.continue(); }
if (q === 'Latest query 01') { await newerGate.pending; return route.continue(); }
initialReads += 1; if (initialReads === 1) return route.fulfill({ status: 503, json: {} });
await firstGate.pending; return route.continue();
});
try {
await enter(page, session.owner); await expect(inbox(page).getByRole('alert').filter({ hasText: translations.en.emailInboxLoadFailed })).toBeVisible();
await expect(inbox(page).getByText(translations.en.emailInboxNoMatches, { exact: true })).toHaveCount(0); await capture(page, info, desktop, 'initial-list503-not-empty');
await keyboard(page, inbox(page).getByRole('button', { name: translations.en.retry, exact: true })); await expect.poll(() => initialReads).toBe(2); await pageOwnedFocus(inbox(page));
await expect(inbox(page).getByRole('group', { name: translations.en.emailInboxLinkedMessages, exact: true }).getByRole('status').filter({ hasText: translations.en.loading })).toContainText(translations.en.loading); firstGate.release(); await expect(inbox(page).getByRole('article')).toHaveCount(2);
const search = page.getByRole('textbox', { name: translations.en.correspondenceSearch, exact: true });
await search.fill('Latest query 00'); await expect.poll(() => count(session, 'GET', '/api/correspondence/page').filter(value => value.query.q === 'Latest query 00').length).toBe(1);
await search.fill('Latest query 01'); await expect.poll(() => count(session, 'GET', '/api/correspondence/page').filter(value => value.query.q === 'Latest query 01').length).toBe(1);
await expect(row(page, seeded.messages[0].subject)).toHaveCount(0); await expect(row(page, seeded.messages[1].subject)).toHaveCount(0);
await expect(inbox(page).getByText(translations.en.emailInboxNoMatches, { exact: true })).toHaveCount(0); await capture(page, info, desktop, 'held-current-query-no-stale-rows');
newerGate.release(); await expect(row(page, seeded.messages[1].subject)).toBeVisible();
const older = page.waitForResponse(response => new URL(response.url()).pathname === '/api/correspondence/page' && new URL(response.url()).searchParams.get('q') === 'Latest query 00'); olderGate.release(); expect((await older).status()).toBe(200);
await expect(row(page, seeded.messages[0].subject)).toHaveCount(0); await expect(row(page, seeded.messages[1].subject)).toBeVisible(); await expect(search).toHaveValue('Latest query 01');
await search.fill('No matching saved fixture'); await expect(inbox(page).getByText(translations.en.emailInboxNoMatches, { exact: true })).toBeVisible(); noDelivery(session);
} finally { firstGate.release(); olderGate.release(); newerGate.release(); }
});
test('[P7-06 read] actual saved-detail Retry preserves its ID and Hide/another row cancel stale completion', async ({ page, session }, info) => {
test.setTimeout(120_000); const seeded = await seed(page, session.owner, 2, 'Saved detail'); await enter(page, session.owner); await expect(inbox(page).getByRole('article')).toHaveCount(2);
const first = seeded.messages[0]; const second = seeded.messages[1]; const gate = hold(); let attempts = 0;
await page.route(`${apiUrl}/correspondence/message/${first.id}`, async route => { attempts += 1; if (attempts === 1) return route.fulfill({ status: 503, json: {} }); await gate.pending; return route.continue(); });
try {
await show(page, first); await expect(row(page, first.subject).getByRole('alert')).toContainText(translations.en.emailInboxMessageLoadFailed);
const listReads = count(session, 'GET', '/api/correspondence/page').length; await keyboard(page, row(page, first.subject).getByRole('button', { name: translations.en.retry, exact: true }));
await expect.poll(() => attempts).toBe(2); await capture(page, info, desktop, 'held-original-saved-detail-retry');
await keyboard(page, row(page, first.subject).getByRole('button', { name: translations.en.emailInboxHideMessage, exact: true }));
await show(page, second); await expect(detail(page, second.subject)).toContainText(second.content);
const late = waitResponse(page, 'GET', `/api/correspondence/message/${first.id}`); gate.release(); expect((await late).status()).toBe(200);
await expect(detail(page, first.subject)).toHaveCount(0); await expect(detail(page, second.subject)).toContainText(second.content);
expect(count(session, 'GET', '/api/correspondence/page')).toHaveLength(listReads);
await show(page, first); await expect(detail(page, first.subject)).toContainText(first.content); await expect(detail(page, first.subject)).toContainText('Owner label'); await expect(detail(page, first.subject)).toContainText('Recorded metadata — æøå.txt');
await expect(row(page, first.subject).locator('script')).toHaveCount(0); expect(await json<Detail>(await page.request.get(`${apiUrl}/correspondence/message/${first.id}`))).toMatchObject({ bodyText: first.content });
await capture(page, info, desktop, 'actual-saved-body-and-metadata'); noDelivery(session);
} finally { gate.release(); }
});
test('[P7-06 read] qualified provider live-copy failure retries exact captured source before real saved fallback', async ({ page, session }, info) => {
test.setTimeout(120_000); const seeded = await seed(page, session.owner, 1, 'Provider branch'); const saved = seeded.messages[0];
await page.route(`${apiUrl}/email/providers`, route => route.fulfill({ status: 200, json: [{ provider: 'gmail', displayName: 'Gmail', connected: true, address: 'controlled-readonly@example.test', canRead: true, canSend: false }] }));
await page.route(`${apiUrl}/correspondence/page?**`, async route => { const actual = await route.fetch(); const data: Inbox = await actual.json(); return route.fulfill({ response: actual, json: { ...data, items: data.items.map(value => value.id === saved.id ? { ...value, provider: 'gmail' } : value) } }); });
let providerReads = 0; let savedReads = 0;
await page.route(`${apiUrl}/email/message?**`, route => { providerReads += 1; expect(new URL(route.request().url()).searchParams.get('provider')).toBe('gmail'); expect(new URL(route.request().url()).searchParams.get('messageId')).toBe(saved.externalMessageId); return route.fulfill({ status: 503, json: {} }); });
await page.route(`${apiUrl}/correspondence/message/${saved.id}`, route => { savedReads += 1; return savedReads === 1 ? route.fulfill({ status: 503, json: {} }) : route.continue(); });
await enter(page, session.owner); await show(page, saved); await expect(row(page, saved.subject).getByRole('alert').filter({ hasText: translations.en.emailInboxMessageLoadFailed })).toBeVisible();
const listReads = count(session, 'GET', '/api/correspondence/page').length;
await keyboard(page, row(page, saved.subject).getByRole('button', { name: translations.en.retry, exact: true }));
await expect(detail(page, saved.subject)).toContainText(saved.content); await expect(row(page, saved.subject)).toContainText(translations.en.emailInboxProviderCopyUnavailable);
expect(providerReads).toBe(2); expect(savedReads).toBe(2); expect(count(session, 'GET', '/api/correspondence/page')).toHaveLength(listReads);
expect((await pageRead(page.request)).items[0].provider).toBe('manual'); await expect(row(page, saved.subject).getByRole('button', { name: translations.en.emailInboxReply, exact: true })).toHaveCount(0);
await capture(page, info, desktop, 'qualified-provider-unavailable-real-saved-copy'); noDelivery(session);
await info.attach('provider-copy-qualification', { body: JSON.stringify({ boundary, actualOwnedMessageId: saved.id, underlyingProvider: 'manual', browserProjectionProvider: 'gmail', providerReads, actualSavedReadAttempts: savedReads,
qualification: 'Only browser discriminator/live-copy/status are controlled fixtures. Actual owned saved body/metadata and tenant admission remain real HTTP. No live mailbox or actual provider grant/read qualification.' }), contentType: 'application/json' });
});
test('[P7-06 read] strict real UI-language204 preserves inbox context, selected saved body and dirty review notes with zero read deltas', async ({ page, session }, info) => {
test.setTimeout(120_000); const seeded = await seed(page, session.owner, 3, 'Locale preserved'); const queue = reviewFixture(seeded.job); await controlledReview(page, queue);
await enter(page, session.owner); await page.getByRole('textbox', { name: translations.en.correspondenceSearch, exact: true }).fill('Locale preserved'); await expect(inbox(page).getByRole('article')).toHaveCount(3); await show(page, seeded.messages[0]); await expect(detail(page, seeded.messages[0].subject)).toContainText(seeded.messages[0].content);
let reads = readRequests(session).length;
const locale = waitResponse(page, 'PUT', '/api/auth/preferences/language'); await keyboard(page, page.locator('header').first().getByRole('button', { name: 'Norsk', exact: true }));
const real = await locale; await info.attach('inbox-locale-first-http', { body: JSON.stringify({ status: real.status(), body: await real.text() }), contentType: 'application/json' }); expect(real.status()).toBe(204);
await expect(page.locator('html')).toHaveAttribute('lang', 'nb-NO'); await expect(page.getByRole('textbox', { name: translations.nb.correspondenceSearch, exact: true })).toHaveValue('Locale preserved'); await expect(detail(page, seeded.messages[0].subject, 'nb')).toContainText(seeded.messages[0].content);
expect(readRequests(session)).toHaveLength(reads); await capture(page, info, { ...desktop, language: 'nb' }, 'strict-locale-preserved-saved-detail');
await keyboard(page, inbox(page, 'nb').getByRole('button', { name: translations.nb.emailInboxReviewSuggestions, exact: true })); await expect(review(page, 'nb').getByRole('article')).toHaveCount(2);
const subject = queue.threads[0].subject; await row(page, subject).getByRole('textbox', { name: translations.nb.gmailReviewNotes, exact: true }).fill('Ulagret kontrollnotat — æøå'); reads = readRequests(session).length;
const second = waitResponse(page, 'PUT', '/api/auth/preferences/language'); await keyboard(page, page.locator('header').first().getByRole('button', { name: 'English', exact: true }));
const secondReal = await second; await info.attach('inbox-locale-second-http', { body: JSON.stringify({ status: secondReal.status(), body: await secondReal.text() }), contentType: 'application/json' }); expect(secondReal.status()).toBe(204);
await expect(row(page, subject).getByRole('textbox', { name: translations.en.gmailReviewNotes, exact: true })).toHaveValue('Ulagret kontrollnotat — æøå'); expect(readRequests(session)).toHaveLength(reads);
await capture(page, info, desktop, 'strict-locale-dirty-review-notes'); noDelivery(session);
expect(session.requests.filter(request => request.method !== 'GET' && !request.path.startsWith('/api/jobapplications/'))).toEqual([]);
});
test('[P7-06 read] independent provider-status503 Retry only rereads status while saved owner messages remain usable', async ({ page, session }, info) => {
test.setTimeout(90_000); const seeded = await seed(page, session.owner, 2, 'Provider status independent'); let providerReads = 0; const gate = hold();
await page.route(`${apiUrl}/email/providers`, async route => { providerReads += 1; if (providerReads === 1) return route.fulfill({ status: 503, json: {} }); await gate.pending; return route.fulfill({ status: 200, json: [{ provider: 'gmail', displayName: 'Gmail', connected: false, address: null, canRead: false, canSend: false }] }); });
try {
await enter(page, session.owner); await expect(inbox(page).getByRole('article')).toHaveCount(2); await expect(inbox(page).getByRole('alert').filter({ hasText: translations.en.emailInboxProviderStatusUnavailable })).toBeVisible();
await show(page, seeded.messages[0]); await expect(detail(page, seeded.messages[0].subject)).toContainText(seeded.messages[0].content);
const listReads = count(session, 'GET', '/api/correspondence/page').length; const savedReads = count(session, 'GET', `/api/correspondence/message/${seeded.messages[0].id}`).length;
const providerAlert = inbox(page).getByRole('alert').filter({ hasText: translations.en.emailInboxProviderStatusUnavailable }); await keyboard(page, providerAlert.getByRole('button', { name: translations.en.retry, exact: true }));
await expect.poll(() => providerReads).toBe(2); await expect(detail(page, seeded.messages[0].subject)).toContainText(seeded.messages[0].content); await capture(page, info, desktop, 'held-status-retry-saved-body-retained'); gate.release();
await expect(page.getByRole('group', { name: translations.en.emailInboxProviderStatus, exact: true })).toContainText(interpolate(translations.en.emailInboxProviderNotConnected, { provider: 'Gmail' }));
expect(count(session, 'GET', '/api/correspondence/page')).toHaveLength(listReads); expect(count(session, 'GET', `/api/correspondence/message/${seeded.messages[0].id}`)).toHaveLength(savedReads);
await expect(inbox(page).getByRole('button', { name: translations.en.emailInboxCompose, exact: true })).toBeDisabled(); noDelivery(session);
} finally { gate.release(); }
});
test('[P7-06 read] qualified paired review reads retain notes/filter and actual nonlinked decision recovery never replays committed POST', async ({ page, session }, info) => {
test.setTimeout(120_000); const seeded = await seed(page, session.owner); const fixture = reviewFixture(seeded.job); let failRead = false; let queueReads = 0; let suggestionsReads = 0; let decisionWrites = 0; const gate = hold();
await page.route(`${apiUrl}/gmail/review-candidates`, route => { queueReads += 1; return failRead ? route.fulfill({ status: 503, json: {} }) : route.fulfill({ status: 200, json: fixture.queue }); });
await page.route(`${apiUrl}/gmail/suggested-jobs`, route => { suggestionsReads += 1; return route.fulfill({ status: 200, json: fixture.suggestions }); });
await enter(page, session.owner, desktop, '/correspondence/review?source=phase7-review&marker=alias&view=inbox#queued');
await expect.poll(() => { const url = new URL(page.url()); return url.pathname + url.search + url.hash; }).toBe('/correspondence?source=phase7-review&marker=alias&view=review#queued');
await expect(review(page).getByRole('article')).toHaveCount(2);
const thread = fixture.threads[0]; const second = fixture.threads[1]; const notes = row(page, thread.subject).getByRole('textbox', { name: translations.en.gmailReviewNotes, exact: true }); await expect(notes).toHaveValue(thread.decisionNote!); await notes.fill(' Owner-reviewed decision note — æøå ');
await keyboard(page, review(page).getByRole('button', { name: translations.en.gmailReviewRouteReview, exact: true })); await expect(row(page, second.subject)).toHaveCount(0);
failRead = true; await keyboard(page, review(page).getByRole('button', { name: translations.en.correspondenceRefresh, exact: true }));
await expect(review(page).getByRole('alert')).toContainText(translations.en.gmailReviewLoadFailed); await expect(notes).toHaveValue(' Owner-reviewed decision note — æøå '); await expect(review(page).getByText(translations.en.gmailReviewNoCandidates, { exact: true })).toHaveCount(0);
failRead = false; await keyboard(page, review(page).getByRole('button', { name: translations.en.retry, exact: true })); await expect(notes).toHaveValue(' Owner-reviewed decision note — æøå ');
await expect(review(page).getByRole('button', { name: translations.en.gmailReviewRouteReview, exact: true })).toHaveAttribute('aria-pressed', 'true');
await capture(page, info, desktop, 'review-read-retry-retains-note-filter');
await page.route(`${apiUrl}/gmail/review-decision`, async route => {
decisionWrites += 1;
if (decisionWrites === 1) { await gate.pending; return route.fulfill({ status: 503, json: {} }); }
// Nonlinked decisions use the actual owner-local endpoint; never provider imports.
const actual = await route.fetch(); const data = await actual.json(); expect(actual.status()).toBe(200); expect(data.decision).toBe('rejected'); expect(data.note).toBe('Owner-reviewed decision note — æøå');
fixture.threads[0].decisionNote = data.note; fixture.threads[0].routing = data.decision; failRead = true;
return route.fulfill({ response: actual, json: data });
});
try {
await keyboard(page, row(page, thread.subject).getByRole('button', { name: translations.en.gmailReviewReject, exact: true })); await expect.poll(() => decisionWrites).toBe(1);
await expect(notes).toBeDisabled(); await expect(review(page).getByRole('button', { name: translations.en.correspondenceRefresh, exact: true })).toBeDisabled();
expect(count(session, 'POST', '/api/gmail/review-decision').map(request => request.body)).toEqual([{ threadId: thread.threadId, decision: 'rejected', jobApplicationId: null, note: 'Owner-reviewed decision note — æøå' }]);
await capture(page, info, desktop, 'held-local-review-decision'); gate.release();
await expect(review(page).getByRole('alert')).toContainText(translations.en.gmailReviewSaveFailed); await expect(notes).toHaveValue(' Owner-reviewed decision note — æøå ');
const committed = waitResponse(page, 'POST', '/api/gmail/review-decision'); await keyboard(page, row(page, thread.subject).getByRole('button', { name: translations.en.gmailReviewReject, exact: true }));
expect(await json<{ note: string; decision: string }>(await committed)).toMatchObject({ note: 'Owner-reviewed decision note — æøå', decision: 'rejected' });
await expect(review(page).getByRole('alert')).toContainText(translations.en.gmailReviewLoadFailed); const writes = decisionWrites; const q = queueReads; const s = suggestionsReads; failRead = false;
await keyboard(page, review(page).getByRole('button', { name: translations.en.retry, exact: true })); await expect.poll(() => queueReads).toBe(q + 1); await expect.poll(() => suggestionsReads).toBe(s + 1); expect(decisionWrites).toBe(writes);
await keyboard(page, review(page).getByRole('button', { name: translations.en.gmailReviewRouteRejected, exact: true })); await expect(row(page, thread.subject)).toBeVisible(); await expect(notes).toHaveValue(' Owner-reviewed decision note — æøå ');
await capture(page, info, desktop, 'committed-decision-get-only-recovery'); noDelivery(session);
await info.attach('review-provider-and-persistence-boundary', { body: JSON.stringify({ boundary, queueReads, suggestionsReads, actualNonlinkedWriteResponse: { threadId: thread.threadId, decision: 'rejected', note: 'Owner-reviewed decision note — æøå' }, decisionWrites,
qualification: 'Paired queue read fixture echoes actual200 local decision response. No supported pure decision GET was available: refreshed fixture notes are not an independent DB/provider persistence readback. Linked decisions/manual-sync/import/create/send are unexecuted.' }), contentType: 'application/json' });
} finally { gate.release(); }
});
test('[P7-06 read] eight finite bilingual theme widths preserve long saved bodies and qualified review-note keyboard access', async ({ page, session }, info) => {
test.setTimeout(240_000); const seeded = await seed(page, session.owner, 3, 'Langt emne om samfunnskritisk plattformutvikling med mange samarbeidspartnere Æ Ø Å'); const fixture = reviewFixture(seeded.job, true); await controlledReview(page, fixture);
const samples: (Sample & { view: 'inbox' | 'detail' | 'review' })[] = [
{ language: 'en', theme: 'light', width: 320, height: 740, view: 'inbox' }, { language: 'nb', theme: 'dark', width: 360, height: 844, view: 'review' },
{ language: 'en', theme: 'dark', width: 390, height: 844, view: 'detail' }, { language: 'nb', theme: 'light', width: 768, height: 1024, view: 'inbox' },
{ language: 'en', theme: 'dark', width: 1024, height: 768, view: 'review' }, { language: 'nb', theme: 'light', width: 1280, height: 320, view: 'detail' },
{ language: 'en', theme: 'light', width: 1440, height: 900, view: 'review' }, { language: 'nb', theme: 'dark', width: 1920, height: 1080, view: 'inbox' },
];
for (const sample of samples) {
await enter(page, session.owner, sample, `/correspondence?source=phase7-width${sample.view === 'review' ? '&view=review' : ''}#long`); const t = translations[sample.language];
if (sample.view === 'review') {
await expect(review(page, sample.language).getByRole('article')).toHaveCount(2);
const control = row(page, fixture.threads[0].subject).getByRole('textbox', { name: t.gmailReviewNotes, exact: true }); await control.focus(); await expect(control).toBeFocused();
} else {
await expect(inbox(page, sample.language).getByRole('article')).toHaveCount(3);
if (sample.view === 'detail') { await show(page, seeded.messages[0], sample.language); await expect(detail(page, seeded.messages[0].subject, sample.language)).toContainText(seeded.messages[0].content); }
const action = row(page, seeded.messages[0].subject).getByRole('button', { name: sample.view === 'detail' ? t.emailInboxHideMessage : t.emailInboxViewMessage, exact: true }); await action.focus(); await expect(action).toBeFocused();
}
await page.evaluate(() => scrollTo(0, 0)); await capture(page, info, sample, `long-${sample.view}`);
}
noDelivery(session); expect(count(session, 'POST', '/api/gmail/review-decision')).toHaveLength(0);
});