From 13418b9b55d7635ad71b815eb39807c41055183c Mon Sep 17 00:00:00 2001 From: Paul Nothaft Date: Tue, 8 Sep 2026 21:49:40 +0200 Subject: [PATCH] fix(usage): scope the pending-packet note to controls it actually gates MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The note added in the previous commit rendered whenever pending_action was truthy, regardless of participation status. Outside `active` (activation_pending, deletion_pending) the portal renders as a plain un-gated link and no v5-upgrade section exists at all, so the note named two controls that either weren't blocked or weren't on screen. And when active but already on the current schema, it wrongly implied a v5-upgrade button existed. Gate on `active` (nothing is actually blocked outside it), and choose between the existing two-control message and a new portal-only one based on whether consent_update_available — which the v5-upgrade section itself is gated on — is true. Four new regression tests cover each shape: activation_pending, deletion_pending, active+current-schema (portal-only), and active+outdated-schema (both, the original case). Found by code review. --- .../__tests__/ProductUsageTab.test.tsx | 26 +++++++++++++++++++ .../settings/tabs/ProductUsageTab.tsx | 20 +++++++++----- frontend/src/i18n/locales/de.json | 1 + frontend/src/i18n/locales/en.json | 1 + 4 files changed, 41 insertions(+), 7 deletions(-) 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 c4bc3ae8..8022c870 100644 --- a/frontend/src/features/settings/tabs/ProductUsageTab.tsx +++ b/frontend/src/features/settings/tabs/ProductUsageTab.tsx @@ -223,14 +223,20 @@ export default function ProductUsageTab() { )} - {data.pending_action && data.pending_action !== 'consent' && ( - // The v5-upgrade and portal buttons below are disabled by the same - // pending-packet guard the backend enforces (command() refuses a - // second packet while one is still unacknowledged) — without this - // note the buttons just look broken, and "Retry" above them isn't - // obviously the fix. + {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('productUsage.pendingBlocksActions')} + {t(data.consent_update_available ? 'productUsage.pendingBlocksActions' : 'productUsage.pendingBlocksPortal')}

)}
diff --git a/frontend/src/i18n/locales/de.json b/frontend/src/i18n/locales/de.json index 4cd92837..e5c78611 100644 --- a/frontend/src/i18n/locales/de.json +++ b/frontend/src/i18n/locales/de.json @@ -19,6 +19,7 @@ "upgradeExplanation": "Deine bestehende Teilnahme behält ihren bisherigen Umfang. Sieh dir den erweiterten Katalog und die beiden Bestandszahlen an, bevor du dich entscheidest. Ablehnen beendet die Teilnahme nicht.", "upgradePending": "Die signierte Erweiterung wartet auf die Bestätigung des Collectors. Bis dahin wird nur der bisher bestätigte Umfang erfasst. Versuche es erneut, wenn der Collector erreichbar ist, oder deaktiviere die Teilnahme, um zu stoppen und zu löschen.", "pendingBlocksActions": "Das v5-Upgrade und das Nutzungsportal sind gesperrt, bis der ausstehende Bericht erneut gesendet wurde oder zugestellt ist — nutze oben „Jetzt senden“.", + "pendingBlocksPortal": "Das Nutzungsportal ist gesperrt, bis der ausstehende Bericht erneut gesendet wurde oder zugestellt ist — nutze oben „Jetzt senden“.", "catalog": { "crm": { "name": "Kundenverwaltung", diff --git a/frontend/src/i18n/locales/en.json b/frontend/src/i18n/locales/en.json index 5e453864..8b50a119 100644 --- a/frontend/src/i18n/locales/en.json +++ b/frontend/src/i18n/locales/en.json @@ -19,6 +19,7 @@ "upgradeExplanation": "Your existing participation keeps its current scope. Review the expanded catalog and the two inventory totals before deciding whether to upgrade. Declining does not end participation.", "upgradePending": "The signed consent upgrade is pending confirmation. Only the previously accepted scope is collected. Retry when the collector is available, or disable participation to stop and delete.", "pendingBlocksActions": "The v5 upgrade and the usage portal are unavailable until the pending report is retried or delivered — use \"Retry / send if due\" above.", + "pendingBlocksPortal": "The usage portal is unavailable until the pending report is retried or delivered — use \"Retry / send if due\" above.", "catalog": { "crm": { "name": "Client management",