diff --git a/frontend/src/__tests__/api-insurance.test.ts b/frontend/src/__tests__/api-insurance.test.ts index bf0b27cad..cf11625e9 100644 --- a/frontend/src/__tests__/api-insurance.test.ts +++ b/frontend/src/__tests__/api-insurance.test.ts @@ -1,4 +1,4 @@ -import { getInsuranceComparison, getInsuranceStatus } from '../api/insurance'; +import { getInsuranceComparison, getInsuranceStatus, getRecommendationInsuranceComparison } from '../api/insurance'; import { apiRequest } from '../api/client'; jest.mock('../api/client', () => ({ apiRequest: jest.fn() })); @@ -16,3 +16,8 @@ describe('insurance api', () => { expect(apiRequest).toHaveBeenCalledWith('/insurance/comparison'); }); }); + +test('recommendation comparison encodes the exact stored ID', async () => { + await getRecommendationInsuranceComparison('stored/id?&'); + expect(apiRequest).toHaveBeenLastCalledWith('/insurance/comparison?recommendation_id=stored%2Fid%3F%26'); +}); diff --git a/frontend/src/__tests__/recommendation-archera-comparison.test.ts b/frontend/src/__tests__/recommendation-archera-comparison.test.ts new file mode 100644 index 000000000..4d69b745a --- /dev/null +++ b/frontend/src/__tests__/recommendation-archera-comparison.test.ts @@ -0,0 +1,153 @@ +import { mountRecommendationArchera } from '../recommendation-archera-comparison'; +import { getInsuranceStatus, getRecommendationInsuranceComparison } from '../api/insurance'; +import type { RecommendationArcheraComparison, RecommendationArcheraOffer } from '../api/types'; +import { canAccess } from '../permissions'; + +jest.mock('../api/insurance', () => ({ + getInsuranceStatus: jest.fn(), getRecommendationInsuranceComparison: jest.fn(), +})); +jest.mock('../permissions', () => ({ canAccess: jest.fn() })); + +const figures: RecommendationArcheraOffer = { + offer_id: '', provider: 'aws', commitment_type: 'savings_plan', + hourly_selected_commitment: '0.500000000000000001', native_duration_seconds: '31536000', + native_plan_type: 'EC2Instance', native_instance_family: 'm7i', effective_contract_term: 'one_year_gris', + payment_option: 'no_upfront', lease_attached: false, upfront_one_time_cost: '1200', + monthly: { + monthly_730h_commitment_cost_total: '110', monthly_730h_cloud_provider_cost: '100', + monthly_730h_archera_premium: '10', monthly_730h_gross_savings: null, + monthly_730h_net_savings: '20', monthly_730h_covered_on_demand_cost: '130', + }, +}; +const result: RecommendationArcheraComparison = { + recommendation_id: 'stored/id', status: 'resource_associated', reason: '', + amount_provenance: 'stored_decimal_only', economic_status: 'unavailable_source_precision', + fetched_at: '2026-10-15T12:00:00Z', currency: null, comparable_delta: null, + currency_note: 'Vendor currency unavailable', premium_included: true, + current: figures, candidates: [ + { offer_id: 'same', status: 'vendor_alternative', reason: '', native_identity_status: 'unavailable', + figures: { ...figures, native_duration_seconds: null, native_plan_type: null, native_instance_family: null } }, + { offer_id: 'same', status: 'unavailable', reason: 'amount_unproven', native_identity_status: 'unavailable', figures: null }, + ], + non_gating_disclosure: 'Purchases do not depend on Archera.', + sponsorship_disclosure: 'Sponsored by Archera.', +}; +const flush = async (): Promise => { await Promise.resolve(); await Promise.resolve(); }; +const action = (): HTMLButtonElement => document.querySelector('[data-action="archera"]')!; + +beforeEach(() => { + document.body.innerHTML = '
'; + jest.clearAllMocks(); + (canAccess as jest.Mock).mockReturnValue(true); + (getInsuranceStatus as jest.Mock).mockResolvedValue({ configured: true, missing: [] }); + (getRecommendationInsuranceComparison as jest.Mock).mockResolvedValue(result); +}); + +test('explicit row action uses the stored ID without comparison on mount or repeated mount', async () => { + await mountRecommendationArchera(document.body); + await mountRecommendationArchera(document.body); + expect(document.querySelectorAll('[data-action="archera"]')).toHaveLength(1); + expect(getRecommendationInsuranceComparison).not.toHaveBeenCalled(); + action().click(); + await flush(); + expect(getRecommendationInsuranceComparison).toHaveBeenCalledWith('stored/id'); + expect(document.querySelector('[role="dialog"]')).not.toBeNull(); +}); + +test.each(['permission', 'unconfigured', 'status_error'])('%s does not expose the action or fetch comparison', async mode => { + if (mode === 'permission') (canAccess as jest.Mock).mockReturnValue(false); + if (mode === 'unconfigured') (getInsuranceStatus as jest.Mock).mockResolvedValue({ configured: false }); + if (mode === 'status_error') (getInsuranceStatus as jest.Mock).mockRejectedValue(new Error('offline')); + await mountRecommendationArchera(document.body); + expect(document.querySelector('button')).toBeNull(); + expect(getRecommendationInsuranceComparison).not.toHaveBeenCalled(); + if (mode === 'permission') expect(getInsuranceStatus).not.toHaveBeenCalled(); +}); + +test('loading deduplicates across rerenders and closing never reopens the response', async () => { + let resolve!: (value: RecommendationArcheraComparison) => void; + (getRecommendationInsuranceComparison as jest.Mock).mockReturnValue(new Promise(r => { resolve = r; })); + await mountRecommendationArchera(document.body); + action().click(); + expect(action().disabled).toBe(true); + expect(document.body.textContent).toContain('Loading Archera alternatives'); + const row = document.querySelector('tr')!; + row.querySelector('button')!.remove(); + await mountRecommendationArchera(document.body); + action().click(); + expect(getRecommendationInsuranceComparison).toHaveBeenCalledTimes(1); + (document.querySelector('[role="dialog"] button') as HTMLButtonElement).click(); + resolve(result); + await flush(); + expect(document.querySelector('[role="dialog"]')).toBeNull(); +}); + +test('native exact figures, null economics, lease distinctions and disclosures remain text', async () => { + await mountRecommendationArchera(document.body); + action().click(); + await flush(); + const dialog = document.querySelector('[role="dialog"]')!; + expect(dialog.querySelector('img')).toBeNull(); + for (const text of [figures.offer_id, '0.500000000000000001', '31536000', 'one_year_gris', + '1200', '110', 'Unknown', 'Original provider precision is unproven', 'Currency: unknown', + 'Comparable cost difference: unavailable', result.non_gating_disclosure, result.sponsorship_disclosure]) { + expect(dialog.textContent).toContain(text); + } + expect(dialog.textContent).not.toMatch(/\$|USD/); + expect(dialog.querySelectorAll('h3')).toHaveLength(2); + expect(dialog.textContent).toContain('Alternative 2: unavailable. amount unproven'); + expect(dialog.textContent).toContain('native product identity is unavailable'); +}); + +test.each([[400, 'invalid'], [403, 'not authorised'], [404, 'no longer exists'], [409, 'changed or is ambiguous'], + [422, 'cannot be compared'], [502, 'unavailable or returned'], [503, 'not available'], [504, 'did not respond']])( +'status %i shows a fixed message and never the response text', async (status, text) => { + (getRecommendationInsuranceComparison as jest.Mock).mockRejectedValue(Object.assign(new Error(''), { status })); + await mountRecommendationArchera(document.body); + action().click(); + await flush(); + const dialog = document.querySelector('[role="dialog"]')!; + expect(dialog.textContent).toContain(text); + expect(dialog.textContent).not.toContain(' { + if (mode === 'unmatched') (getRecommendationInsuranceComparison as jest.Mock).mockResolvedValue({ + ...result, status: 'unavailable', reason: 'identity_unproven', current: null, candidates: [], + }); + if (mode === 'error') (getRecommendationInsuranceComparison as jest.Mock).mockRejectedValue(new Error('')); + if (mode === 'wrong_id') (getRecommendationInsuranceComparison as jest.Mock).mockResolvedValue({ ...result, recommendation_id: 'another' }); + await mountRecommendationArchera(document.body); + action().click(); + await flush(); + const dialog = document.querySelector('[role="dialog"]')!; + expect(dialog.querySelectorAll('h3')).toHaveLength(0); + expect(dialog.querySelector('img')).toBeNull(); + expect(action().disabled).toBe(false); + if (mode === 'unmatched') expect(dialog.textContent).toContain('identity unproven'); + if (mode === 'error') expect(dialog.textContent).toContain('Archera alternatives unavailable.'); + if (mode === 'wrong_id') expect(dialog.textContent).toContain('changed or is ambiguous'); +}); + +test('closing while loading restores focus without allowing a duplicate request', async () => { + let resolve!: (value: RecommendationArcheraComparison) => void; + (getRecommendationInsuranceComparison as jest.Mock).mockReturnValue(new Promise(r => { resolve = r; })); + await mountRecommendationArchera(document.body); + const button = action(); + button.focus(); + button.click(); + (document.querySelector('[role="dialog"] button') as HTMLButtonElement).click(); + expect(document.activeElement).toBe(button); + button.click(); + expect(getRecommendationInsuranceComparison).toHaveBeenCalledTimes(1); + resolve(result); + await flush(); +}); + +test('the module imports no purchase or plan API', () => { + const source = jest.requireActual('fs').readFileSync(require.resolve('../recommendation-archera-comparison'), 'utf8'); + const imports = [...source.matchAll(/from '([^']+)'/g)].map(m => m[1]); + expect(imports).toEqual(['./api/insurance', './api/types', './permissions', './modal']); +}); diff --git a/frontend/src/api/insurance.ts b/frontend/src/api/insurance.ts index a54cd71a8..cd88424de 100644 --- a/frontend/src/api/insurance.ts +++ b/frontend/src/api/insurance.ts @@ -3,7 +3,7 @@ */ import { apiRequest } from './client'; -import type { ArcheraComparison, InsuranceStatus } from './types'; +import type { ArcheraComparison, InsuranceStatus, RecommendationArcheraComparison } from './types'; /** Reports whether the comparison is configured. Makes no vendor call. */ export async function getInsuranceStatus(): Promise { @@ -14,3 +14,7 @@ export async function getInsuranceStatus(): Promise { export async function getInsuranceComparison(): Promise { return apiRequest('/insurance/comparison'); } + +export async function getRecommendationInsuranceComparison(id: string): Promise { + return apiRequest(`/insurance/comparison?recommendation_id=${encodeURIComponent(id)}`); +} diff --git a/frontend/src/api/types.ts b/frontend/src/api/types.ts index f3ad8bc6f..9a0a89037 100644 --- a/frontend/src/api/types.ts +++ b/frontend/src/api/types.ts @@ -1056,3 +1056,41 @@ export interface ArcheraComparison { non_gating_disclosure: string; sponsorship_disclosure: string; } + +export interface RecommendationArcheraOffer { + offer_id: string; + provider: string; + commitment_type: string; + hourly_selected_commitment: string | null; + native_duration_seconds: string | null; + native_plan_type: string | null; + native_instance_family: string | null; + effective_contract_term: string | null; + payment_option: string | null; + lease_attached: boolean; + monthly: ArcheraFinancials; + upfront_one_time_cost: string | null; +} + +export interface RecommendationArcheraComparison { + recommendation_id: string; + status: string; + reason: string; + amount_provenance: string; + economic_status: string; + fetched_at: string; + currency: string | null; + currency_note: string; + comparable_delta: string | null; + premium_included: boolean; + current: RecommendationArcheraOffer | null; + candidates: Array<{ + offer_id: string; + status: string; + reason: string; + native_identity_status: string; + figures: RecommendationArcheraOffer | null; + }>; + non_gating_disclosure: string; + sponsorship_disclosure: string; +} diff --git a/frontend/src/recommendation-archera-comparison.ts b/frontend/src/recommendation-archera-comparison.ts new file mode 100644 index 000000000..5d5eb7cbe --- /dev/null +++ b/frontend/src/recommendation-archera-comparison.ts @@ -0,0 +1,130 @@ +import { getInsuranceStatus, getRecommendationInsuranceComparison } from './api/insurance'; +import type { RecommendationArcheraComparison, RecommendationArcheraOffer } from './api/types'; +import { canAccess } from './permissions'; +import { openModal, closeModal } from './modal'; + +const pending = new Set(); + +const FAILURES: Record = { + 400: 'The recommendation request was invalid.', + 403: 'You are not authorised to view Archera alternatives.', + 404: 'This recommendation no longer exists. Refresh the list.', + 409: 'The recommendation changed or is ambiguous. Refresh the list and try again.', + 422: 'This recommendation type cannot be compared with Archera.', + 502: 'Archera is unavailable or returned an unusable response.', + 503: 'The comparison service is not available right now.', + 504: 'Archera did not respond in time.', +}; + +function failureText(error: unknown): string { + const status = (error as { status?: number } | null)?.status; + return (status !== undefined && FAILURES[status]) || 'Archera alternatives unavailable.'; +} + +function node(tag: K, text?: string): HTMLElementTagNameMap[K] { + const element = document.createElement(tag); + if (text !== undefined) element.textContent = text; + return element; +} + +function offerView(offer: RecommendationArcheraOffer, title: string): HTMLElement { + const section = node('section'); + section.append(node('h3', title)); + const values: Array<[string, string | null]> = [ + ['Offer', offer.offer_id], + ['Provider / product', `${offer.provider} / ${offer.commitment_type}`], + ['Selected hourly commitment', offer.hourly_selected_commitment], + ['Native duration (seconds)', offer.native_duration_seconds], + ['Native plan type', offer.native_plan_type], + ['Native instance family', offer.native_instance_family], + ['Effective contract / lease term', offer.effective_contract_term], + ['Payment', offer.payment_option], + ['Lease attached', offer.lease_attached ? 'Yes' : 'No'], + ['Monthly commitment cost (730 hours)', offer.monthly.monthly_730h_commitment_cost_total], + ['Monthly provider cost (730 hours)', offer.monthly.monthly_730h_cloud_provider_cost], + ['Monthly Archera premium (730 hours)', offer.monthly.monthly_730h_archera_premium], + ['Monthly gross savings (730 hours)', offer.monthly.monthly_730h_gross_savings], + ['Monthly net savings (730 hours)', offer.monthly.monthly_730h_net_savings], + ['Monthly covered on-demand cost (730 hours)', offer.monthly.monthly_730h_covered_on_demand_cost], + ['Upfront cost (one-time)', offer.upfront_one_time_cost], + ]; + const list = node('dl'); + for (const [label, value] of values) list.append(node('dt', label), node('dd', value ?? 'Unknown')); + section.append(list); + return section; +} + +function resultView(result: RecommendationArcheraComparison): HTMLElement { + const root = node('div'); + root.append(node('p', result.status === 'resource_associated' + ? 'Resource association found. Comparable economics remain unavailable.' + : `Association unavailable: ${result.reason.replace(/_/g, ' ')}`)); + root.append(node('p', `Fetched at ${result.fetched_at}`)); + root.append(node('p', result.currency_note)); + root.append(node('p', 'Currency: unknown. Comparable cost difference: unavailable.')); + root.append(node('p', 'The stored hourly amount has already passed through floating-point conversion. Original provider precision is unproven; these are native vendor figures, not an equivalent-cost quote.')); + root.append(node('p', `Premium included: ${result.premium_included ? 'Yes' : 'No'}`)); + root.append(node('p', result.non_gating_disclosure), node('p', result.sponsorship_disclosure)); + if (result.current) root.append(offerView(result.current, 'Current vendor offer')); + for (const [index, candidate] of result.candidates.entries()) { + root.append(node('p', `Alternative ${index + 1}: ${candidate.status.replace(/_/g, ' ')}. ${candidate.reason.replace(/_/g, ' ')}`)); + root.append(node('p', 'Alternative native product identity is unavailable; current-offer dimensions are not inherited.')); + if (candidate.figures) root.append(offerView(candidate.figures, `Vendor alternative ${index + 1}`)); + } + return root; +} + +async function compare(id: string, button: HTMLButtonElement): Promise { + if (pending.has(id) || !canAccess('view', 'recommendations')) return; + pending.add(id); + const modal = node('div'); + modal.className = 'modal hidden'; + modal.setAttribute('role', 'dialog'); + modal.setAttribute('aria-modal', 'true'); + modal.setAttribute('aria-label', 'Archera recommendation alternatives'); + const panel = node('div'); + panel.className = 'modal-content modal-wide'; + const close = node('button', 'Close'); + close.type = 'button'; + close.className = 'btn'; + close.addEventListener('click', () => closeModal(modal)); + const content = node('div', 'Loading Archera alternatives'); + content.setAttribute('aria-live', 'polite'); + panel.append(node('h2', 'Archera recommendation alternatives'), close, content); + modal.append(panel); + document.body.append(modal); + openModal(modal, { onClose: () => { button.disabled = false; modal.remove(); } }); + button.disabled = true; + try { + const result = await getRecommendationInsuranceComparison(id); + if (result.recommendation_id !== id) throw Object.assign(new Error('mismatched recommendation'), { status: 409 }); + content.replaceChildren(resultView(result)); + } catch (error) { + content.textContent = failureText(error); + } finally { + pending.delete(id); + button.disabled = false; + } +} + +export async function mountRecommendationArchera(root: HTMLElement): Promise { + if (!canAccess('view', 'recommendations')) return; + try { + if (!(await getInsuranceStatus()).configured) return; + } catch { + return; + } + for (const row of root.querySelectorAll('tr.recommendation-row[data-rec-id]')) { + const id = row.dataset['recId']; + if (!id || row.querySelector('[data-action="archera"]')) continue; + const button = node('button', 'Archera alternatives'); + button.type = 'button'; + button.className = 'btn btn-small'; + button.dataset['action'] = 'archera'; + button.addEventListener('click', event => { + event.stopPropagation(); + void compare(id, button); + }); + row.cells[0]?.append(button); + } +} diff --git a/frontend/src/recommendations.ts b/frontend/src/recommendations.ts index 6f44b1be9..bd1b97094 100644 --- a/frontend/src/recommendations.ts +++ b/frontend/src/recommendations.ts @@ -24,6 +24,7 @@ import type { RecommendationsResponse, LocalRecommendation, RecommendationsSumma import { openModal } from './modal'; import { showSkeletonRows, teardownSkeleton } from './lib/skeleton'; import { canAccess } from './permissions'; +import { mountRecommendationArchera } from './recommendation-archera-comparison'; // Issue #869: true when the current session can take any action on // recommendations (purchase or plan). Readonly/viewer sessions have neither @@ -5020,6 +5021,7 @@ function renderRecommendationsList(loadedRecs: LocalRecommendation[]): void { renderRecommendationsList(loadedRecs); }); }); + void mountRecommendationArchera(container); } // Resolve a priced override, valid own payment, or priced legacy fallback. diff --git a/frontend/tests-e2e/recommendation-archera.spec.ts b/frontend/tests-e2e/recommendation-archera.spec.ts new file mode 100644 index 000000000..cf78a29f7 --- /dev/null +++ b/frontend/tests-e2e/recommendation-archera.spec.ts @@ -0,0 +1,66 @@ +import { test, expect } from '@playwright/test'; +import { mockApi, seedAuth, RECS } from './fixtures/recs'; + +test('readonly stored row opens native Archera figures without selecting or purchasing', async ({ page }) => { + const errors: string[] = []; + page.on('pageerror', error => errors.push(error.message)); + await seedAuth(page); + const { calls } = await mockApi(page); + await page.route('**/api/auth/me', route => route.fulfill({ json: { id: 'viewer', groups: [], email: 'viewer@example.com' } })); + await page.route('**/api/auth/me/permissions', route => route.fulfill({ json: { + permissions: [{ action: 'view', resource: 'recommendations' }], is_admin: false, + } })); + await page.route(/\/api\/recommendations(?:\?.*)?$/, route => route.fulfill({ json: { + recommendations: [{ ...RECS[0], id: 'stored/id', service: 'savings-plans-ec2instance', resource_type: 'm7i' }], + summary: {}, regions: ['us-east-1'], + } })); + await page.route('**/api/insurance/status', route => route.fulfill({ json: { configured: true, missing: [] } })); + const comparisonCalls: string[] = []; + const figures = { + offer_id: '', provider: 'aws', commitment_type: 'savings_plan', + hourly_selected_commitment: '0.500000000000000001', native_duration_seconds: '31536000', + native_plan_type: 'EC2Instance', native_instance_family: 'm7i', effective_contract_term: 'one_year_gris', + payment_option: 'no_upfront', lease_attached: false, upfront_one_time_cost: '1200', + monthly: { monthly_730h_commitment_cost_total: '110', monthly_730h_cloud_provider_cost: '100', + monthly_730h_archera_premium: '10', monthly_730h_gross_savings: null, + monthly_730h_net_savings: '20', monthly_730h_covered_on_demand_cost: '130' }, + }; + await page.route('**/api/insurance/comparison?*', route => { + comparisonCalls.push(route.request().url()); + return route.fulfill({ json: { + recommendation_id: 'stored/id', status: 'resource_associated', reason: '', + amount_provenance: 'stored_decimal_only', economic_status: 'unavailable_source_precision', + fetched_at: '2026-10-15T12:00:00Z', currency: null, comparable_delta: null, + currency_note: 'Vendor currency unavailable', premium_included: true, current: figures, + candidates: [{ offer_id: 'alternative', status: 'vendor_alternative', reason: '', + native_identity_status: 'unavailable', figures: { ...figures, native_duration_seconds: null, + native_plan_type: null, native_instance_family: null, lease_attached: true, upfront_one_time_cost: '2400' } }], + non_gating_disclosure: 'Purchases do not depend on Archera.', sponsorship_disclosure: 'Sponsored by Archera.', + } }); + }); + await page.goto('/opportunities'); + const button = page.getByRole('button', { name: 'Archera alternatives' }); + await expect(button).toBeVisible(); + expect(comparisonCalls).toEqual([]); + await expect(page.getByRole('checkbox', { name: 'Select recommendation', exact: true })).toHaveCount(0); + await button.click(); + const dialog = page.getByRole('dialog', { name: 'Archera recommendation alternatives' }); + await expect(dialog).toContainText('0.500000000000000001'); + await expect(dialog).toContainText('one_year_gris'); + await expect(dialog).toContainText('2400'); + await expect(dialog).toContainText('native product identity is unavailable'); + await expect(dialog).toContainText('Currency: unknown. Comparable cost difference: unavailable.'); + await expect(dialog).toContainText('Original provider precision is unproven'); + await expect(dialog).toContainText('Sponsored by Archera.'); + await expect(dialog.locator('img')).toHaveCount(0); + expect(new URL(comparisonCalls[0]!).searchParams.get('recommendation_id')).toBe('stored/id'); + expect(comparisonCalls).toHaveLength(1); + expect(calls.filter(call => call.method !== 'GET')).toEqual([]); + expect(await page.evaluate(() => (window as unknown as { __archeraXss?: number }).__archeraXss)).toBeUndefined(); + await page.setViewportSize({ width: 390, height: 844 }); + expect(await dialog.evaluate(element => element.scrollWidth <= element.clientWidth)).toBe(true); + await page.keyboard.press('Escape'); + await expect(dialog).toHaveCount(0); + await expect(button).toBeFocused(); + expect(errors).toEqual([]); +});