fix(usage): scope the pending-packet note to controls it actually gates

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.
This commit is contained in:
Paul Nothaft
2026-09-08 21:49:40 +02:00
parent 842e9056dd
commit 13418b9b55
4 changed files with 41 additions and 7 deletions
@@ -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(screen.getByRole('button', { name: 'productUsage.reviewUpgrade' })).toBeDisabled();
expect(service.upgradeConsent).not.toHaveBeenCalled(); 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', () => { describe('product usage controls', () => {
it('offers identity-free audit receipts after opt-out without restoring participation controls', async () => { it('offers identity-free audit receipts after opt-out without restoring participation controls', async () => {
vi.mocked(service.status).mockResolvedValue({ vi.mocked(service.status).mockResolvedValue({
@@ -223,14 +223,20 @@ export default function ProductUsageTab() {
</Button> </Button>
</div> </div>
)} )}
{data.pending_action && data.pending_action !== 'consent' && ( {active && data.pending_action && data.pending_action !== 'consent' && (
// The v5-upgrade and portal buttons below are disabled by the same // The portal button below (and the v5-upgrade button above, when
// pending-packet guard the backend enforces (command() refuses a // present) are disabled by the same pending-packet guard the
// second packet while one is still unacknowledged) — without this // backend enforces (command() refuses a second packet while one is
// note the buttons just look broken, and "Retry" above them isn't // still unacknowledged) — without this note they just look broken,
// obviously the fix. // 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.
<p role="status" className="text-sm text-neutral-600 dark:text-neutral-400"> <p role="status" className="text-sm text-neutral-600 dark:text-neutral-400">
{t('productUsage.pendingBlocksActions')} {t(data.consent_update_available ? 'productUsage.pendingBlocksActions' : 'productUsage.pendingBlocksPortal')}
</p> </p>
)} )}
<div className="flex flex-wrap gap-3"> <div className="flex flex-wrap gap-3">
+1
View File
@@ -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.", "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.", "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“.", "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": { "catalog": {
"crm": { "crm": {
"name": "Kundenverwaltung", "name": "Kundenverwaltung",
+1
View File
@@ -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.", "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.", "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.", "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": { "catalog": {
"crm": { "crm": {
"name": "Client management", "name": "Client management",