diff --git a/frontend/src/__tests__/api-insurance.test.ts b/frontend/src/__tests__/api-insurance.test.ts new file mode 100644 index 00000000..bf0b27ca --- /dev/null +++ b/frontend/src/__tests__/api-insurance.test.ts @@ -0,0 +1,18 @@ +import { getInsuranceComparison, getInsuranceStatus } from '../api/insurance'; +import { apiRequest } from '../api/client'; + +jest.mock('../api/client', () => ({ apiRequest: jest.fn() })); + +describe('insurance api', () => { + beforeEach(() => jest.resetAllMocks()); + + it('status is a plain GET with no query or body', async () => { + await getInsuranceStatus(); + expect(apiRequest).toHaveBeenCalledWith('/insurance/status'); + }); + + it('comparison is a plain GET with no query or body', async () => { + await getInsuranceComparison(); + expect(apiRequest).toHaveBeenCalledWith('/insurance/comparison'); + }); +}); diff --git a/frontend/src/__tests__/archera-comparison.test.ts b/frontend/src/__tests__/archera-comparison.test.ts new file mode 100644 index 00000000..6df31cc0 --- /dev/null +++ b/frontend/src/__tests__/archera-comparison.test.ts @@ -0,0 +1,677 @@ +/** + * Archera comparison section (issue #785). The fixture is the Go DTO golden + * file read from its Go path, so the UI is tested against the real shape. + * Success rendering is therefore fixture-based; no live Archera calls. + */ +import * as fs from 'fs'; +import * as path from 'path'; +import { buildComparisonView, renderComparisonSection } from '../archera-comparison'; +import { getInsuranceComparison, getInsuranceStatus } from '../api/insurance'; +import type { ArcheraComparison, ArcheraOffer, ArcheraProductSupport } from '../api/types'; + +jest.mock('../api/insurance', () => ({ + getInsuranceStatus: jest.fn(), + getInsuranceComparison: jest.fn(), +})); + +const status = getInsuranceStatus as jest.Mock; +const comparison = getInsuranceComparison as jest.Mock; + +const GOLDEN_PATH = path.resolve(__dirname, '../../../internal/archera/testdata/comparison.golden.json'); +const golden = JSON.parse(fs.readFileSync(GOLDEN_PATH, 'utf8')) as ArcheraComparison; + +// satisfies makes tsc fail when the TS DTO gains or loses a key; the +// runtime check below fails when the Go golden file drifts from the TS keys. +const TOP_KEYS = { + title: 1, plan_id: 1, fetched_at: 1, currency: 1, currency_note: 1, premium_included: 1, + basis_note: 1, delta_basis_note: 1, current: 1, hypotheticals: 1, rows: 1, + non_gating_disclosure: 1, sponsorship_disclosure: 1, +} satisfies Record; +const SUPPORT_KEYS = { + status: 1, source: 1, evidence: 1, underwriting_allowance: 1, customer_eligibility: 1, +} satisfies Record; + +function clone(): ArcheraComparison { + return JSON.parse(JSON.stringify(golden)) as ArcheraComparison; +} + +function firstOffer(c: ArcheraComparison): ArcheraOffer { + const row = c.rows[0]; + if (!row) throw new Error('golden has no rows'); + return row.current; +} + +async function mount(): Promise { + document.body.replaceChildren(); + const root = document.createElement('div'); + document.body.appendChild(root); + await renderComparisonSection(root); + return root; +} + +async function clickAndLoad(root: HTMLElement, data: ArcheraComparison): Promise { + comparison.mockResolvedValueOnce(data); + root.querySelector('button')!.click(); + await flush(); +} + +async function flush(): Promise { + for (let i = 0; i < 5; i++) await Promise.resolve(); +} + +beforeEach(() => { + jest.resetAllMocks(); + status.mockResolvedValue({ configured: true, missing: [] }); +}); + +describe('golden drift', () => { + it('golden keys equal the TS DTO keys', () => { + expect(Object.keys(golden).sort()).toEqual(Object.keys(TOP_KEYS).sort()); + const ps = firstOffer(golden).archera_product_support; + expect(Object.keys(ps).sort()).toEqual(Object.keys(SUPPORT_KEYS).sort()); + }); +}); + +describe('status gating', () => { + it('configured=false: nothing rendered and no comparison request', async () => { + status.mockResolvedValue({ configured: false, missing: ['ARCHERA_ORG_ID'] }); + const root = await mount(); + expect(root.innerHTML).toBe(''); + expect(comparison).not.toHaveBeenCalled(); + }); + + it('404 (scoped session): nothing rendered and no comparison request', async () => { + status.mockRejectedValue(Object.assign(new Error('not found'), { status: 404 })); + const root = await mount(); + expect(root.innerHTML).toBe(''); + expect(comparison).not.toHaveBeenCalled(); + }); + + it.each([500, 0])('status error %s: one unavailable line, no comparison request', async code => { + status.mockRejectedValue(Object.assign(new Error('boom'), { status: code })); + const root = await mount(); + expect(root.textContent).toBe('Archera comparison status unavailable'); + expect(root.querySelector('button')).toBeNull(); + expect(comparison).not.toHaveBeenCalled(); + }); + + it('configured: button only, no comparison request until clicked', async () => { + const root = await mount(); + expect(root.querySelector('button')!.textContent).toBe('Compare with Archera'); + expect(comparison).not.toHaveBeenCalled(); + }); +}); + +describe('rendering the golden response', () => { + it('renders the DTO title, notes, both disclosures and fetched-at verbatim', async () => { + const root = await mount(); + await clickAndLoad(root, golden); + expect(root.querySelector('h2')!.textContent).toBe(golden.title); + const txt = root.textContent!; + for (const s of [golden.currency_note, golden.basis_note, golden.delta_basis_note, + golden.non_gating_disclosure, golden.sponsorship_disclosure, + firstOffer(golden).archera_offer_name_note!]) { + expect(txt).toContain(s); + } + expect(root.querySelector('.archera-fetched-at')!.textContent).toContain('Fetched at'); + expect(root.querySelector('.archera-fetched-at')!.textContent).toContain('9 Oct 2026'); + expect(txt).not.toContain('org_id'); + }); + + it('renders disclosures from the response, not a hardcoded copy', async () => { + const c = clone(); + c.non_gating_disclosure = 'DISCLOSURE-A-CHANGED'; + c.sponsorship_disclosure = 'DISCLOSURE-B-CHANGED'; + const root = await mount(); + await clickAndLoad(root, c); + expect(root.textContent).toContain('DISCLOSURE-A-CHANGED'); + expect(root.textContent).toContain('DISCLOSURE-B-CHANGED'); + expect(root.textContent).not.toContain(golden.non_gating_disclosure); + }); + + it('shows no currency symbol, USD or dollar wording of its own', async () => { + const c = clone(); + c.basis_note = 'one-time amounts'; + const root = await mount(); + await clickAndLoad(root, c); + expect(root.textContent).not.toMatch(/\$|USD|dollar/i); + }); + + it('uses captions and column/row header scopes on every table', async () => { + const root = await mount(); + await clickAndLoad(root, golden); + const tables = root.querySelectorAll('table'); + expect(tables.length).toBeGreaterThanOrEqual(3); + tables.forEach(t => { + expect(t.querySelector('caption')!.textContent).not.toBe(''); + t.querySelectorAll('th').forEach(th => expect(th.getAttribute('scope')).toMatch(/^(col|row)$/)); + }); + }); +}); + +describe('money', () => { + it('null renders Unknown, "0" stays 0, long decimals are verbatim', async () => { + const c = clone(); + c.current.monthly_730h_net_savings = null; + c.current.monthly_730h_gross_savings = '0'; + c.current.upfront_one_time_cost = '0.0000000000000000000000000001234567890123'; + const root = await mount(); + await clickAndLoad(root, c); + const rows = Array.from(root.querySelectorAll('table')[0]!.querySelectorAll('tr')); + const byLabel = (l: string) => rows.find(r => r.textContent!.startsWith(l))!.querySelector('td')!.textContent; + expect(byLabel('Net savings')).toBe('Unknown'); + expect(byLabel('Gross savings')).toBe('0'); + expect(byLabel('Upfront cost')).toBe('0.0000000000000000000000000001234567890123'); + }); +}); + +describe('money in every cell matches the golden value for that column', () => { + function grid(t: HTMLTableElement): Array> { + const heads = Array.from(t.querySelectorAll('thead th')).map(h => h.textContent!); + return Array.from(t.querySelectorAll('tbody tr')).map(tr => { + const out: Record = {}; + Array.from(tr.children).forEach((c, i) => { out[heads[i]!] = c.textContent!; }); + return out; + }); + } + const tableByCaption = (root: HTMLElement, cap: RegExp) => + Array.from(root.querySelectorAll('table')).find(t => cap.test(t.querySelector('caption')!.textContent!))!; + + it('current plan totals table', async () => { + const root = await mount(); + await clickAndLoad(root, golden); + const rows = Array.from(tableByCaption(root, /^Current plan totals/).querySelectorAll('tbody tr')) + .map(tr => [tr.children[0]!.textContent, tr.children[1]!.textContent]); + const c = golden.current; + expect(rows).toEqual([ + ['Commitment cost total (monthly)', c.monthly_730h_commitment_cost_total], + ['Cloud provider cost (monthly)', c.monthly_730h_cloud_provider_cost], + ['Archera premium (monthly)', c.monthly_730h_archera_premium], + ['Gross savings (monthly)', c.monthly_730h_gross_savings], + ['Net savings (monthly)', c.monthly_730h_net_savings], + ['Covered on-demand cost (monthly)', c.monthly_730h_covered_on_demand_cost], + ['Upfront cost (one-time)', c.upfront_one_time_cost], + ]); + expect(new Set(rows.map(r => r[1])).size).toBe(7); + }); + + it('hypotheticals table', async () => { + const root = await mount(); + await clickAndLoad(root, golden); + const g = grid(tableByCaption(root, /^Hypothetical/)); + expect(g).toHaveLength(golden.hypotheticals.length); + golden.hypotheticals.forEach((h, i) => { + const r = g[i]!; + expect(r['Contract term']).toBe(h.contract_term); + expect(r['Payment option']).toBe(h.payment_option); + expect(r['Commitment cost total (monthly)']).toBe(h.totals.monthly_730h_commitment_cost_total); + expect(r['Net savings (monthly)']).toBe(h.totals.monthly_730h_net_savings); + expect(r['Upfront cost (one-time)']).toBe(h.totals.upfront_one_time_cost); + expect(r['Net savings change vs current plan (monthly)']).toBe(h.delta_vs_current.monthly_730h_net_savings); + expect(r['Commitment cost change vs current plan (monthly)']).toBe(h.delta_vs_current.monthly_730h_commitment_cost); + expect(r['Upfront change vs current plan (one-time)']).toBe(h.delta_vs_current.upfront_one_time_cost); + h.line_items.forEach(li => { + expect(r['Line items']).toContain(`${li.line_item_id}: ${li.reason} (term ${li.actual_term}, payment ${li.actual_payment_option}, type ${li.actual_commitment_type})`); + }); + }); + }); + + it('offer tables: current and every candidate', async () => { + const root = await mount(); + await clickAndLoad(root, golden); + golden.rows.forEach(row => { + const g = grid(tableByCaption(root, new RegExp(`^Offers for line item ${row.line_item_id}$`))); + const offers = [row.current, ...row.candidates]; + expect(g).toHaveLength(offers.length); + offers.forEach((o, i) => { + const r = g[i]!; + expect(r['Offer']).toBe(`${o.offer_id} (${o.is_current ? 'current' : 'candidate'})`); + expect(r['Provider / commitment type']).toBe(`${o.provider} / ${o.commitment_type}`); + expect(r['Region']).toBe(o.region); + expect(r['Term / payment option']).toBe(`${o.contract_term} / ${o.payment_option}`); + expect(r['Discount rate']).toBe(o.discount_rate); + expect(r['Breakeven days']).toBe(o.breakeven_days); + expect(r['Net savings (monthly)']).toBe(o.monthly.monthly_730h_net_savings); + expect(r['Upfront cost (one-time)']).toBe(o.upfront_one_time_cost); + expect(r['Net savings change vs current plan (monthly)']).toBe(o.delta_vs_current.monthly_730h_net_savings); + expect(r['Upfront change vs current plan (one-time)']).toBe(o.delta_vs_current.upfront_one_time_cost); + }); + }); + }); + + it('golden money values are distinct per column so a swap cannot hide', () => { + const vals = [golden.current, golden.hypotheticals[0]!.totals].flatMap(t => Object.values(t)); + expect(new Set(vals).size).toBe(vals.length); + }); +}); + +describe('fetched-at shows local time and UTC', () => { + const OPTS: Intl.DateTimeFormatOptions = { dateStyle: 'medium', timeStyle: 'long' }; + const instant = new Date(golden.fetched_at); + const utcText = new Intl.DateTimeFormat('en-GB', { ...OPTS, timeZone: 'UTC' }).format(instant); + const RealDTF = Intl.DateTimeFormat; + + afterEach(() => { + Intl.DateTimeFormat = RealDTF; + }); + + // jest cannot change the process zone, so the browser's default zone is + // simulated: formatters built without an explicit timeZone get `tz`. + async function fetchedAt(tz: string): Promise { + Intl.DateTimeFormat = function (locale?: string | string[], o?: Intl.DateTimeFormatOptions) { + return new RealDTF(locale, o?.timeZone ? o : { ...o, timeZone: tz }); + } as unknown as typeof Intl.DateTimeFormat; + const root = await mount(); + await clickAndLoad(root, golden); + return root.querySelector('.archera-fetched-at')!.textContent!; + } + + it('non-UTC zone: local text and UTC text are both present and exact', async () => { + const got = await fetchedAt('America/New_York'); + const local = new Intl.DateTimeFormat(undefined, { ...OPTS, timeZone: 'America/New_York' }).format(instant); + expect(local).not.toBe(utcText); + expect(utcText).toContain('12:00:00'); + expect(got).toBe(`Fetched at ${local} (${utcText})`); + }); + + it('UTC zone: exact text', async () => { + const got = await fetchedAt('UTC'); + const local = new Intl.DateTimeFormat(undefined, { ...OPTS, timeZone: 'UTC' }).format(instant); + expect(got).toBe(`Fetched at ${local} (${utcText})`); + }); + + it('UTC part stays in UTC when the browser zone is far away', async () => { + const got = await fetchedAt('Pacific/Auckland'); + expect(got.endsWith(`(${utcText})`)).toBe(true); + }); +}); + +describe('captions, notes, live text and unknown money in every cell', () => { + const captions = (root: HTMLElement) => + Array.from(root.querySelectorAll('caption')).map(c => c.textContent); + + it('offer table captions carry the line item id; empty is "Not given"; hostile stays text', async () => { + const root = await mount(); + await clickAndLoad(root, golden); + expect(captions(root)).toContain(`Offers for line item ${golden.rows[0]!.line_item_id}`); + + const empty = clone(); + empty.rows[0]!.line_item_id = ''; + const r2 = await mount(); + await clickAndLoad(r2, empty); + expect(captions(r2)).toContain('Offers for line item Not given'); + + const hostile = clone(); + hostile.rows[0]!.line_item_id = ''; + const r3 = await mount(); + await clickAndLoad(r3, hostile); + expect(captions(r3)).toContain('Offers for line item '); + expect(r3.querySelector('img')).toBeNull(); + }); + + it('shows the no-cache note', async () => { + const root = await mount(); + await clickAndLoad(root, golden); + expect(root.textContent).toContain('Archera does not document a cache lifetime. Use Refresh to fetch again.'); + }); + + it('live region text: loading, loaded; button stays Refresh after a later error', async () => { + const root = await mount(); + const button = root.querySelector('button')!; + const live = root.querySelector('.archera-comparison [aria-live="polite"]') ?? root.querySelector('[aria-live="polite"]')!; + let resolve!: (c: ArcheraComparison) => void; + comparison.mockReturnValueOnce(new Promise(r => { resolve = r; })); + button.click(); + expect(live.textContent).toBe('Loading Archera comparison'); + resolve(golden); + await flush(); + expect(live.textContent).toBe('Archera comparison loaded'); + expect(button.textContent).toBe('Refresh'); + comparison.mockRejectedValueOnce(Object.assign(new Error('x'), { status: 502 })); + button.click(); + await flush(); + expect(button.textContent).toBe('Refresh'); + }); + + function nullMoney(node: unknown, key = ''): unknown { + if (Array.isArray(node)) return node.map(x => nullMoney(x, key)); + if (node && typeof node === 'object') { + return Object.fromEntries(Object.entries(node).map(([k, v]) => [k, nullMoney(v, k)])); + } + return /^(monthly_730h_|upfront_one_time_cost$|discount_rate$|breakeven_days$)/.test(key) ? null : node; + } + + it('a null money value renders Unknown in every money cell of every table', async () => { + const root = await mount(); + await clickAndLoad(root, nullMoney(clone()) as ArcheraComparison); + const moneyHeads = /savings|cost|upfront|Upfront|Discount|Breakeven|premium|Amount/i; + let checked = 0; + root.querySelectorAll('table').forEach(t => { + const heads = Array.from(t.querySelectorAll('thead th')).map(h => h.textContent!); + t.querySelectorAll('tbody tr').forEach(tr => { + Array.from(tr.children).forEach((c, i) => { + const h = heads[i]!; + const rowLabel = tr.children[0]!.textContent!; + const isMoneyCell = h === 'Amount' ? true : (moneyHeads.test(h) && h !== 'Line items'); + if (isMoneyCell && !(h === 'Amount' && c === tr.children[0])) { + expect({ h, rowLabel, v: c.textContent }).toEqual({ h, rowLabel, v: 'Unknown' }); + checked++; + } + }); + }); + }); + // 7 totals + 7 hypothetical + 6 per offer * 2 offers + expect(checked).toBe(7 + 6 + 6 * 2); + }); +}); + +describe('multiple rows, candidates, hypotheticals, line items; refresh replaces', () => { + // Rewrites every money string to a unique value so any index mixup shows. + function big(seed: number): ArcheraComparison { + const c = clone(); + let n = seed; + const uniq = (node: unknown, key = ''): unknown => { + if (Array.isArray(node)) return node.map(x => uniq(x, key)); + if (node && typeof node === 'object') { + return Object.fromEntries(Object.entries(node).map(([k, v]) => [k, uniq(v, k)])); + } + return typeof node === 'string' && /^(monthly_730h_|upfront_one_time_cost$|discount_rate$|breakeven_days$)/.test(key) + ? String(n++) : node; + }; + const base = uniq(c) as ArcheraComparison; + const h0 = base.hypotheticals[0]!; + base.hypotheticals = [h0, JSON.parse(JSON.stringify(uniq(h0))) as typeof h0]; + base.hypotheticals.forEach((h, i) => { + h.contract_term = `term-${seed}-${i}`; + const li0 = h.line_items[0]!; + h.line_items = [0, 1].map(j => ({ ...li0, line_item_id: `li-${seed}-${i}-${j}`, reason: `reason-${i}-${j}` })); + }); + const r0 = base.rows[0]!; + const mk = (i: number): typeof r0 => { + const r = JSON.parse(JSON.stringify(uniq(r0))) as typeof r0; + r.line_item_id = `row-${seed}-${i}`; + const cand0 = r.candidates[0]!; + r.candidates = [0, 1].map(j => ({ ...JSON.parse(JSON.stringify(uniq(cand0))), offer_id: `cand-${seed}-${i}-${j}` })); + r.current.offer_id = `cur-${seed}-${i}`; + return r; + }; + base.rows = [mk(0), mk(1)]; + return base; + } + + function offerMoney(o: ArcheraOffer): string[] { + return [o.discount_rate, o.breakeven_days, o.monthly.monthly_730h_net_savings, o.upfront_one_time_cost, + o.delta_vs_current.monthly_730h_net_savings, o.delta_vs_current.upfront_one_time_cost] as string[]; + } + + it('every row, candidate, hypothetical and line item renders its own values in order', async () => { + const c = big(1000); + const root = await mount(); + await clickAndLoad(root, c); + const tables = Array.from(root.querySelectorAll('table')); + // totals + hypotheticals + one per row + expect(tables).toHaveLength(2 + c.rows.length); + const hyp = tables[1]!; + const hrows = Array.from(hyp.querySelectorAll('tbody tr')); + expect(hrows).toHaveLength(2); + c.hypotheticals.forEach((h, i) => { + const cells = Array.from(hrows[i]!.children).map(x => x.textContent); + expect(cells.slice(0, 2)).toEqual([h.contract_term, h.payment_option]); + expect(cells.slice(2, 8)).toEqual([ + h.totals.monthly_730h_commitment_cost_total, h.totals.monthly_730h_net_savings, h.totals.upfront_one_time_cost, + h.delta_vs_current.monthly_730h_net_savings, h.delta_vs_current.monthly_730h_commitment_cost, + h.delta_vs_current.upfront_one_time_cost]); + const lis = Array.from(hrows[i]!.children[8]!.children).map(x => x.textContent); + expect(lis).toHaveLength(2); + h.line_items.forEach((li, j) => expect(lis[j]).toContain(`${li.line_item_id}: ${li.reason}`)); + }); + c.rows.forEach((r, i) => { + const t = tables[2 + i]!; + expect(t.querySelector('caption')!.textContent).toBe(`Offers for line item ${r.line_item_id}`); + const trs = Array.from(t.querySelectorAll('tbody tr')); + const offers = [r.current, ...r.candidates]; + expect(trs).toHaveLength(3); + offers.forEach((o, k) => { + const cells = Array.from(trs[k]!.children).map(x => x.textContent); + expect(cells[0]).toContain(o.offer_id); + expect(cells.slice(7)).toEqual(offerMoney(o)); + }); + }); + expect(root.querySelectorAll('h3')).toHaveLength(2); + }); + + it('Refresh replaces the old view: one set of tables, new values, no stale ones', async () => { + const first = big(2000); + const second = big(3000); + const root = await mount(); + await clickAndLoad(root, first); + const oldVal = first.current.monthly_730h_net_savings!; + expect(root.textContent).toContain(`row-2000-0`); + comparison.mockResolvedValueOnce(second); + root.querySelector('button')!.click(); + await flush(); + expect(root.querySelectorAll('table')).toHaveLength(2 + second.rows.length); + expect(root.querySelectorAll('h2')).toHaveLength(1); + expect(root.textContent).toContain('row-3000-0'); + expect(root.textContent).not.toContain('row-2000-0'); + expect(root.textContent).toContain(second.current.monthly_730h_net_savings!); + expect(root.textContent).not.toContain(`li-2000`); + void oldVal; + }); +}); + +describe('aria-busy and empty vendor strings', () => { + it('aria-busy is absent after success and after error', async () => { + const root = await mount(); + const button = root.querySelector('button')!; + await clickAndLoad(root, golden); + expect(button.hasAttribute('aria-busy')).toBe(false); + comparison.mockRejectedValueOnce(Object.assign(new Error('fixed server text'), { status: 502 })); + button.click(); + expect(button.getAttribute('aria-busy')).toBe('true'); + await flush(); + expect(button.hasAttribute('aria-busy')).toBe(false); + }); + + const VENDOR_KEYS = new Set([ + 'offer_id', 'provider', 'commitment_type', 'region', 'contract_term', 'payment_option', + 'lease_menu_item_id', 'archera_offer_name', 'line_item_id', 'reason', 'actual_term', + 'actual_payment_option', 'actual_commitment_type', + ]); + function blank(node: unknown, key = ''): unknown { + if (typeof node === 'string') return VENDOR_KEYS.has(key) ? '' : node; + if (Array.isArray(node)) return node.map(x => blank(x, key)); + if (node && typeof node === 'object') { + return Object.fromEntries(Object.entries(node).map(([k, v]) => [k, blank(v, k)])); + } + return node; + } + + it('an empty vendor string renders "Not given" in every place it is shown', async () => { + const c = blank(clone()) as ArcheraComparison; + const root = await mount(); + await clickAndLoad(root, c); + const NG = 'Not given'; + const hyp = Array.from(root.querySelectorAll('table')).find(t => /^Hypothetical/.test(t.querySelector('caption')!.textContent!))!; + const hcells = Array.from(hyp.querySelectorAll('tbody tr')[0]!.children).map(x => x.textContent); + expect(hcells[0]).toBe(NG); + expect(hcells[1]).toBe(NG); + expect(hcells[8]).toBe(`${NG}: ${NG} (term ${NG}, payment ${NG}, type ${NG})`); + expect(root.querySelector('h3')!.textContent).toBe(`Line item ${NG}`); + expect(root.querySelector('caption + thead')).not.toBeNull(); + const offers = root.querySelectorAll('table')[2]!; + const trs = Array.from(offers.querySelectorAll('tbody tr')); + expect(trs.length).toBeGreaterThan(1); + trs.forEach((tr, i) => { + const cells = Array.from(tr.children).map(x => x.textContent); + expect(cells[0]).toBe(`${NG} (${i === 0 ? 'current' : 'candidate'})`); + expect(cells[1]).toBe(`${NG} / ${NG}`); + expect(cells[2]).toBe(NG); + expect(cells[3]).toBe(`${NG} / ${NG}`); + expect(cells[4]).toBe(i === 0 ? `lease attached (${NG})` : 'no lease'); + expect(cells[5]).toBe(NG); + }); + }); +}); + +describe('vendor strings are text only', () => { + const HOSTILE = (n: number) => `\u0007\u202e${n}`; + + // Replaces every string leaf except the enum-like ones that gate rendering. + function poison(node: unknown, tokens: Map, p: string): unknown { + if (typeof node === 'string') { + if (/(^|\.)(status|fetched_at)$/.test(p)) return node; + const v = HOSTILE(tokens.size); + tokens.set(p, v); + return v; + } + if (Array.isArray(node)) return node.map((x, i) => poison(x, tokens, `${p}[${i}]`)); + if (node && typeof node === 'object') { + return Object.fromEntries(Object.entries(node).map(([k, v]) => [k, poison(v, tokens, p ? `${p}.${k}` : k)])); + } + return node; + } + + // Leaves the UI deliberately does not display. + const NOT_RENDERED = /^(plan_id|rows\[\d+\]\.(current|candidates\[\d+\])\.(monthly\.monthly_730h_(commitment_cost_total|cloud_provider_cost|archera_premium|gross_savings|covered_on_demand_cost)|delta_vs_current\.(discount_rate|breakeven_days)|archera_product_support\.(underwriting_allowance|customer_eligibility))|hypotheticals\[\d+\]\.totals\.monthly_730h_(cloud_provider_cost|archera_premium|gross_savings|covered_on_demand_cost))$/; + + it('every rendered vendor string is text; no element is created from any of them', async () => { + const tokens = new Map(); + const c = poison(clone(), tokens, '') as ArcheraComparison; + const root = await mount(); + await clickAndLoad(root, c); + const rendered = root.textContent!; + const missing: string[] = []; + for (const [p, v] of tokens) { + if (!NOT_RENDERED.test(p) && !rendered.includes(v)) missing.push(p); + } + expect(missing).toEqual([]); + expect(root.querySelectorAll('img, script, svg, iframe, a').length).toBe(0); + // Only the structural elements the component itself builds exist. + const allowed = new Set(['H2', 'H3', 'P', 'DIV', 'TABLE', 'CAPTION', 'THEAD', 'TBODY', 'TR', 'TH', 'TD', 'SECTION', 'BUTTON']); + root.querySelectorAll('*').forEach(n => expect(allowed.has(n.tagName)).toBe(true)); + }); +}); + +describe('product support', () => { + it('supported shows source and evidence', async () => { + const root = await mount(); + await clickAndLoad(root, golden); + const ps = firstOffer(golden).archera_product_support; + expect(root.textContent).toContain('Supported (documented)'); + expect(root.textContent).toContain(ps.source!); + expect(root.textContent).toContain(ps.evidence!); + }); + + it.each(['unknown', 'unsupported', '', 'SUPPORTED', 'garbage'])('status %p renders not-known and never "unsupported"', async st => { + const c = clone(); + for (const r of c.rows) for (const o of [r.current, ...r.candidates]) { + o.archera_product_support = { + status: st, source: 'SRC-LEAK', evidence: 'EVD-LEAK', + underwriting_allowance: 'allowed', customer_eligibility: 'eligible', + }; + } + const root = await mount(); + await clickAndLoad(root, c); + const txt = root.textContent!; + expect(txt).toContain('Not known to be supported by Archera'); + expect(txt).not.toMatch(/unsupported/i); + expect(txt).not.toContain('SRC-LEAK'); + expect(txt).not.toContain('EVD-LEAK'); + expect(txt).not.toMatch(/allowed|eligible\b/); + expect(txt).toContain('Underwriting allowance: Unknown'); + expect(txt).toContain('Customer eligibility: Unknown'); + }); + + it('lease attached is labelled', async () => { + const root = await mount(); + await clickAndLoad(root, golden); + expect(root.textContent).toContain('lease attached (lease-cur)'); + }); +}); + +describe('refresh, errors and accessibility', () => { + it('refresh re-requests once per click; in-flight clicks are ignored; nothing polls', async () => { + jest.useFakeTimers(); + try { + const root = await mount(); + const button = root.querySelector('button')!; + let resolve!: (c: ArcheraComparison) => void; + comparison.mockReturnValueOnce(new Promise(r => { resolve = r; })); + button.click(); + expect(button.disabled).toBe(true); + expect(button.getAttribute('aria-busy')).toBe('true'); + button.click(); + expect(comparison).toHaveBeenCalledTimes(1); + resolve(golden); + await flush(); + expect(button.disabled).toBe(false); + expect(button.textContent).toBe('Refresh'); + expect(document.activeElement).toBe(button); + jest.advanceTimersByTime(10 * 60 * 1000); + await flush(); + expect(comparison).toHaveBeenCalledTimes(1); + comparison.mockResolvedValueOnce(golden); + button.click(); + await flush(); + expect(comparison).toHaveBeenCalledTimes(2); + } finally { + jest.useRealTimers(); + } + }); + + it('never schedules timers or intervals', async () => { + const si = jest.spyOn(global, 'setInterval'); + const st = jest.spyOn(global, 'setTimeout'); + const root = await mount(); + await clickAndLoad(root, golden); + expect(si).not.toHaveBeenCalled(); + expect(st).not.toHaveBeenCalled(); + si.mockRestore(); + st.mockRestore(); + }); + + it('429 with retry_after_seconds shows the wait; Refresh stays enabled', async () => { + const root = await mount(); + comparison.mockRejectedValueOnce(Object.assign(new Error('Archera rate limit reached'), + { status: 429, details: { retry_after_seconds: 30 } })); + root.querySelector('button')!.click(); + await flush(); + const live = root.querySelector('[aria-live="polite"]')!; + expect(live.textContent).toContain('Retry after 30 seconds'); + expect(root.querySelector('button')!.disabled).toBe(false); + }); + + it('429 without retry_after_seconds uses the real server text, saying so exactly once', async () => { + const root = await mount(); + // Exact text from mapInsuranceError (internal/api/handler_insurance_comparison.go). + comparison.mockRejectedValueOnce(Object.assign(new Error('Archera rate limit reached; retry-after not given'), { status: 429 })); + root.querySelector('button')!.click(); + await flush(); + const t = root.querySelector('[aria-live="polite"]')!.textContent!; + expect(t).toBe('Archera rate limit reached; retry-after not given'); + expect(t.match(/retry-after not given/gi)).toHaveLength(1); + }); + + it('429 whose message lacks the wait text gets it appended once', async () => { + const root = await mount(); + comparison.mockRejectedValueOnce(Object.assign(new Error('Rate limited'), { status: 429 })); + root.querySelector('button')!.click(); + await flush(); + expect(root.querySelector('[aria-live="polite"]')!.textContent!.match(/retry-after not given/gi)).toHaveLength(1); + }); + + it.each([502, 503])('server error %s message shown as text in the live region', async code => { + const root = await mount(); + comparison.mockRejectedValueOnce(Object.assign(new Error('fixed server text'), { status: code })); + root.querySelector('button')!.click(); + await flush(); + expect(root.querySelector('[aria-live="polite"]')!.textContent).toBe('fixed server text'); + }); + + it('unparseable fetched_at is shown raw', () => { + const c = clone(); + c.fetched_at = 'not-a-date'; + expect(buildComparisonView(c).querySelector('.archera-fetched-at')!.textContent).toBe('Fetched at not-a-date'); + }); +}); diff --git a/frontend/src/api/index.ts b/frontend/src/api/index.ts index b9e2b30a..154bbf48 100644 --- a/frontend/src/api/index.ts +++ b/frontend/src/api/index.ts @@ -253,3 +253,9 @@ export { setPlanAccounts, getFederationIaC } from './accounts'; + +// Re-export Archera comparison functions +export { + getInsuranceStatus, + getInsuranceComparison +} from './insurance'; diff --git a/frontend/src/api/insurance.ts b/frontend/src/api/insurance.ts new file mode 100644 index 00000000..a54cd71a --- /dev/null +++ b/frontend/src/api/insurance.ts @@ -0,0 +1,16 @@ +/** + * Archera comparison API functions (read-only, explicit user action). + */ + +import { apiRequest } from './client'; +import type { ArcheraComparison, InsuranceStatus } from './types'; + +/** Reports whether the comparison is configured. Makes no vendor call. */ +export async function getInsuranceStatus(): Promise { + return apiRequest('/insurance/status'); +} + +/** Fetches the Archera comparison now. One vendor request per call, no caching. */ +export async function getInsuranceComparison(): Promise { + return apiRequest('/insurance/comparison'); +} diff --git a/frontend/src/api/types.ts b/frontend/src/api/types.ts index e10824a2..bc0bc705 100644 --- a/frontend/src/api/types.ts +++ b/frontend/src/api/types.ts @@ -948,3 +948,105 @@ export interface RequestOptions extends RequestInit { */ timeoutMs?: number; } + +// --------------------------------------------------------------------------- +// Archera comparison (GET /api/insurance/*). Mirrors internal/archera/dto.go +// field for field; money is an exact decimal string or null (unknown, never 0). +// --------------------------------------------------------------------------- + +export interface InsuranceStatus { + configured: boolean; + missing: string[]; +} + +export interface ArcheraProductSupport { + status: string; + source?: string; + evidence?: string; + underwriting_allowance: string; + customer_eligibility: string; +} + +export interface ArcheraFinancials { + monthly_730h_commitment_cost_total: string | null; + monthly_730h_cloud_provider_cost: string | null; + monthly_730h_archera_premium: string | null; + monthly_730h_gross_savings: string | null; + monthly_730h_net_savings: string | null; + monthly_730h_covered_on_demand_cost: string | null; +} + +export interface ArcheraTotals extends ArcheraFinancials { + upfront_one_time_cost: string | null; +} + +export interface ArcheraOfferDelta { + monthly_730h_net_savings: string | null; + upfront_one_time_cost: string | null; + discount_rate: string | null; + breakeven_days: string | null; +} + +export interface ArcheraHypotheticalDelta { + monthly_730h_net_savings: string | null; + monthly_730h_commitment_cost: string | null; + upfront_one_time_cost: string | null; +} + +export interface ArcheraLineItem { + line_item_id: string; + reason: string; + actual_term: string | null; + actual_payment_option: string | null; + actual_commitment_type: string | null; +} + +export interface ArcheraHypothetical { + contract_term: string | null; + payment_option: string; + totals: ArcheraTotals; + delta_vs_current: ArcheraHypotheticalDelta; + line_items: ArcheraLineItem[]; +} + +export interface ArcheraOffer { + offer_id: string; + is_current: boolean; + provider: string; + commitment_type: string; + region: string | null; + contract_term: string | null; + payment_option: string | null; + lease_attached: boolean; + lease_menu_item_id: string | null; + archera_offer_name?: string | null; + archera_offer_name_note?: string; + archera_product_support: ArcheraProductSupport; + discount_rate: string | null; + breakeven_days: string | null; + monthly: ArcheraFinancials; + upfront_one_time_cost: string | null; + delta_vs_current: ArcheraOfferDelta; +} + +export interface ArcheraRow { + line_item_id: string; + current: ArcheraOffer; + candidates: ArcheraOffer[]; +} + +export interface ArcheraComparison { + title: string; + plan_id: string; + fetched_at: string; + currency: string | null; + currency_note: string; + premium_included: boolean; + basis_note: string; + delta_basis_note: string; + current: ArcheraTotals; + hypotheticals: ArcheraHypothetical[]; + rows: ArcheraRow[]; + non_gating_disclosure: string; + sponsorship_disclosure: string; +} diff --git a/frontend/src/archera-comparison.ts b/frontend/src/archera-comparison.ts new file mode 100644 index 00000000..e105db02 --- /dev/null +++ b/frontend/src/archera-comparison.ts @@ -0,0 +1,274 @@ +/** + * Archera plan comparison section (issue #785), shown on the Archera + * education page. Read-only; fetched only on an explicit click. + * + * Rules enforced here: + * - Every string from the server (vendor text included) is set with + * textContent; there is no innerHTML. + * - Money is rendered verbatim as the exact decimal string the server + * sent. null renders as "Unknown", never 0. There is no currency + * symbol and no numeric parsing. + * - Titles, notes and both disclosures come from the response verbatim. + * - No polling, no timers, no client cache: each click is one request. + */ + +import { getInsuranceComparison, getInsuranceStatus } from './api/insurance'; +import type { + ArcheraComparison, + ArcheraFinancials, + ArcheraOffer, + ArcheraTotals, +} from './api/types'; + +const UNKNOWN = 'Unknown'; +const NOT_GIVEN = 'Not given'; +const NOT_KNOWN_SUPPORTED = 'Not known to be supported by Archera'; + +function el( + tag: K, + text?: string, + className?: string, +): HTMLElementTagNameMap[K] { + const node = document.createElement(tag); + if (text !== undefined) node.textContent = text; + if (className) node.className = className; + return node; +} + +function money(v: unknown): string { + return typeof v === 'string' ? v : UNKNOWN; +} + +function text(v: unknown): string { + return typeof v === 'string' && v !== '' ? v : NOT_GIVEN; +} + +function cell(tag: 'td' | 'th', content: string, scope?: 'row' | 'col'): HTMLTableCellElement { + const c = el(tag, content); + if (scope) c.scope = scope; + return c; +} + +function table(caption: string, headers: string[]): { wrap: HTMLElement; body: HTMLTableSectionElement } { + const wrap = el('div'); + wrap.style.overflowX = 'auto'; + const t = el('table'); + t.appendChild(el('caption', caption)); + const head = el('thead'); + const tr = el('tr'); + for (const h of headers) tr.appendChild(cell('th', h, 'col')); + head.appendChild(tr); + t.appendChild(head); + const body = el('tbody'); + t.appendChild(body); + wrap.appendChild(t); + return { wrap, body }; +} + +function formatFetchedAt(raw: unknown): string { + const s = typeof raw === 'string' ? raw : ''; + const d = new Date(s); + if (s === '' || Number.isNaN(d.getTime())) return `Fetched at ${s || NOT_GIVEN}`; + const opts: Intl.DateTimeFormatOptions = { dateStyle: 'medium', timeStyle: 'long' }; + const local = new Intl.DateTimeFormat(undefined, opts).format(d); + const utc = new Intl.DateTimeFormat('en-GB', { ...opts, timeZone: 'UTC' }).format(d); + return `Fetched at ${local} (${utc})`; +} + +function totalsRows(body: HTMLTableSectionElement, t: ArcheraTotals): void { + const rows: Array<[string, string | null]> = [ + ['Commitment cost total (monthly)', t.monthly_730h_commitment_cost_total], + ['Cloud provider cost (monthly)', t.monthly_730h_cloud_provider_cost], + ['Archera premium (monthly)', t.monthly_730h_archera_premium], + ['Gross savings (monthly)', t.monthly_730h_gross_savings], + ['Net savings (monthly)', t.monthly_730h_net_savings], + ['Covered on-demand cost (monthly)', t.monthly_730h_covered_on_demand_cost], + ['Upfront cost (one-time)', t.upfront_one_time_cost], + ]; + for (const [label, value] of rows) { + const tr = el('tr'); + tr.appendChild(cell('th', label, 'row')); + tr.appendChild(cell('td', money(value))); + body.appendChild(tr); + } +} + +function productSupport(o: ArcheraOffer): HTMLElement { + const box = el('div'); + const ps = o.archera_product_support; + if (ps && ps.status === 'supported') { + box.appendChild(el('div', 'Supported (documented)')); + if (ps.source) box.appendChild(el('div', `Source: ${ps.source}`)); + if (ps.evidence) box.appendChild(el('div', `Evidence: ${ps.evidence}`)); + } else { + box.appendChild(el('div', NOT_KNOWN_SUPPORTED)); + } + box.appendChild(el('div', 'Underwriting allowance: Unknown')); + box.appendChild(el('div', 'Customer eligibility: Unknown')); + return box; +} + +function offerName(o: ArcheraOffer): HTMLElement { + const box = el('div'); + if (typeof o.archera_offer_name === 'string' && o.archera_offer_name !== '') { + box.appendChild(el('div', `Archera's name for this offer: ${o.archera_offer_name}`)); + if (o.archera_offer_name_note) box.appendChild(el('div', o.archera_offer_name_note)); + } else { + box.textContent = NOT_GIVEN; + } + return box; +} + +function offerRow(body: HTMLTableSectionElement, o: ArcheraOffer): void { + const tr = el('tr'); + const id = cell('th', `${text(o.offer_id)}${o.is_current ? ' (current)' : ' (candidate)'}`, 'row'); + tr.appendChild(id); + tr.appendChild(cell('td', `${text(o.provider)} / ${text(o.commitment_type)}`)); + tr.appendChild(cell('td', text(o.region))); + tr.appendChild(cell('td', `${text(o.contract_term)} / ${text(o.payment_option)}`)); + tr.appendChild(cell('td', o.lease_attached ? `lease attached (${text(o.lease_menu_item_id)})` : 'no lease')); + const name = el('td'); + name.appendChild(offerName(o)); + tr.appendChild(name); + const support = el('td'); + support.appendChild(productSupport(o)); + tr.appendChild(support); + tr.appendChild(cell('td', money(o.discount_rate))); + tr.appendChild(cell('td', money(o.breakeven_days))); + const m: ArcheraFinancials = o.monthly; + tr.appendChild(cell('td', money(m.monthly_730h_net_savings))); + tr.appendChild(cell('td', money(o.upfront_one_time_cost))); + tr.appendChild(cell('td', money(o.delta_vs_current.monthly_730h_net_savings))); + tr.appendChild(cell('td', money(o.delta_vs_current.upfront_one_time_cost))); + body.appendChild(tr); +} + +const OFFER_HEADERS = [ + 'Offer', 'Provider / commitment type', 'Region', 'Term / payment option', 'Lease', + 'Archera offer name', 'Archera product support', 'Discount rate', 'Breakeven days', + 'Net savings (monthly)', 'Upfront cost (one-time)', + 'Net savings change vs current plan (monthly)', 'Upfront change vs current plan (one-time)', +]; + +/** Builds the full result view from the response. Exported for tests. */ +export function buildComparisonView(c: ArcheraComparison): HTMLElement { + const root = el('div'); + root.appendChild(el('h2', c.title)); + root.appendChild(el('p', formatFetchedAt(c.fetched_at), 'archera-fetched-at')); + root.appendChild(el('p', 'Archera does not document a cache lifetime. Use Refresh to fetch again.')); + for (const note of [c.currency_note, c.basis_note, c.delta_basis_note]) { + root.appendChild(el('p', note, 'archera-note')); + } + root.appendChild(el('p', c.non_gating_disclosure, 'archera-disclosure')); + root.appendChild(el('p', c.sponsorship_disclosure, 'archera-disclosure')); + + const cur = table('Current plan totals', ['Measure', 'Amount']); + totalsRows(cur.body, c.current); + root.appendChild(cur.wrap); + + if (c.hypotheticals.length > 0) { + const h = table('Hypothetical alternatives, compared with the current plan', [ + 'Contract term', 'Payment option', 'Commitment cost total (monthly)', 'Net savings (monthly)', + 'Upfront cost (one-time)', 'Net savings change vs current plan (monthly)', + 'Commitment cost change vs current plan (monthly)', 'Upfront change vs current plan (one-time)', + 'Line items', + ]); + for (const hy of c.hypotheticals) { + const tr = el('tr'); + tr.appendChild(cell('th', text(hy.contract_term), 'row')); + tr.appendChild(cell('td', text(hy.payment_option))); + tr.appendChild(cell('td', money(hy.totals.monthly_730h_commitment_cost_total))); + tr.appendChild(cell('td', money(hy.totals.monthly_730h_net_savings))); + tr.appendChild(cell('td', money(hy.totals.upfront_one_time_cost))); + tr.appendChild(cell('td', money(hy.delta_vs_current.monthly_730h_net_savings))); + tr.appendChild(cell('td', money(hy.delta_vs_current.monthly_730h_commitment_cost))); + tr.appendChild(cell('td', money(hy.delta_vs_current.upfront_one_time_cost))); + const li = el('td'); + for (const item of hy.line_items) { + li.appendChild(el('div', + `${text(item.line_item_id)}: ${text(item.reason)} (term ${text(item.actual_term)}, ` + + `payment ${text(item.actual_payment_option)}, type ${text(item.actual_commitment_type)})`)); + } + tr.appendChild(li); + h.body.appendChild(tr); + } + root.appendChild(h.wrap); + } + + for (const r of c.rows) { + root.appendChild(el('h3', `Line item ${text(r.line_item_id)}`)); + const t = table(`Offers for line item ${text(r.line_item_id)}`, OFFER_HEADERS); + offerRow(t.body, r.current); + for (const cand of r.candidates) offerRow(t.body, cand); + root.appendChild(t.wrap); + } + return root; +} + +function errorText(err: unknown): string { + const e = err as { message?: unknown; details?: Record }; + const base = typeof e?.message === 'string' && e.message !== '' ? e.message : 'Archera comparison failed'; + const secs = e?.details?.['retry_after_seconds']; + if ((e as { status?: number })?.status === 429) { + if (typeof secs === 'number' && secs > 0) return `${base}. Retry after ${secs} seconds.`; + // The server text for a 429 without Retry-After already says so. + if (!/retry-after not given/i.test(base)) return `${base}. Retry-after not given.`; + } + return base; +} + +/** + * Renders the comparison action into `root` when the backend reports the + * feature configured. Hidden for configured=false and for 404; any other + * status failure shows one line. No comparison request is made in any of + * those cases. + */ +export async function renderComparisonSection(root: HTMLElement): Promise { + let configured = false; + try { + configured = (await getInsuranceStatus()).configured === true; + } catch (err) { + if ((err as { status?: number })?.status !== 404) { + root.appendChild(el('p', 'Archera comparison status unavailable')); + } + return; + } + if (!configured) return; + + const section = el('section'); + section.setAttribute('aria-label', 'Archera plan comparison'); + const button = el('button', 'Compare with Archera', 'btn'); + button.type = 'button'; + const live = el('div'); + live.setAttribute('aria-live', 'polite'); + const result = el('div'); + section.append(button, live, result); + root.appendChild(section); + + let inFlight = false; + let loaded = false; + button.addEventListener('click', () => { + if (inFlight) return; + inFlight = true; + button.disabled = true; + button.setAttribute('aria-busy', 'true'); + live.textContent = 'Loading Archera comparison'; + void getInsuranceComparison() + .then(c => { + result.replaceChildren(buildComparisonView(c)); + live.textContent = 'Archera comparison loaded'; + loaded = true; + button.textContent = 'Refresh'; + }) + .catch((err: unknown) => { + live.textContent = errorText(err); + if (loaded) button.textContent = 'Refresh'; + }) + .finally(() => { + inFlight = false; + button.disabled = false; + button.removeAttribute('aria-busy'); + button.focus(); + }); + }); +} diff --git a/frontend/src/archera.ts b/frontend/src/archera.ts index 85981db6..b8e20ad1 100644 --- a/frontend/src/archera.ts +++ b/frontend/src/archera.ts @@ -23,6 +23,8 @@ * No backend, routing, or IaC changes (frontend-only). */ +import { renderComparisonSection } from './archera-comparison'; + /** Canonical Archera signup URL with CUDly attribution. */ export const ARCHERA_SIGNUP_URL = 'https://www.archera.ai/cudly'; @@ -354,6 +356,11 @@ function buildArcheraPage(root: HTMLElement): void { appendArcheraEducationBody(root); root.appendChild(buildSignupBlock()); + + const comparison = document.createElement('div'); + comparison.className = 'archera-comparison'; + root.appendChild(comparison); + void renderComparisonSection(comparison); } /** diff --git a/frontend/tests-e2e/archera-comparison.spec.ts b/frontend/tests-e2e/archera-comparison.spec.ts new file mode 100644 index 00000000..70737561 --- /dev/null +++ b/frontend/tests-e2e/archera-comparison.spec.ts @@ -0,0 +1,84 @@ +/** + * Archera comparison section on the education page (issue #785). + * Fixture-based: API responses are mocked with page.route using the Go DTO + * golden file, so this proves the render path only. No live Archera calls. + */ +import { test, expect, type Page } from '@playwright/test'; +import * as fs from 'fs'; +import * as path from 'path'; +import { mockApi, seedAuth } from './fixtures/recs'; + +const golden = JSON.parse(fs.readFileSync( + path.resolve(__dirname, '../../internal/archera/testdata/comparison.golden.json'), 'utf8')); + +async function setup(page: Page, statusBody: unknown, statusCode = 200) { + const errors: string[] = []; + const comparisonCalls: string[] = []; + page.on('pageerror', e => errors.push(e.message)); + await seedAuth(page); + await mockApi(page); + await page.route('**/api/insurance/status', r => r.fulfill({ status: statusCode, json: statusBody })); + await page.route('**/api/insurance/comparison', r => { + comparisonCalls.push(r.request().url()); + return r.fulfill({ json: golden }); + }); + return { errors, comparisonCalls }; +} + +test('not configured: no action and no comparison request', async ({ page }) => { + const { errors, comparisonCalls } = await setup(page, { configured: false, missing: ['ARCHERA_ORG_ID'] }); + await page.goto('/archera-insurance'); + await expect(page.locator('.archera-page-inner h1')).toBeVisible(); + await expect(page.getByRole('button', { name: 'Compare with Archera' })).toHaveCount(0); + expect(comparisonCalls).toEqual([]); + expect(errors).toEqual([]); +}); + +test('configured: compare, disclosures, refresh', async ({ page }) => { + const { errors, comparisonCalls } = await setup(page, { configured: true, missing: [] }); + await page.goto('/archera-insurance'); + const button = page.getByRole('button', { name: 'Compare with Archera' }); + await button.scrollIntoViewIfNeeded(); + expect(comparisonCalls).toEqual([]); + await button.click(); + await expect(page.getByRole('heading', { name: golden.title })).toBeVisible(); + await expect(page.getByText(golden.non_gating_disclosure)).toBeVisible(); + await expect(page.getByText(golden.sponsorship_disclosure)).toBeVisible(); + await expect(page.locator('.archera-fetched-at')).toContainText('Fetched at'); + expect(comparisonCalls).toHaveLength(1); + const refresh = page.getByRole('button', { name: 'Refresh' }); + await expect(refresh).toBeFocused(); + await refresh.click(); + await expect.poll(() => comparisonCalls.length).toBe(2); + expect(errors).toEqual([]); +}); + +test('hostile vendor string stays text and 429 shows the wait', async ({ page }) => { + const { errors } = await setup(page, { configured: true, missing: [] }); + const hostile = JSON.parse(JSON.stringify(golden)); + hostile.rows[0].current.offer_id = ''; + await page.route('**/api/insurance/comparison', r => r.fulfill({ json: hostile })); + await page.goto('/archera-insurance'); + await page.getByRole('button', { name: 'Compare with Archera' }).click(); + await expect(page.getByText('').first()).toBeVisible(); + expect(await page.evaluate(() => (window as unknown as { __xss?: number }).__xss)).toBeUndefined(); + + await page.route('**/api/insurance/comparison', r => r.fulfill({ + status: 429, headers: { 'Retry-After': '30' }, + json: { error: 'Archera rate limit reached', retry_after_seconds: 30 }, + })); + await page.getByRole('button', { name: 'Refresh' }).click(); + await expect(page.locator('.archera-comparison [aria-live="polite"]')).toContainText('Retry after 30 seconds'); + await expect(page.getByRole('button', { name: 'Refresh' })).toBeEnabled(); + expect(errors).toEqual([]); +}); + +test('mobile width: comparison does not widen the page', async ({ page }) => { + await page.setViewportSize({ width: 390, height: 800 }); + await setup(page, { configured: true, missing: [] }); + await page.goto('/archera-insurance'); + await page.getByRole('button', { name: 'Compare with Archera' }).click(); + await expect(page.getByRole('heading', { name: golden.title })).toBeVisible(); + const overflow = await page.evaluate(() => document.documentElement.scrollWidth - window.innerWidth); + expect(overflow).toBeLessThanOrEqual(0); +});