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 }; 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; 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) { return template.replace(/\{(\w+)\}/g, (_, key: string) => String(params[key] ?? `{${key}}`)); } function hold() { let release!: () => void; const pending = new Promise(resolve => { release = resolve; }); return { pending, release }; } async function json(response: APIResponse | Response, status = 200): Promise { 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 { 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(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) + ''; return json(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 ', 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 = {}) { return json(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(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) { 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(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(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(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); });