diff --git a/frontend/src/__tests__/purchases-deeplink.test.ts b/frontend/src/__tests__/purchases-deeplink.test.ts index 477929a01..ee04a444c 100644 --- a/frontend/src/__tests__/purchases-deeplink.test.ts +++ b/frontend/src/__tests__/purchases-deeplink.test.ts @@ -47,6 +47,35 @@ describe('handlePurchaseDeeplink approve (issue #247)', () => { expect(window.location.pathname).toBe('/purchases'); }); + it.each([ + [403, 'forbidden', 'view:purchases'], + [404, 'execution not found', 'not found or is outside your account access'], + ])('details fetch failing with %s is permanent: no retry advice, keeps server message', async (status, message, wording) => { + (api.getPurchaseDetails as jest.Mock).mockRejectedValue(Object.assign(new Error(message), { status })); + + await expect(handlePurchaseDeeplink()).resolves.toBe(true); + + const text = document.querySelector('[role="alert"]')?.textContent ?? ''; + expect(text).toContain(wording); + expect(text).toContain(`(${message})`); + expect(text).not.toContain('retry'); + }); + + it.each([ + [500, 'internal error'], + [401, 'unauthorized'], + [undefined, 'Failed to fetch'], + ])('details fetch failing with %s keeps the retry path', async (status, message) => { + (api.getPurchaseDetails as jest.Mock).mockRejectedValue(Object.assign(new Error(message), { status })); + + await expect(handlePurchaseDeeplink()).resolves.toBe(true); + + const text = document.querySelector('[role="alert"]')?.textContent ?? ''; + expect(text).toContain('Open the approval link again to retry.'); + expect(text).toContain(`(${message})`); + expect(text).not.toContain('view:purchases'); + }); + it('shows the upfront amount and approves when details load', async () => { (api.getPurchaseDetails as jest.Mock).mockResolvedValue({ execution_id: EXEC_ID, status: 'pending', total_upfront_cost: 1200, estimated_savings: 10, diff --git a/frontend/src/approval-details.ts b/frontend/src/approval-details.ts index 61d756e11..587266990 100644 --- a/frontend/src/approval-details.ts +++ b/frontend/src/approval-details.ts @@ -351,7 +351,21 @@ export async function buildApprovalDetailsBody(executionId: string): Promise { } catch (err) { console.error('Failed to load purchase details for approval:', err); showToast({ - message: `${approvalDetailsUnavailableMessage(err)} Open the approval link again to retry.`, + message: isPermanentDetailsError(err) + ? approvalDetailsUnavailableMessage(err) + : `${approvalDetailsUnavailableMessage(err)} Open the approval link again to retry.`, kind: 'error', timeout: null, }); diff --git a/frontend/tests-e2e/history-action-confirmation.spec.ts b/frontend/tests-e2e/history-action-confirmation.spec.ts index 857db70df..4d27b96d6 100644 --- a/frontend/tests-e2e/history-action-confirmation.spec.ts +++ b/frontend/tests-e2e/history-action-confirmation.spec.ts @@ -122,7 +122,7 @@ test('approve is not offered when the purchase details cannot be loaded (issue # await page.route(`**/api/purchases/${ID}`, route => { detailGets++; return detailGets === 1 - ? route.fulfill({ status: 403, json: { error: 'forbidden' } }) + ? route.fulfill({ status: 500, json: { error: 'internal error' } }) : route.fulfill({ json: DETAILS }); }); await page.goto('/purchases/history'); @@ -137,6 +137,21 @@ test('approve is not offered when the purchase details cannot be loaded (issue # await expect.poll(() => fixture.posts.length).toBe(1); }); +for (const [status, wording] of [[403, 'view:purchases'], [404, 'outside your account access']] as const) { + test(`approval link with details ${status} shows permanent wording without retry advice (issue #529)`, async ({ page }) => { + const fixture = await historyFixture(page, 'approve'); + await page.route(`**/api/purchases/${ID}`, route => route.fulfill({ status, json: { error: 'denied by server' } })); + await page.goto(`/purchases/approve/${ID}?token=tok`); + const alert = page.getByRole('alert'); + await expect(alert).toContainText('cannot be approved without showing the amount'); + await expect(alert).toContainText(wording); + await expect(alert).toContainText('denied by server'); + await expect(alert).not.toContainText(/try again|retry/i); + await expect(page.locator('.modal-confirm-backdrop')).toHaveCount(0); + expect(fixture.posts).toHaveLength(0); + }); +} + test('canceling an independent row does not steal focus from another confirmation', async ({ page }) => { const fixture = await historyFixture(page, 'approve'); const details = gate();