diff --git a/frontend/src/features/settings/__tests__/ProductUsageTab.test.tsx b/frontend/src/features/settings/__tests__/ProductUsageTab.test.tsx index 8e8c39db..844caf23 100644 --- a/frontend/src/features/settings/__tests__/ProductUsageTab.test.tsx +++ b/frontend/src/features/settings/__tests__/ProductUsageTab.test.tsx @@ -103,6 +103,32 @@ it('pending v2 confirmation clearly keeps v1 and cannot queue another upgrade', expect(screen.getByRole('button', { name: 'productUsage.reviewUpgrade' })).toBeDisabled(); expect(service.upgradeConsent).not.toHaveBeenCalled(); }); +it.each(['activation_pending', 'deletion_pending'] as const)('a stuck packet in %s names nothing, since neither control is actually gated by it', async (pendingStatus) => { + // Outside `active`, the portal renders as a plain always-enabled link and + // no v5-upgrade section exists — pending_action blocks neither, so the + // note must not claim it does. + vi.mocked(service.status).mockResolvedValue({ ...status, status: pendingStatus, collector_url: 'https://usage.picpeak.app', pending_action: pendingStatus === 'activation_pending' ? 'register' : 'delete' }); + mount(); + await screen.findByText(`productUsage.states.${pendingStatus}`); + expect(screen.queryByText('productUsage.pendingBlocksActions')).toBeNull(); + expect(screen.queryByText('productUsage.pendingBlocksPortal')).toBeNull(); +}); +it('a stuck report on an already-current schema names only the portal, not a non-existent upgrade button', async () => { + vi.mocked(service.status).mockResolvedValue({ ...status, status: 'active', consent_update_available: false, pending_action: 'report' }); + mount(); + expect(await screen.findByText('productUsage.pendingBlocksPortal')).toBeInTheDocument(); + expect(screen.queryByText('productUsage.pendingBlocksActions')).toBeNull(); + expect(screen.queryByText('productUsage.reviewUpgrade')).toBeNull(); + expect(screen.getByRole('button', { name: 'productUsage.openUsagePortal' })).toBeDisabled(); +}); +it('a stuck report with a v5 upgrade available names both actually-gated controls', async () => { + vi.mocked(service.status).mockResolvedValue({ ...status, status: 'active', consent_update_available: true, collector_url: 'https://usage.picpeak.app', pending_action: 'report' }); + mount(); + expect(await screen.findByText('productUsage.pendingBlocksActions')).toBeInTheDocument(); + expect(screen.queryByText('productUsage.pendingBlocksPortal')).toBeNull(); + expect(screen.getByRole('button', { name: 'productUsage.reviewUpgrade' })).toBeDisabled(); + expect(screen.getByRole('button', { name: 'productUsage.openUsagePortal' })).toBeDisabled(); +}); describe('product usage controls', () => { it('offers identity-free audit receipts after opt-out without restoring participation controls', async () => { vi.mocked(service.status).mockResolvedValue({ diff --git a/frontend/src/features/settings/tabs/ProductUsageTab.tsx b/frontend/src/features/settings/tabs/ProductUsageTab.tsx index d633db4d..8022c870 100644 --- a/frontend/src/features/settings/tabs/ProductUsageTab.tsx +++ b/frontend/src/features/settings/tabs/ProductUsageTab.tsx @@ -223,6 +223,22 @@ export default function ProductUsageTab() { )} + {active && data.pending_action && data.pending_action !== 'consent' && ( + // The portal button below (and the v5-upgrade button above, when + // present) are disabled by the same pending-packet guard the + // backend enforces (command() refuses a second packet while one is + // still unacknowledged) — without this note they just look broken, + // and "Retry" above isn't obviously the fix. Gated on `active`: + // outside that status the portal renders as a plain, un-gated link + // and no v5-upgrade section exists, so pending_action blocks + // nothing this note could correctly describe (e.g. + // activation_pending/deletion_pending with their own packet still + // in flight). Which controls it names depends on whether the + // v5-upgrade section is actually on screen. +

+ {t(data.consent_update_available ? 'productUsage.pendingBlocksActions' : 'productUsage.pendingBlocksPortal')} +

+ )}
{data.status === 'disabled' ? (