Files
jobtrackingapp/job-tracker-ui/e2e/phase7-correspondence-compose.spec.ts

213 lines
45 KiB
TypeScript

import AxeBuilder from '@axe-core/playwright';
import { expect, test as base, type APIRequestContext, type APIResponse, type Locator, type Page, type Response, type Route, type TestInfo } from '@playwright/test';
import { translations } from '../src/i18n/translations';
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 Draft = { id: string; jobApplicationId: number; provider: string; to: string; subject: string; bodyText: string; threadId: string | null; clientRequestId: string; revision: number; createdAtUtc: string; updatedAtUtc: string };
type Send = { attemptId: string; status: 'pending' | 'sending' | 'sent' | 'failed' | 'uncertain'; duplicate: boolean; failureCategory?: string | null };
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 uuid = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
const boundary = 'Real owner job and email-draft create/list/detail/update/delete/new-attempt contracts use isolated disposable SQLite. Browser provider capability is an explicitly labelled presentation fixture; no provider grant or connection is installed. One case permits the real email/send request only after independently proving the fresh owner has no Gmail connection: the server records failed/reauthorization_required before delivery, and its failed-only new-attempt response is real. Every other send endpoint is blocked or fulfilled by explicitly labelled browser fixtures, never a provider delivery. Intercepted uncertain/pending/sending/sent/generic409 states prove UI safeguards, not provider persistence, delivery, model or production qualification. Free accounts and disabled delivery/model/alert workers remain required. No Gmail sync/import/create/unlink, external provider/model, raw SQL/schema, production or deployment action. Receipt bodies contain fixture fields and identities only; no credentials/CSRF/session headers. All enabled axe rules and target-size, complete incomplete vectors, finite keyboard/reflow images and strict language HTTP204 assertions do not replace human reader, device/software keyboard, 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(value => value.method === method && value.path === path); }
function reads(session: Session) { return session.requests.filter(value => value.method === 'GET' && /^\/api\/(?:email\/drafts|email\/providers|jobapplications\/choices|correspondence\/page)(?:\/|$)/.test(value.path)); }
function noUnrelatedWrites(session: Session) { expect(session.requests.filter(value => value.method !== 'GET' && /^\/api\/(?:gmail|ai)(?:\/|$)/.test(value.path))).toEqual([]); }
async function keyboard(page: Page, control: Locator) { await expect(control).toBeVisible(); await expect(control).toBeEnabled(); await control.focus(); await expect(control).toBeFocused(); await page.keyboard.press('Enter'); }
function draft(page: Page, language: Sample['language'] = 'en') { return page.getByRole('region', { name: translations[language].emailInboxDraftTitle, exact: true }); }
function setup(page: Page, language: Sample['language'] = 'en') { return page.getByRole('region', { name: translations[language].emailInboxCompose, exact: true }); }
function fields(page: Page, language: Sample['language'] = 'en') { const section = draft(page, language); const t = translations[language]; return {
to: section.getByRole('textbox', { name: t.emailInboxRecipient, exact: true }), subject: section.getByRole('textbox', { name: t.emailInboxSubject, exact: true }), body: section.getByRole('textbox', { name: t.emailInboxMessage, exact: true }),
}; }
function save(page: Page, language: Sample['language'] = 'en') { const t = translations[language]; return draft(page, language).getByRole('button', { name: new RegExp(`^(?:${t.emailInboxSaveDraft}|${t.profileSaveChanges})$`) }); }
function send(page: Page, language: Sample['language'] = 'en') { return draft(page, language).getByRole('button', { name: translations[language].emailInboxReviewSend, exact: true }); }
function retry(page: Page, message?: string) { const alerts = page.getByRole('alert'); return (message ? alerts.filter({ hasText: message }) : alerts.filter({ has: page.getByRole('button', { name: translations.en.retry, exact: true }) })).getByRole('button', { name: translations.en.retry, exact: true }); }
async function ownedFocus(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-compose-${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)) 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 page.route(`${apiUrl}/email/send`, route => route.abort('blockedbyclient'));
await provide({ owner, administrator, admin, requests });
noUnrelatedWrites({ owner, administrator, admin, requests });
} finally { await info.attach('correspondence-compose-boundary', { body: JSON.stringify({ boundary, requests, blockedExternalRequests: blocked }), contentType: 'application/json' }); await admin.dispose(); }
},
});
async function job(api: APIRequestContext, owner: Owner, title = 'Recorded compose job — æøå', company?: number) {
const companyId = company ?? (await json<{ id: number }>(await api.post(`${apiUrl}/companies`, { headers: owner.headers, data: { name: 'Recorded compose company — æøå' } }), 201)).id;
return json<Job>(await api.post(`${apiUrl}/jobapplications`, { headers: owner.headers, data: { companyId, jobTitle: title, status: 'Saved' } }), 201);
}
async function stored(api: APIRequestContext, owner: Owner, parent: Job, subject = 'Recorded saved draft — æøå', bodyText = 'Recorded private draft body.\nKeep line breaks and Æ Ø Å.') {
return json<Draft>(await api.post(`${apiUrl}/email/drafts`, { headers: owner.headers, data: { jobApplicationId: parent.id, provider: 'gmail', to: 'recorded-recipient@example.test', subject, bodyText, threadId: null } }), 201);
}
async function getDraft(api: APIRequestContext, value: Draft) { return json<Draft>(await api.get(`${apiUrl}/email/drafts/${value.id}`)); }
async function capableProjection(page: Page) { await page.route(`${apiUrl}/email/providers`, route => route.fulfill({ status: 200, json: [{ provider: 'gmail', displayName: 'Gmail', connected: true, address: 'recorded-owner@example.test', canRead: true, canSend: true }] })); }
async function enter(page: Page, owner: Owner, sample: Sample = desktop) {
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('/correspondence?source=phase7-compose&marker=preserved#draft');
const current = await page.evaluate(() => ({ language: localStorage.getItem('uiLanguage'), theme: localStorage.getItem('jobtracker.themeMode') }));
if (current.language !== sample.language || current.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);
}
async function resume(page: Page, value: Draft, language: Sample['language'] = 'en') {
await keyboard(page, page.getByRole('button', { name: interpolate(translations[language].emailInboxResumeDraft, { name: value.subject }), exact: true })); await expect(fields(page, language).body).toHaveValue(value.bodyText);
}
async function compose(page: Page, language: Sample['language'] = 'en') { await keyboard(page, page.getByRole('button', { name: translations[language].emailInboxCompose, exact: true })); await expect(setup(page, language)).toBeVisible(); await keyboard(page, setup(page, language).getByRole('button', { name: translations[language].emailInboxStartDraft, exact: true })); await expect(draft(page, language)).toBeVisible(); }
async function fill(page: Page, value: { to: string; subject: string; bodyText: string }, language: Sample['language'] = 'en') { const controls = fields(page, language); await controls.to.fill(value.to); await controls.subject.fill(value.subject); await controls.body.fill(value.bodyText); }
async function confirmSend(page: Page, language: Sample['language'] = 'en') { await keyboard(page, send(page, language)); const dialog = page.getByRole('dialog', { name: translations[language].emailInboxConfirmSendTitle, exact: true }); await expect(dialog).toBeVisible(); await keyboard(page, dialog.getByRole('button', { name: translations[language].emailInboxSendEmail, 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') },
characterHelpers: Array.from(document.querySelectorAll<HTMLElement>('.MuiFormHelperText-root')).filter(node => node.textContent?.includes('200000') && node.getClientRects().length > 0 && getComputedStyle(node).visibility === 'visible').map(node => { const style = getComputedStyle(node); const paper = node.closest('.MuiPaper-root'); return { text: node.textContent, foreground: style.color, paperBackground: paper ? getComputedStyle(paper).backgroundColor : null, fontSize: style.fontSize, fontWeight: style.fontWeight, disabledClass: node.classList.contains('Mui-disabled') }; }),
}));
const axe = await new AxeBuilder({ page }).options({ rules: { 'target-size': { enabled: true } } }).analyze(); const name = `p706-compose-${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 every width/theme/language/state combination.', 'Every incomplete vector retained; no human reader, touch/software keyboard, real zoom, font/photo or production acceptance.', 'Full-page images can be resized; viewport capture separately records focused controls where relevant.', 'Provider capability and delivery-result projections are controlled fixtures; real owner draft persistence and one disconnected failure remain distinct.'] }), 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([]);
}
test('[P7-06 compose] real server-owned identity, revisions, stale conflict, reload and confirmed delete preserve exact draft content', async ({ page, session }, info) => {
test.setTimeout(180_000); const parent = await job(page.request, session.owner); await capableProjection(page); await enter(page, session.owner); await compose(page);
const initial = { to: 'recorded-recipient@example.test', subject: 'Created from UI — æøå', bodyText: 'Actual owner text\n<script>plain text marker</script>\nÆ Ø Å' }; await fill(page, initial);
const createdResponse = waitResponse(page, 'POST', '/api/email/drafts'); await keyboard(page, save(page)); const created = await json<Draft>(await createdResponse, 201);
expect(created).toMatchObject({ ...initial, jobApplicationId: parent.id, provider: 'gmail', revision: 1 }); expect(created.id).toMatch(uuid); expect(created.clientRequestId).toMatch(uuid); expect(created.id).not.toBe(created.clientRequestId);
expect(count(session, 'POST', '/api/email/drafts')).toEqual([{ method: 'POST', path: '/api/email/drafts', query: {}, body: { ...initial, jobApplicationId: parent.id, provider: 'gmail', threadId: null } }]); expect(await getDraft(page.request, created)).toEqual(created);
const changed = 'Reviewed owner change\nPreserve exact body and æøå.'; await fields(page).body.fill(changed); const updatedResponse = waitResponse(page, 'PUT', `/api/email/drafts/${created.id}`); await keyboard(page, save(page)); const updated = await json<Draft>(await updatedResponse);
expect(updated).toMatchObject({ ...initial, bodyText: changed, id: created.id, clientRequestId: created.clientRequestId, revision: 2 }); expect(count(session, 'PUT', `/api/email/drafts/${created.id}`)[0].body).toEqual({ revision: 1, to: initial.to, subject: initial.subject, bodyText: changed });
const remote = await json<Draft>(await page.request.put(`${apiUrl}/email/drafts/${created.id}`, { headers: session.owner.headers, data: { revision: 2, to: initial.to, subject: initial.subject, bodyText: 'Other tab saved revision — æøå' } })); expect(remote.revision).toBe(3);
const local = 'Retain this conflicting local text — æøå'; await fields(page).body.fill(local); const conflict = waitResponse(page, 'PUT', `/api/email/drafts/${created.id}`); await keyboard(page, save(page)); expect((await conflict).status()).toBe(409); await expect(page.getByText(translations.en.emailInboxDraftChangedElsewhere, { exact: true })).toBeVisible(); await expect(fields(page).body).toHaveValue(local); expect((await getDraft(page.request, created)).bodyText).toBe(remote.bodyText);
await capture(page, info, desktop, 'actual-stale409-local-retained'); await page.reload(); await resume(page, remote); expect(await getDraft(page.request, created)).toEqual(remote);
const deletes = count(session, 'DELETE', `/api/email/drafts/${created.id}`).length; const discard = draft(page).getByRole('button', { name: translations.en.emailInboxDiscardDraft, exact: true }); await keyboard(page, discard);
const dialog = page.getByRole('dialog', { name: translations.en.emailInboxDiscardDraftTitle, exact: true }); await keyboard(page, dialog.getByRole('button', { name: translations.en.cancel, exact: true })); await expect(discard).toBeFocused(); expect(count(session, 'DELETE', `/api/email/drafts/${created.id}`)).toHaveLength(deletes);
await keyboard(page, discard); const deleted = waitResponse(page, 'DELETE', `/api/email/drafts/${created.id}`); await keyboard(page, page.getByRole('dialog', { name: translations.en.emailInboxDiscardDraftTitle, exact: true }).getByRole('button', { name: translations.en.emailInboxDiscardDraft, exact: true })); expect((await deleted).status()).toBe(204);
await expect(draft(page)).toHaveCount(0); expect(count(session, 'DELETE', `/api/email/drafts/${created.id}`)).toEqual([{ method: 'DELETE', path: `/api/email/drafts/${created.id}`, query: { revision: '3' }, body: null }]); expect((await page.request.get(`${apiUrl}/email/drafts/${created.id}`)).status()).toBe(404); expect(await json<Draft[]>(await page.request.get(`${apiUrl}/email/drafts`))).toEqual([]); expect(count(session, 'POST', '/api/email/send')).toHaveLength(0);
});
test('[P7-06 compose] anonymous and another owner cannot read, update, delete, prepare or associate a private draft', async ({ page, session, playwright }, info) => {
test.setTimeout(120_000); const parent = await job(page.request, session.owner); const original = await stored(page.request, session.owner, parent); 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 compose job'); const privateOther = await stored(foreign, other, otherJob, 'Other owner private draft');
expect(await json<Draft[]>(await page.request.get(`${apiUrl}/email/drafts`))).toEqual([original]); expect(await json<Draft[]>(await foreign.get(`${apiUrl}/email/drafts`))).toEqual([privateOther]);
for (const api of [anonymous, foreign]) { expect((await api.get(`${apiUrl}/email/drafts/${original.id}`)).status()).toBe(api === anonymous ? 401 : 404); }
expect((await anonymous.get(`${apiUrl}/email/drafts`)).status()).toBe(401); expect((await foreign.put(`${apiUrl}/email/drafts/${original.id}`, { headers: other.headers, data: { revision: original.revision, to: original.to, subject: original.subject, bodyText: 'Forbidden overwrite' } })).status()).toBe(404);
expect((await foreign.delete(`${apiUrl}/email/drafts/${original.id}`, { headers: other.headers, params: { revision: original.revision } })).status()).toBe(404); expect((await foreign.post(`${apiUrl}/email/drafts/${original.id}/new-attempt`, { headers: other.headers, data: { revision: original.revision } })).status()).toBe(404);
expect((await foreign.post(`${apiUrl}/email/drafts`, { headers: other.headers, data: { jobApplicationId: parent.id, provider: 'gmail', to: '', subject: '', bodyText: '' } })).status()).toBe(404); expect((await page.request.get(`${apiUrl}/email/drafts/${privateOther.id}`)).status()).toBe(404); expect(await getDraft(page.request, original)).toEqual(original);
await capableProjection(page); await enter(page, session.owner); await resume(page, original); await expect(page.getByRole('button', { name: interpolate(translations.en.emailInboxResumeDraft, { name: privateOther.subject }), exact: true })).toHaveCount(0); await capture(page, info, desktop, 'actual-owner-only-draft'); expect(count(session, 'POST', '/api/email/send')).toHaveLength(0);
} finally { await foreign.dispose(); await anonymous.dispose(); }
});
test('[P7-06 compose] failed owned job choices provide GET-only Retry without claiming there are no jobs', async ({ page, session }, info) => {
test.setTimeout(120_000); const parent = await job(page.request, session.owner); await capableProjection(page); let attempts = 0; const gate = hold(); const reached = hold();
await page.route(`${apiUrl}/jobapplications/choices*`, async route => { attempts += 1; if (attempts === 1) return route.fulfill({ status: 503, json: {} }); reached.release(); await gate.pending; return route.fulfill({ response: await route.fetch() }); });
try {
await enter(page, session.owner); await expect(retry(page)).toBeVisible(); await expect(page.getByText(translations.en.emailInboxAddJobFirst, { exact: true })).toHaveCount(0); const before = reads(session).filter(value => value.path !== '/api/jobapplications/choices').length;
await keyboard(page, retry(page)); await reached.pending; expect(attempts).toBe(2); expect(reads(session).filter(value => value.path !== '/api/jobapplications/choices')).toHaveLength(before); expect(count(session, 'POST', '/api/email/drafts')).toHaveLength(0); gate.release();
await expect(page.getByRole('button', { name: translations.en.emailInboxCompose, exact: true })).toBeEnabled(); await keyboard(page, page.getByRole('button', { name: translations.en.emailInboxCompose, exact: true })); await expect(setup(page).getByRole('combobox', { name: translations.en.emailInboxJob, exact: true })).toHaveValue(new RegExp(parent.jobTitle)); await capture(page, info, desktop, 'actual-job-choices-recovered'); expect(count(session, 'POST', '/api/email/send')).toHaveLength(0);
} finally { gate.release(); await page.unroute(`${apiUrl}/jobapplications/choices*`); }
});
test('[P7-06 compose] saved-draft GET Retry and committed Save/list503 retain identity and never repeat the write', async ({ page, session }, info) => {
test.setTimeout(150_000); const parent = await job(page.request, session.owner); const original = await stored(page.request, session.owner, parent); await capableProjection(page); let attempts = 0; let failNext = false;
await page.route(`${apiUrl}/email/drafts`, async route => { if (route.request().method() !== 'GET') return route.continue(); attempts += 1; if (attempts === 1 || failNext) { failNext = false; return route.fulfill({ status: 503, json: {} }); } return route.fulfill({ response: await route.fetch() }); });
await enter(page, session.owner); await keyboard(page, retry(page, translations.en.emailInboxDraftsLoadFailed)); await resume(page, original); expect(attempts).toBe(2); const edited = 'Successful write with failed following list read — æøå'; await fields(page).body.fill(edited); failNext = true;
const written = waitResponse(page, 'PUT', `/api/email/drafts/${original.id}`); await keyboard(page, save(page)); const current = await json<Draft>(await written); expect(current).toMatchObject({ id: original.id, revision: 2, clientRequestId: original.clientRequestId, bodyText: edited }); await expect(retry(page, translations.en.emailInboxDraftsLoadFailed)).toBeVisible(); await expect(fields(page).body).toHaveValue(edited); expect(await getDraft(page.request, original)).toEqual(current);
const writes = count(session, 'PUT', `/api/email/drafts/${original.id}`).length; const creates = count(session, 'POST', '/api/email/drafts').length; await keyboard(page, retry(page, translations.en.emailInboxDraftsLoadFailed)); await expect(page.getByText(translations.en.emailInboxDraftsLoadFailed, { exact: true })).toHaveCount(0); expect(attempts).toBe(4); expect(count(session, 'PUT', `/api/email/drafts/${original.id}`)).toHaveLength(writes); expect(count(session, 'POST', '/api/email/drafts')).toHaveLength(creates); expect(await getDraft(page.request, original)).toEqual(current); await capture(page, info, desktop, 'committed-save-list-get-only-recovery'); expect(count(session, 'POST', '/api/email/send')).toHaveLength(0);
});
test('[P7-06 compose] held captured Save locks conflicting controls and late unmounted completion preserves both owned drafts', async ({ page, session }, info) => {
test.setTimeout(150_000); const firstJob = await job(page.request, session.owner, 'Held save owner A'); const secondJob = await job(page.request, session.owner, 'Unmount destination owner B', firstJob.companyId); const first = await stored(page.request, session.owner, firstJob, 'Held saved draft A'); const second = await stored(page.request, session.owner, secondJob, 'Independent saved draft B'); await capableProjection(page); await enter(page, session.owner); await resume(page, first);
const text = 'Captured A Save stays on A after unmount — æøå'; await fields(page).body.fill(text); const gate = hold(); const reached = hold(); const path = `/api/email/drafts/${first.id}`;
await page.route(`${apiUrl}/email/drafts/${first.id}`, async route => { if (route.request().method() !== 'PUT') return route.continue(); reached.release(); await gate.pending; return route.fulfill({ response: await route.fetch() }); });
try {
await keyboard(page, save(page)); await reached.pending; for (const control of Object.values(fields(page))) await expect(control).toBeDisabled(); await expect(send(page)).toBeDisabled(); await expect(draft(page).getByRole('button', { name: translations.en.emailInboxDiscardDraft, exact: true })).toBeDisabled(); await expect(page.getByRole('button', { name: translations.en.emailInboxCompose, exact: true })).toBeDisabled(); await expect(page.getByRole('button', { name: interpolate(translations.en.emailInboxResumeDraft, { name: second.subject }), exact: true })).toBeDisabled(); await ownedFocus(draft(page));
expect(count(session, 'PUT', path)).toEqual([{ method: 'PUT', path, query: {}, body: { revision: first.revision, to: first.to, subject: first.subject, bodyText: text } }]); expect(count(session, 'POST', '/api/email/send')).toHaveLength(0); await capture(page, info, desktop, 'held-save-conflict-controls-locked'); const origin = await page.evaluate(() => performance.timeOrigin);
await keyboard(page, page.getByRole('button', { name: new RegExp(translations.en.quickSearch) })); const commands = page.getByRole('dialog', { name: translations.en.quickSearch }); await commands.getByRole('textbox', { name: translations.en.quickSearch }).fill(secondJob.jobTitle); await keyboard(page, commands.getByRole('button').filter({ hasText: secondJob.jobTitle })); await expect(page).toHaveURL(new RegExp(`/jobs/${secondJob.id}$`));
await expect(page.getByRole('heading', { level: 2, name: secondJob.jobTitle, exact: true })).toBeVisible(); await expect(page.getByRole('tablist', { name: translations.en.workspaceSections, exact: true })).toBeVisible(); await expect(page.locator('[aria-labelledby="email-draft-title"]')).toHaveCount(0); expect(await page.evaluate(() => performance.timeOrigin)).toBe(origin);
const completed = waitResponse(page, 'PUT', path); gate.release(); const saved = await json<Draft>(await completed); expect(saved).toMatchObject({ id: first.id, bodyText: text, revision: 2, clientRequestId: first.clientRequestId }); expect(await getDraft(page.request, second)).toEqual(second); await page.goBack(); await resume(page, second); await expect(fields(page).body).toHaveValue(second.bodyText); expect(await getDraft(page.request, first)).toEqual(saved); await capture(page, info, desktop, 'late-a-save-b-draft-preserved');
} finally { gate.release(); await page.unroute(`${apiUrl}/email/drafts/${first.id}`); }
});
test('[P7-06 compose] recipient-only and subject-only draft Cancel retain edits and explicit send Cancel makes zero POSTs', async ({ page, session }, info) => {
test.setTimeout(150_000); await job(page.request, session.owner); await capableProjection(page); await enter(page, session.owner); await compose(page); const controls = fields(page); await controls.to.fill('recipient-only@example.test'); const discard = draft(page).getByRole('button', { name: translations.en.emailInboxDiscardDraft, exact: true }); await keyboard(page, discard);
await keyboard(page, page.getByRole('dialog', { name: translations.en.emailInboxDiscardDraftTitle, exact: true }).getByRole('button', { name: translations.en.cancel, exact: true })); await expect(controls.to).toHaveValue('recipient-only@example.test'); await expect(discard).toBeFocused(); await controls.to.fill(''); await controls.subject.fill('Subject-only dirty draft — æøå');
await keyboard(page, page.getByRole('button', { name: translations.en.emailInboxCompose, exact: true })); await keyboard(page, setup(page).getByRole('button', { name: translations.en.emailInboxStartDraft, exact: true })); await keyboard(page, page.getByRole('dialog', { name: translations.en.emailInboxReplaceDraftTitle, exact: true }).getByRole('button', { name: translations.en.cancel, exact: true })); await expect(controls.subject).toHaveValue('Subject-only dirty draft — æøå'); await keyboard(page, setup(page).getByRole('button', { name: translations.en.cancel, exact: true }));
const reviewed = { to: 'reviewed-recipient@example.test', subject: 'Explicit reviewed subject — æøå', bodyText: 'Review this exact body.\nNo delivery on Cancel.' }; await fill(page, reviewed); await keyboard(page, send(page)); const dialog = page.getByRole('dialog', { name: translations.en.emailInboxConfirmSendTitle, exact: true }); await expect(dialog).toContainText(reviewed.to); await expect(dialog).toContainText(reviewed.subject); await capture(page, info, desktop, 'explicit-send-review-before-cancel'); await keyboard(page, dialog.getByRole('button', { name: translations.en.cancel, exact: true })); await expect(send(page)).toBeFocused(); await expect(controls.to).toHaveValue(reviewed.to); await expect(controls.subject).toHaveValue(reviewed.subject); await expect(controls.body).toHaveValue(reviewed.bodyText); expect(session.requests.filter(value => value.method !== 'GET' && value.path.startsWith('/api/email/'))).toEqual([]);
});
test('[P7-06 compose] real failed-attempt preparation changes only server identity and preserves all unsaved local fields for the next reviewed send', async ({ page, session }, info) => {
test.setTimeout(150_000); const parent = await job(page.request, session.owner); const original = await stored(page.request, session.owner, parent); const realProviders = await json<{ provider: string; connected: boolean; canSend: boolean }[]>(await page.request.get(`${apiUrl}/email/providers`)); expect(realProviders.find(value => value.provider === 'gmail')).toMatchObject({ connected: false, canSend: false }); await capableProjection(page); await enter(page, session.owner); await resume(page, original);
const local = { to: 'unsaved-local-recipient@example.test', subject: 'Unsaved local subject survives new attempt — æøå', bodyText: 'Unsaved local body survives identity preparation.\nPreserve line breaks and Æ Ø Å.' }; await fill(page, local); await expect(draft(page).getByText(translations.en.assetsUnsavedChanges, { exact: true })).toBeVisible(); let reviewedRequestId = original.clientRequestId;
const safeDisconnected = async (route: Route) => { expect(route.request().method()).toBe('POST'); expect(route.request().postDataJSON()).toEqual({ ...local, jobApplicationId: parent.id, provider: 'gmail', clientRequestId: reviewedRequestId, threadId: null, confirmed: true }); return route.continue(); }; await page.route(`${apiUrl}/email/send`, safeDisconnected);
try {
const attempted = waitResponse(page, 'POST', '/api/email/send'); await confirmSend(page); const result = await json<Send>(await attempted, 409); expect(result).toMatchObject({ status: 'failed', duplicate: false, failureCategory: 'reauthorization_required' }); expect(result.attemptId).toMatch(uuid); await expect(page.getByText(translations.en.emailInboxDeliveryFailed, { exact: true })).toBeVisible(); await expect(send(page)).toBeDisabled(); await expect(save(page)).toBeDisabled(); expect(await getDraft(page.request, original)).toEqual(original);
const prepared = waitResponse(page, 'POST', `/api/email/drafts/${original.id}/new-attempt`); await keyboard(page, page.getByRole('button', { name: translations.en.emailInboxPrepareAttempt, exact: true })); const replacement = await json<Draft>(await prepared); expect(replacement).toMatchObject({ id: original.id, revision: 2, to: original.to, subject: original.subject, bodyText: original.bodyText }); expect(replacement.clientRequestId).toMatch(uuid); expect(replacement.clientRequestId).not.toBe(original.clientRequestId); expect(count(session, 'POST', `/api/email/drafts/${original.id}/new-attempt`)).toEqual([{ method: 'POST', path: `/api/email/drafts/${original.id}/new-attempt`, query: {}, body: { revision: 1 } }]); expect(await getDraft(page.request, original)).toEqual(replacement); await expect(send(page)).toBeEnabled(); await expect(save(page)).toBeEnabled(); expect(count(session, 'POST', '/api/email/send')).toHaveLength(1);
await expect(fields(page).to).toHaveValue(local.to); await expect(fields(page).subject).toHaveValue(local.subject); await expect(fields(page).body).toHaveValue(local.bodyText); await expect(draft(page).getByText(translations.en.assetsUnsavedChanges, { exact: true })).toBeVisible(); expect(count(session, 'PUT', `/api/email/drafts/${original.id}`)).toHaveLength(0); await capture(page, info, desktop, 'real-new-attempt-unsaved-fields-preserved');
expect((await page.request.post(`${apiUrl}/email/drafts/${original.id}/new-attempt`, { headers: session.owner.headers, data: { revision: replacement.revision } })).status()).toBe(409); expect(await getDraft(page.request, original)).toEqual(replacement); reviewedRequestId = replacement.clientRequestId;
const reviewedAgain = waitResponse(page, 'POST', '/api/email/send'); await confirmSend(page); const secondResult = await json<Send>(await reviewedAgain, 409); expect(secondResult).toMatchObject({ status: 'failed', duplicate: false, failureCategory: 'reauthorization_required' }); expect(secondResult.attemptId).not.toBe(result.attemptId); expect(count(session, 'POST', '/api/email/send').map(value => value.body)).toEqual([original.clientRequestId, replacement.clientRequestId].map(clientRequestId => ({ ...local, jobApplicationId: parent.id, provider: 'gmail', clientRequestId, threadId: null, confirmed: true }))); expect(count(session, 'POST', `/api/email/drafts/${original.id}/new-attempt`)).toHaveLength(1); expect(await getDraft(page.request, original)).toEqual(replacement); await expect(fields(page).to).toHaveValue(local.to); await expect(fields(page).subject).toHaveValue(local.subject); await expect(fields(page).body).toHaveValue(local.bodyText); await expect(draft(page).getByText(translations.en.assetsUnsavedChanges, { exact: true })).toBeVisible();
await info.attach('real-disconnected-failed-attempt', { body: JSON.stringify({ realProviders, result, secondResult, original, replacement, unsavedLocalFields: local, delivery: 'Both actual requests fail before provider.SendAsync because the account has no connection. No provider grant, sync or delivery installed; draft content remains the separately verified persisted snapshot.' }), contentType: 'application/json' });
} finally { await page.unroute(`${apiUrl}/email/send`, safeDisconnected); }
});
test('[P7-06 compose] labelled uncertain pending sending sent and generic409 fixtures retain state with no implicit identity or resend', async ({ page, session }, info) => {
test.setTimeout(240_000); const parent = await job(page.request, session.owner); await capableProjection(page);
for (const status of ['uncertain', 'pending', 'sending', 'sent', 'generic409'] as const) {
const original = await stored(page.request, session.owner, parent, `Controlled ${status} draft — æøå`); await enter(page, session.owner);
// Independent delivery fixtures require a fresh document; same hash URL preserves current draft.
if (status !== 'uncertain') { const origin = await page.evaluate(() => performance.timeOrigin); await page.reload(); expect(await page.evaluate(() => performance.timeOrigin)).not.toBe(origin); }
await resume(page, original); const before = count(session, 'POST', '/api/email/send').length;
const fixture = async (route: Route) => { expect(route.request().postDataJSON()).toMatchObject({ clientRequestId: original.clientRequestId, jobApplicationId: parent.id, confirmed: true }); return route.fulfill(status === 'generic409' ? { status: 409, json: { title: 'Idempotency conflict', detail: 'Controlled conflict without definitive provider failure.' } } : { status: status === 'sent' ? 200 : 409, json: { attemptId: original.clientRequestId, status, duplicate: status !== 'sent', failureCategory: 'controlled_ui_fixture' } }); }; await page.route(`${apiUrl}/email/send`, fixture);
try {
const responded = waitResponse(page, 'POST', '/api/email/send'); await confirmSend(page); const result = await responded; expect(result.status()).toBe(status === 'sent' ? 200 : 409); await expect(send(page)).toBeDisabled(); await expect(save(page)).toBeDisabled(); await expect(page.getByRole('button', { name: translations.en.emailInboxPrepareAttempt, exact: true })).toHaveCount(0);
if (status !== 'generic409') await expect(page.getByText(status === 'sent' ? translations.en.emailInboxSentSuccess : translations.en.emailInboxDeliveryUncertain, { exact: true })).toBeVisible(); for (const control of Object.values(fields(page))) await expect(control).toBeDisabled(); await expect(send(page)).toBeDisabled(); await expect(save(page)).toBeDisabled(); await expect(page.getByRole('button', { name: translations.en.emailInboxPrepareAttempt, exact: true })).toHaveCount(0);
expect(count(session, 'POST', '/api/email/send')).toHaveLength(before + 1); expect(count(session, 'POST', `/api/email/drafts/${original.id}/new-attempt`)).toHaveLength(0); if (status === 'sent') { expect((await page.request.get(`${apiUrl}/email/drafts/${original.id}`)).status()).toBe(404); expect(count(session, 'DELETE', `/api/email/drafts/${original.id}`)).toEqual([{ method: 'DELETE', path: `/api/email/drafts/${original.id}`, query: { revision: '1' }, body: null }]); } else expect(await getDraft(page.request, original)).toEqual(original);
await info.attach(`controlled-${status}-send-result`, { body: JSON.stringify({ qualification: 'Browser response fixture; does not record a real delivery attempt or establish provider status.', original, responseStatus: result.status(), responseBody: await result.text(), sendRequestCount: before + 1 }), contentType: 'application/json' }); if (status === 'uncertain' || status === 'generic409') await capture(page, info, desktop, `controlled-${status}-identity-protected`);
} finally { await page.unroute(`${apiUrl}/email/send`, fixture); }
}
});
test('[P7-06 compose] strict actual language204 preserves all dirty fields and saved identity without draft or setup rereads', async ({ page, session }, info) => {
test.setTimeout(120_000); const parent = await job(page.request, session.owner); const original = await stored(page.request, session.owner, parent); await capableProjection(page); await enter(page, session.owner); await resume(page, original); const edited = { to: 'edited-local@example.test', subject: 'Unsaved bilingual subject — æøå', bodyText: 'Unsaved bilingual body\nÆ Ø Å' }; await fill(page, edited); const readCount = reads(session).length;
const changed = waitResponse(page, 'PUT', '/api/auth/preferences/language'); await keyboard(page, page.locator('header').first().getByRole('button', { name: 'Norsk', exact: true })); const actual = await changed; await info.attach('compose-first-language-http', { body: JSON.stringify({ status: actual.status(), body: await actual.text(), qualification: 'Ordinary strict204 assertion; existing RD11 remains a failure if the real backend returns500.' }), contentType: 'application/json' }); expect(actual.status()).toBe(204);
await expect(page.locator('html')).toHaveAttribute('lang', 'nb-NO'); await expect(fields(page, 'nb').to).toHaveValue(edited.to); await expect(fields(page, 'nb').subject).toHaveValue(edited.subject); await expect(fields(page, 'nb').body).toHaveValue(edited.bodyText); expect(reads(session)).toHaveLength(readCount); expect(await getDraft(page.request, original)).toEqual(original); await capture(page, info, { ...desktop, language: 'nb' }, 'strict-language-dirty-draft');
const restored = waitResponse(page, 'PUT', '/api/auth/preferences/language'); await keyboard(page, page.locator('header').first().getByRole('button', { name: 'English', exact: true })); const actualRestored = await restored; await info.attach('compose-second-language-http', { body: JSON.stringify({ status: actualRestored.status(), body: await actualRestored.text() }), contentType: 'application/json' }); expect(actualRestored.status()).toBe(204); await expect(fields(page).body).toHaveValue(edited.bodyText); expect(reads(session)).toHaveLength(readCount); expect(session.requests.filter(value => value.method !== 'GET' && value.path.startsWith('/api/email/'))).toEqual([]);
});
test('[P7-06 compose] eight finite bilingual theme widths retain long drafts and keyboard setup review Cancel with complete axe vectors', async ({ page, session }, info) => {
test.setTimeout(300_000); const parent = await job(page.request, session.owner, 'Samfunnskritisk plattformutvikling med internasjonale samarbeidspartnere Æ Ø Å'); const original = await stored(page.request, session.owner, parent, 'Langt e-postemne om samfunnskritiske tjenester og inkluderende arbeidsprosesser Æ Ø Å', ('Reviewed application message line; keep readable text and line breaks Æ Ø Å.\n').repeat(24)); await capableProjection(page);
const samples: Sample[] = [{ language: 'en', theme: 'light', width: 320, height: 740 }, { language: 'nb', theme: 'dark', width: 360, height: 844 }, { language: 'en', theme: 'dark', width: 390, height: 844 }, { language: 'nb', theme: 'light', width: 768, height: 1024 }, { language: 'en', theme: 'dark', width: 1024, height: 768 }, { language: 'nb', theme: 'light', width: 1280, height: 320 }, { language: 'en', theme: 'light', width: 1440, height: 900 }, { language: 'nb', theme: 'dark', width: 1920, height: 1080 }];
for (const sample of samples) {
await enter(page, session.owner, sample); await resume(page, original, sample.language); const t = translations[sample.language]; const controls = fields(page, sample.language); await controls.body.focus(); await expect(controls.body).toBeFocused(); await expect(controls.body).toHaveAttribute('maxlength', '200000'); await capture(page, info, sample, 'long-draft-keyboard');
if ([320, 768, 1440].includes(sample.width)) { await keyboard(page, page.getByRole('button', { name: t.emailInboxCompose, exact: true })); const choice = setup(page, sample.language).getByRole('combobox', { name: t.emailInboxJob, exact: true }); await choice.focus(); await expect(choice).toBeFocused(); await capture(page, info, sample, 'owned-setup-keyboard'); await keyboard(page, setup(page, sample.language).getByRole('button', { name: t.cancel, exact: true })); await expect(controls.body).toHaveValue(original.bodyText); }
if ([390, 1280].includes(sample.width)) { await keyboard(page, send(page, sample.language)); const dialog = page.getByRole('dialog', { name: t.emailInboxConfirmSendTitle, exact: true }); await expect(dialog).toContainText(original.to); await capture(page, info, sample, 'send-review-keyboard'); await keyboard(page, dialog.getByRole('button', { name: t.cancel, exact: true })); await expect(send(page, sample.language)).toBeFocused(); await send(page, sample.language).scrollIntoViewIfNeeded(); const position = await send(page, sample.language).boundingBox(); expect(position).not.toBeNull(); expect(position!.y).toBeGreaterThanOrEqual(0); expect(position!.y + position!.height).toBeLessThanOrEqual(sample.height); await info.attach(`p706-compose-${sample.language}-${sample.width}-focused-viewport`, { body: await page.screenshot({ fullPage: false, animations: 'disabled' }), contentType: 'image/png' }); }
}
expect(session.requests.filter(value => value.method !== 'GET' && value.path.startsWith('/api/email/'))).toEqual([]); expect(await getDraft(page.request, original)).toEqual(original);
});