import AxeBuilder from '@axe-core/playwright'; import { expect, test as base, type APIRequestContext, type Page, type TestInfo } from '@playwright/test'; import { translations } from '../src/i18n/translations'; import type { CvVariant } from '../src/cvBuilder'; const apiUrl = 'http://localhost:5302/api'; const password = 'E2ePassword123!'; type Owner = { id: string; email: string; headers: Record }; type Sample = { language: 'en' | 'nb'; theme: 'light' | 'dark'; width: number; height: number }; async function login(api: APIRequestContext, email: string): Promise { expect((await api.post(`${apiUrl}/auth/login`, { data: { email, password } })).status()).toBe(200); const response = await api.get(`${apiUrl}/auth/me`); expect(response.status()).toBe(200); const me = await response.json(); const csrf = (await api.storageState()).cookies.find(cookie => cookie.name === 'XSRF-TOKEN'); expect(csrf).toBeDefined(); return { id: me.id, email, headers: { 'X-CSRF-TOKEN': csrf!.value, 'X-Session-Context': me.sessionContext } }; } async function createOwner(api: APIRequestContext, headers: Record) { const email = `p7-career-${Date.now()}-${Math.random().toString(36).slice(2)}@example.test`; expect((await api.post(`${apiUrl}/users`, { headers, data: { email, password, roles: [] } })).status()).toBe(200); return email; } const test = base.extend<{ owner: Owner }>({ owner: async ({ page, context, playwright }, provide) => { const admin = await playwright.request.newContext(); try { const adminOwner = await login(admin, 'e2e@example.test'); const owner = await login(page.request, await createOwner(admin, adminOwner.headers)); await context.route('**/*', route => { const url = new URL(route.request().url()); return ['localhost', '127.0.0.1'].includes(url.hostname) || ['data:', 'blob:', 'about:'].includes(url.protocol) ? route.continue() : route.abort(); }); await context.addInitScript(() => { if (!localStorage.getItem('uiLanguage')) localStorage.setItem('uiLanguage', 'en'); if (!localStorage.getItem('jobtracker.themeMode')) localStorage.setItem('jobtracker.themeMode', 'light'); }); await provide(owner); } finally { await admin.dispose(); } }, }); async function enter(page: Page, owner: Owner, sample: Sample, section = 'overview') { 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.setViewportSize({ width: sample.width, height: sample.height }); await page.goto(`/career?section=${section}&source=phase7#preserved`); await page.evaluate(({ language, theme }) => { localStorage.setItem('uiLanguage', language); localStorage.setItem('jobtracker.themeMode', theme); }, sample); await page.emulateMedia({ reducedMotion: 'reduce' }); 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); } async function navigateSection(page: Page, language: Sample['language'], section: 'overview' | 'profile' | 'evidence' | 'import') { const t = translations[language]; const label = { overview: t.careerWorkspaceOverviewTab, profile: t.careerWorkspaceProfileTab, evidence: t.careerWorkspaceEvidenceTab, import: t.careerWorkspaceImportTab }[section]; const selector = page.getByRole('combobox', { name: t.careerWorkspaceNavigation, exact: true }); if (await selector.isVisible()) { await selector.click(); await page.getByRole('option', { name: label, exact: true }).click(); } else { await page.getByRole('tab', { name: label, exact: true }).click(); } const url = new URL(page.url()); expect(url.searchParams.get('section')).toBe(section === 'overview' ? null : section); expect(url.searchParams.get('source')).toBe('phase7'); expect(url.hash).toBe('#preserved'); } 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) })); const axe = await new AxeBuilder({ page }).options({ rules: { 'target-size': { enabled: true } } }).analyze(); const name = `p7-career-${sample.language}-${sample.theme}-${sample.width}-${state}`; await info.attach(`${name}-audit`, { body: JSON.stringify({ sample, state, geometry, ariaSnapshot: await page.getByRole('main').ariaSnapshot(), axe: { scope: 'All enabled rules plus target-size; complete findings retained', violations: axe.violations, incomplete: axe.incomplete, passes: axe.passes.map(rule => ({ id: rule.id, nodes: rule.nodes.length })) }, limits: ['No human screen reader or physical touch/software keyboard', 'No actual browser zoom or production font/photo qualification', 'Controlled faults are explicit; no model/provider invocation'], }), contentType: 'application/json' }); await info.attach(name, { body: await page.screenshot({ fullPage: true, animations: 'disabled' }), contentType: 'image/png' }); expect(geometry.rootOverflow).toBe(0); } const matrix: Sample[] = [ { language: 'nb', theme: 'dark', width: 320, height: 844 }, { language: 'en', theme: 'light', width: 360, height: 800 }, { language: 'nb', theme: 'light', width: 390, height: 844 }, { language: 'en', theme: 'dark', width: 768, height: 1024 }, { language: 'nb', theme: 'dark', width: 1024, height: 768 }, { language: 'en', theme: 'light', width: 1280, height: 320 }, { language: 'nb', theme: 'light', width: 1440, height: 900 }, { language: 'en', theme: 'dark', width: 1920, height: 1080 }, ]; test('[P7-02 PRES-C04 E09] real SQLite evidence list preserves owner boundaries and existing validation', async ({ page, owner, playwright }, info) => { const created = await page.request.post(`${apiUrl}/career/evidence`, { headers: owner.headers, data: { category: 'project', title: 'Owner A evidence', statement: 'Recorded, reviewed fact.', tags: [' C# ', 'c#', 'Norsk'], sourceType: 'manual', sourceReference: ' Retrospective ', isVerified: true } }); expect(created.status()).toBe(200); const item = await created.json(); const listed = await page.request.get(`${apiUrl}/career/evidence`); expect(listed.status(), 'the actual local SQLite HTTP list must succeed').toBe(200); expect(await listed.json()).toEqual([item]); expect(item.tags).toEqual(['C#', 'Norsk']); expect(item.sourceReference).toBe('Retrospective'); const admin = await playwright.request.newContext(); const other = await playwright.request.newContext(); const anonymous = await playwright.request.newContext(); try { const adminOwner = await login(admin, 'e2e@example.test'); const otherOwner = await login(other, await createOwner(admin, adminOwner.headers)); expect(await (await other.get(`${apiUrl}/career/evidence?ownerUserId=${encodeURIComponent(owner.id)}`)).json()).toEqual([]); expect((await other.put(`${apiUrl}/career/evidence/${item.id}`, { headers: otherOwner.headers, data: { title: 'Foreign overwrite', statement: 'Denied' } })).status()).toBe(404); expect((await other.delete(`${apiUrl}/career/evidence/${item.id}`, { headers: otherOwner.headers })).status()).toBe(404); expect((await anonymous.get(`${apiUrl}/career/evidence`)).status()).toBe(401); expect((await page.request.post(`${apiUrl}/career/evidence`, { headers: owner.headers, data: { title: ' ', statement: 'Not saved' } })).status()).toBe(400); expect((await page.request.post(`${apiUrl}/career/evidence`, { headers: owner.headers, data: { title: 'Not saved', statement: ' ' } })).status()).toBe(400); expect(await (await page.request.get(`${apiUrl}/career/evidence`)).json()).toEqual([item]); await info.attach('native-evidence-owner-provider-contract', { body: JSON.stringify({ provider: 'Local Playwright SQLite', list200: true, foreignUpdate404: true, foreignDelete404: true, anonymous401: true, invalidCreate400: true, noForeignMutation: true, qualification: 'Actual preceding HTTP assertions; no InMemory or production-provider inference' }), contentType: 'application/json' }); } finally { await admin.dispose(); await other.dispose(); await anonymous.dispose(); } }); for (const sample of matrix) { test(`[P7-02 PRES-C01 C02 C04 C05 E08 E09] ${sample.language}/${sample.theme} ${sample.width}px overview and factual evidence preserve canonical source and recent CV links`, async ({ page, owner }, info) => { test.setTimeout(120_000); const profile = { version: '1', contact: { fullName: 'Åse Kunnskapsbank', headline: 'Canonical source' }, jobs: [{ title: 'Engineer', company: 'Dokumentasjon AS', bullets: ['Recorded work, no invented outcomes.'] }], skills: ['C#', 'Norsk'] }; expect((await page.request.put(`${apiUrl}/career/profile`, { headers: owner.headers, data: { profile } })).status()).toBe(200); const before = await (await page.request.get(`${apiUrl}/career/profile`)).json(); const cvs: CvVariant[] = []; for (let i = 0; i < 4; i++) { const r = await page.request.post(`${apiUrl}/cv/variants`, { headers: owner.headers, data: { name: `CV ${i + 1} — ${'Lang dokumenttittel '.repeat(5)}` } }); expect(r.status()).toBe(200); cvs.push(await r.json()); } const summariesResponse = await page.request.get(`${apiUrl}/cv/variants`); expect(summariesResponse.status()).toBe(200); const authoritativeRecent = (await summariesResponse.json()).slice(0, 3).map((cv: { id: number }) => `/career/builder/${cv.id}`); const item = await page.request.post(`${apiUrl}/career/evidence`, { headers: owner.headers, data: { title: 'Et dokumentert prosjekt med en lang tittel — æøå '.repeat(4), statement: 'Faktisk dokumentasjon fra et gjennomført prosjekt. '.repeat(8), category: 'project', tags: ['Tilgjengelighet', 'Systematisk dokumentasjon'], sourceType: 'manual', sourceReference: 'Prosjektgjennomgang — æøå', isVerified: true, } }); expect(item.status()).toBe(200); const evidence = await item.json(); if (sample.width === 1440) { for (let i = 1; i <= 10; i++) { expect((await page.request.post(`${apiUrl}/career/evidence`, { headers: owner.headers, data: { title: `Additional owned evidence ${i}`, statement: 'Another supported statement. '.repeat(8), sourceType: 'manual', isVerified: true, } })).status()).toBe(200); } expect(await (await page.request.get(`${apiUrl}/career/evidence`)).json()).toHaveLength(11); } await enter(page, owner, sample); const t = translations[sample.language]; await expect(page.getByRole('heading', { name: t.careerWorkspaceOverviewTab, level: 2 })).toBeVisible(); await expect(page.getByRole('progressbar', { name: t.careerOverviewCompletenessLabel })).toHaveAttribute('aria-valuenow', String(before.completeness.percent)); const recent = page.getByRole('region', { name: t.careerOverviewRecentCvs, exact: true }); await expect(recent.getByRole('link', { name: t.cvDashboardOpen, exact: true })).toHaveCount(3); const links = await recent.getByRole('link', { name: t.cvDashboardOpen, exact: true }).evaluateAll(nodes => nodes.map(node => node.getAttribute('href'))); expect(new Set(links).size).toBe(3); expect(links).toEqual(authoritativeRecent); await capture(page, info, sample, 'populated-overview'); await navigateSection(page, sample.language, 'evidence'); await expect(page.getByRole('heading', { name: t.careerEvidenceTitle, level: 2 })).toBeVisible(); await expect(page.getByText(evidence.title, { exact: true })).toBeVisible(); await expect(page.getByLabel(t.careerEvidenceStatement)).toBeVisible(); if (sample.width === 1440) await expect(page.getByText('Additional owned evidence 10', { exact: true })).toBeVisible(); await capture(page, info, sample, 'long-evidence'); expect(await (await page.request.get(`${apiUrl}/career/profile`)).json()).toEqual(before); for (const cv of cvs) expect(await (await page.request.get(`${apiUrl}/cv/variants/${cv.id}`)).json()).toEqual(cv); await page.reload(); await expect(page.getByText(evidence.title, { exact: true })).toBeVisible(); }); } for (const failedPath of ['/career/profile', '/profile-cv/runs', '/cv/variants']) { test(`[P7-02 PRES-C02 E09] unavailable ${failedPath} stays unknown and retries its reads without writes or false first-run guidance`, async ({ page, owner }, info) => { const sample: Sample = { language: 'en', theme: 'dark', width: 390, height: 844 }; const actualProfile = await (await page.request.get(`${apiUrl}/career/profile`)).json(); let fail = true; let reads = 0; await page.route(`**/api${failedPath}`, async route => { if (route.request().method() === 'GET') { reads++; if (fail) return route.fulfill({ status: 500, contentType: 'application/json', body: JSON.stringify({ message: 'Controlled source read unavailable' }) }); } return route.continue(); }); await enter(page, owner, sample); const t = translations.en; const message = failedPath === '/career/profile' ? t.careerOverviewUnavailable : failedPath === '/profile-cv/runs' ? t.careerOverviewImportUnavailable : t.careerOverviewRecentFailed; const alert = page.getByRole('alert').filter({ hasText: message }); await expect(alert).toBeVisible(); await expect(page.getByText(t.careerOverviewFirstRun, { exact: true })).toHaveCount(0); if (failedPath === '/career/profile') await expect(page.getByRole('progressbar', { name: t.careerOverviewCompletenessLabel })).toHaveCount(0); if (failedPath === '/profile-cv/runs') await expect(page.getByText(t.careerOverviewNoImport, { exact: true })).toHaveCount(0); if (failedPath === '/cv/variants') await expect(page.getByText(t.careerOverviewNoCvs, { exact: true })).toHaveCount(0); await capture(page, info, sample, `unavailable-${failedPath.split('/').pop()}`); const writes: string[] = []; page.on('request', request => { if (request.url().startsWith(apiUrl) && !['GET', 'HEAD', 'OPTIONS'].includes(request.method())) writes.push(request.method() + ' ' + request.url()); }); const beforeReads = reads; fail = false; await alert.getByRole('button', { name: t.retry, exact: true }).focus(); await page.keyboard.press('Enter'); await expect(alert).toHaveCount(0); const focusRegion = failedPath === '/career/profile' ? t.careerOverviewProfileStatus : failedPath === '/profile-cv/runs' ? t.careerOverviewCvImport : t.careerOverviewRecentCvs; const focusLabel = failedPath === '/career/profile' ? t.careerOverviewImproveProfile : failedPath === '/profile-cv/runs' ? t.careerOverviewImportCv : t.careerOverviewViewAll; await expect(page.getByRole('region', { name: focusRegion, exact: true }).getByRole('link', { name: focusLabel, exact: true })).toBeFocused(); await expect(page.getByRole('progressbar', { name: t.careerOverviewCompletenessLabel })).toHaveAttribute('aria-valuenow', String(actualProfile.completeness.percent)); expect(reads).toBe(beforeReads + 1); expect(writes).toEqual([]); }); } test('[P7-02 PRES-C04 E09] evidence pending/failure/read retry and failed save preserve drafts across section changes, cancellation and actual deletion', async ({ page, owner }, info) => { test.setTimeout(120_000); const sample: Sample = { language: 'en', theme: 'light', width: 390, height: 844 }; expect((await page.request.put(`${apiUrl}/career/profile`, { headers: owner.headers, data: { profile: { version: '1', contact: { fullName: 'Independent canonical source' }, skills: ['Original source fact'] }, cvText: 'Original raw source', } })).status()).toBe(200); const canonicalBefore = await (await page.request.get(`${apiUrl}/career/profile`)).json(); const retainedCvs: CvVariant[] = []; for (const name of ['Selected CV', 'Sibling CV']) { const response = await page.request.post(`${apiUrl}/cv/variants`, { headers: owner.headers, data: { name } }); expect(response.status()).toBe(200); retainedCvs.push(await response.json()); } let readFailure = true; let holdRead = false; let releaseRead!: () => void; const delayedRead = new Promise(resolve => { releaseRead = resolve; }); let saveFailure = true; const payloads: unknown[] = []; await page.route('**/api/career/evidence', async route => { const request = route.request(); if (request.method() === 'GET' && readFailure) return route.fulfill({ status: 500, contentType: 'application/json', body: JSON.stringify({ message: 'Controlled evidence read unavailable' }) }); if (request.method() === 'GET' && holdRead) await delayedRead; if (request.method() === 'POST') { payloads.push(request.postDataJSON()); if (saveFailure) return route.fulfill({ status: 500, contentType: 'application/json', body: JSON.stringify({ message: 'Controlled evidence write unavailable' }) }); } return route.continue(); }); await enter(page, owner, sample, 'evidence'); const t = translations.en; const readAlert = page.getByRole('alert').filter({ hasText: 'Controlled evidence read unavailable' }); await expect(readAlert).toBeVisible(); await expect(page.getByText(t.careerEvidenceEmpty, { exact: true })).toHaveCount(0); readFailure = false; holdRead = true; await readAlert.getByRole('button', { name: t.retry, exact: true }).click(); const bank = page.getByRole('region', { name: t.careerEvidenceTitle, exact: true }); await expect(bank.getByText(t.loading, { exact: true })).toBeVisible(); await expect(page.getByText(t.careerEvidenceEmpty, { exact: true })).toHaveCount(0); await expect(page.getByRole('button', { name: t.careerEvidenceSave, exact: true })).toBeDisabled(); await capture(page, info, sample, 'pending-evidence-read'); releaseRead(); await expect(page.getByText(t.careerEvidenceEmpty, { exact: true })).toBeVisible(); await page.getByLabel(t.careerEvidenceItemTitle).fill('Reviewed project — æøå'); await page.getByLabel(t.careerEvidenceStatement).fill('A fact supplied by the owner.'); await page.getByLabel(t.careerEvidenceTags, { exact: true }).fill('C#, Norsk'); await page.getByLabel(t.careerEvidenceSource, { exact: true }).fill('Project retrospective'); await page.getByRole('button', { name: t.careerEvidenceSave, exact: true }).click(); const saveAlert = page.getByRole('alert').filter({ hasText: 'Controlled evidence write unavailable' }); await expect(saveAlert).toBeVisible(); await expect(saveAlert.getByRole('button', { name: t.retry, exact: true })).toHaveCount(0); await capture(page, info, sample, 'failed-save-retained-draft'); await navigateSection(page, 'en', 'overview'); await navigateSection(page, 'en', 'evidence'); await expect(page.getByLabel(t.careerEvidenceItemTitle)).toHaveValue('Reviewed project — æøå'); await expect(page.getByLabel(t.careerEvidenceStatement)).toHaveValue('A fact supplied by the owner.'); saveFailure = false; const saved = page.waitForResponse(response => response.url() === `${apiUrl}/career/evidence` && response.request().method() === 'POST'); await page.getByRole('button', { name: t.careerEvidenceSave, exact: true }).focus(); await page.keyboard.press('Enter'); expect((await saved).status()).toBe(200); expect(payloads).toEqual(Array(2).fill({ category: 'achievement', title: 'Reviewed project — æøå', statement: 'A fact supplied by the owner.', tags: ['C#', ' Norsk'], sourceType: 'manual', sourceReference: 'Project retrospective', isVerified: true })); const [item] = await (await page.request.get(`${apiUrl}/career/evidence`)).json(); await expect(page.getByText(item.title, { exact: true })).toBeVisible(); await expect(page.getByLabel(t.careerEvidenceItemTitle)).toHaveValue(''); expect(await (await page.request.get(`${apiUrl}/career/profile`)).json()).toEqual(canonicalBefore); for (const cv of retainedCvs) expect(await (await page.request.get(`${apiUrl}/cv/variants/${cv.id}`)).json()).toEqual(cv); const row = page.getByRole('article').filter({ hasText: item.title }); const remove = row.getByRole('button', { name: t.careerEvidenceDeleteMessage.replace('{title}', item.title), exact: true }); await remove.focus(); await page.keyboard.press('Enter'); const dialog = page.getByRole('dialog', { name: t.careerEvidenceDeleteTitle, exact: true }); await expect(dialog).toBeVisible(); await dialog.getByRole('button', { name: t.cancel, exact: true }).click(); await expect(remove).toBeFocused(); expect(await (await page.request.get(`${apiUrl}/career/evidence`)).json()).toEqual([item]); let deleteFailure = true; await page.route(`**/api/career/evidence/${item.id}`, route => route.request().method() === 'DELETE' && deleteFailure ? route.fulfill({ status: 500, contentType: 'application/json', body: JSON.stringify({ message: 'Controlled evidence deletion unavailable' }) }) : route.continue()); await remove.click(); const failedDelete = page.waitForResponse(response => response.url() === `${apiUrl}/career/evidence/${item.id}` && response.request().method() === 'DELETE'); await dialog.getByRole('button', { name: t.deleteAction, exact: true }).click(); expect((await failedDelete).status()).toBe(500); const deleteAlert = row.getByRole('alert').filter({ hasText: 'Controlled evidence deletion unavailable' }); await expect(deleteAlert).toBeVisible(); expect(await (await page.request.get(`${apiUrl}/career/evidence`)).json()).toEqual([item]); await capture(page, info, sample, 'failed-delete-retained-row'); deleteFailure = false; await deleteAlert.getByRole('button', { name: t.retry, exact: true }).focus(); await page.keyboard.press('Enter'); await expect(dialog).toBeVisible(); const deleted = page.waitForResponse(response => response.url() === `${apiUrl}/career/evidence/${item.id}` && response.request().method() === 'DELETE'); await dialog.getByRole('button', { name: t.deleteAction, exact: true }).click(); expect((await deleted).status()).toBe(204); await expect(page.getByText(t.careerEvidenceEmpty, { exact: true })).toBeVisible(); await expect(page.getByLabel(t.careerEvidenceItemTitle)).toBeFocused(); await page.reload(); await expect(page.getByText(t.careerEvidenceEmpty, { exact: true })).toBeVisible(); expect(await (await page.request.get(`${apiUrl}/career/profile`)).json()).toEqual(canonicalBefore); for (const cv of retainedCvs) expect(await (await page.request.get(`${apiUrl}/cv/variants/${cv.id}`)).json()).toEqual(cv); }); test('[P7-02 PRES-C01 C02] existing raw source copy and history entry remain read-only', async ({ page, owner, context }, info) => { const sample: Sample = { language: 'en', theme: 'light', width: 1440, height: 900 }; const profile = { version: '1', contact: { fullName: 'Canonical source' }, skills: ['Recorded skill'] }; const raw = 'Exact raw source — æøå\nA supported owner claim.\n'; expect((await page.request.put(`${apiUrl}/career/profile`, { headers: owner.headers, data: { profile: { ...profile, skills: ['Recorded skill', 'Recorded later skill'] }, cvText: raw } })).status()).toBe(200); expect((await page.request.put(`${apiUrl}/career/profile`, { headers: owner.headers, data: { profile, cvText: raw } })).status()).toBe(200); const before = await (await page.request.get(`${apiUrl}/career/profile`)).json(); const history = await (await page.request.get(`${apiUrl}/career/profile/versions`)).json(); await context.grantPermissions(['clipboard-read', 'clipboard-write']); await enter(page, owner, sample); await navigateSection(page, 'en', 'import'); const t = translations.en; await page.getByRole('button', { name: new RegExp(t.profileCvRawPanelTitle) }).click(); await page.getByRole('button', { name: t.profileCopyCvText, exact: true }).click(); const copied = await page.evaluate(() => navigator.clipboard.readText()); await info.attach('native-clipboard-line-endings', { body: JSON.stringify({ requestedSource: raw, copied, requestedCodePoints: [...raw].map(value => value.codePointAt(0)), copiedCodePoints: [...copied].map(value => value.codePointAt(0)), qualification: 'Windows clipboard may convert LF to CRLF; preserve exact text after this explicit platform normalization, without trimming or other changes' }), contentType: 'application/json' }); expect(copied.replace(/\r\n/g, '\n')).toBe(raw); await navigateSection(page, 'en', 'profile'); const label = t.profileCvVersionHistory.replace('{count}', String(history.length)); await page.getByRole('button', { name: label, exact: true }).click(); await expect(page.getByRole('button', { name: new RegExp(`^${t.careerRestoreVersion.replace('{version}', '\\d+')}$`) }).first()).toBeVisible(); expect(await (await page.request.get(`${apiUrl}/career/profile`)).json()).toEqual(before); expect(await (await page.request.get(`${apiUrl}/career/profile/versions`)).json()).toEqual(history); await info.attach('native-career-source-history-entry', { body: JSON.stringify({ clipboardContentPreservedWithExplicitWindowsLineEndingNormalization: true, historyEntryVisible: true, canonicalAndHistoryUnchanged: true, qualification: 'Browser clipboard permission granted; exact OS clipboard bytes/human acceptance not claimed; restore/save/import-review migration is P7-03' }), contentType: 'application/json' }); });