fix(sponsor): anchor 14-day toast cooldown on show, not just on CTA click (#2477)

The sponsor toast re-fired on every fresh browser session. The backend
    owns the 14-day cooldown but only persists the anchor (last_shown_at) and
    the seen-milestone record inside POST /sponsor-prompt/dismiss, and the hook
    only called dismiss from the "View supporters" CTA onClick. A user who saw
    the toast but never clicked the CTA persisted no state; the per-tab
    sessionStorage guard hid the re-fire within one session, but every new
    session re-checked against empty state and re-showed the same milestone.

    Record the toast as shown the moment it renders (POST /dismiss right after
    showPersistentToast) so display is what arms the cooldown. CTA click stays
    optional and just navigates. Frontend-only; backend cooldown logic unchanged.
This commit is contained in:
maziggy
2026-07-07 11:06:10 +02:00
4 changed files with 124 additions and 6 deletions
@@ -0,0 +1,114 @@
/**
* Tests for the in-app sponsor-toast hook (#2477 regression guard).
*
* The bug: the 14-day cooldown is backend-owned, but the anchor is only
* persisted by POST /sponsor-prompt/dismiss — and the hook used to call
* dismiss ONLY from the "View supporters" CTA's onClick. A user who saw the
* toast but never clicked it persisted no state, so the toast re-fired on
* every fresh browser session. The fix records-on-show: the hook POSTs
* /dismiss the moment it renders the toast. These tests pin that contract so
* it can't silently regress back to click-only anchoring.
*/
import { describe, it, expect, afterEach, beforeEach, vi } from 'vitest';
import { renderHook, waitFor, cleanup } from '@testing-library/react';
import { http, HttpResponse } from 'msw';
import { server } from '../mocks/server';
import { useSponsorPrompt } from '../../hooks/useSponsorPrompt';
// The hook only needs `loading` from auth and `showPersistentToast` from the
// toast context — mock both so the test doesn't drag in the real providers
// (auth bootstrap, toast portal). The real sponsorPromptApi still runs and
// hits MSW, which is exactly what we want to assert on.
vi.mock('../../contexts/AuthContext', () => ({
useAuth: () => ({ loading: false }),
}));
const showPersistentToast = vi.fn();
vi.mock('../../contexts/ToastContext', () => ({
useToast: () => ({ showPersistentToast }),
}));
beforeEach(() => {
showPersistentToast.mockClear();
sessionStorage.clear();
});
afterEach(() => {
cleanup();
});
describe('useSponsorPrompt', () => {
it('records the toast as shown (POSTs /dismiss) as soon as it renders, without a CTA click', async () => {
const dismissed: string[] = [];
server.use(
http.get('/api/v1/sponsor-prompt/check', () =>
HttpResponse.json({
show: true,
milestone: 'prints-500',
family: 'prints',
threshold: 500,
payload: { count: 512 },
}),
),
http.post('/api/v1/sponsor-prompt/dismiss', async ({ request }) => {
const body = (await request.json()) as { milestone: string };
dismissed.push(body.milestone);
return new HttpResponse(null, { status: 204 });
}),
);
renderHook(() => useSponsorPrompt('EUR'));
// The toast is shown...
await waitFor(() => expect(showPersistentToast).toHaveBeenCalledTimes(1));
// ...and the cooldown is anchored on show, not on any CTA interaction.
await waitFor(() => expect(dismissed).toEqual(['prints-500']));
// The CTA is present for navigation but carries no onClick side effect —
// anchoring no longer depends on the user clicking through.
const options = showPersistentToast.mock.calls[0][3];
expect(options.action.href).toContain('from=app-toast-prints-500');
expect(options.action.onClick).toBeUndefined();
});
it('does not show a toast or anchor the cooldown when the check returns show:false', async () => {
let dismissCalls = 0;
server.use(
http.get('/api/v1/sponsor-prompt/check', () => HttpResponse.json({ show: false })),
http.post('/api/v1/sponsor-prompt/dismiss', () => {
dismissCalls += 1;
return new HttpResponse(null, { status: 204 });
}),
);
renderHook(() => useSponsorPrompt('EUR'));
// Give the async effect a chance to run before asserting the negatives.
await waitFor(() => expect(sessionStorage.getItem('sponsorPromptShown')).toBe('1'));
expect(showPersistentToast).not.toHaveBeenCalled();
expect(dismissCalls).toBe(0);
});
it('does not re-check within the same browser session (sessionStorage guard)', async () => {
let checkCalls = 0;
server.use(
http.get('/api/v1/sponsor-prompt/check', () => {
checkCalls += 1;
return HttpResponse.json({ show: false });
}),
http.post('/api/v1/sponsor-prompt/dismiss', () => new HttpResponse(null, { status: 204 })),
);
const first = renderHook(() => useSponsorPrompt('EUR'));
await waitFor(() => expect(checkCalls).toBe(1));
first.unmount();
// A second mount in the same session (e.g. a route change that remounts
// Layout) must not re-run the check — that per-tab guard is what keeps the
// toast from flashing repeatedly while a session is open.
renderHook(() => useSponsorPrompt('EUR'));
await new Promise((r) => setTimeout(r, 20));
expect(checkCalls).toBe(1);
});
});
+8 -4
View File
@@ -5,7 +5,11 @@
* sponsors page with a Matomo-trackable `?from=app-toast-{milestone}` param.
*
* The 14-day cooldown + already-seen-milestone deduplication is owned by the
* backend service — the hook just trusts the check endpoint's verdict.
* backend service. The hook trusts the check endpoint's verdict, and the moment
* it actually renders the toast it POSTs /dismiss to anchor the cooldown — being
* *shown* is what arms the 14-day gate, not the user clicking the CTA. (Clicking
* is optional; without this record-on-show, an ignored toast would never persist
* any state and would re-fire on every fresh browser session.)
*/
import { useEffect, useRef } from 'react';
import { useTranslation } from 'react-i18next';
@@ -77,11 +81,11 @@ export function useSponsorPrompt(currencyCode = 'EUR') {
action: {
label: t('sponsors.viewSupporters', 'View supporters'),
href: `https://bambuddy.cool/sponsors.html?from=app-toast-${result.milestone}`,
onClick: () => {
void sponsorPromptApi.dismiss(result.milestone!);
},
},
});
// Anchor the 14-day cooldown as soon as the toast is on screen, so an
// ignored toast doesn't re-fire on the next browser session.
void sponsorPromptApi.dismiss(result.milestone);
} catch {
// Network / 401 — silently skip; next session retries.
}
File diff suppressed because one or more lines are too long
+1 -1
View File
@@ -26,7 +26,7 @@
<!-- Splash screens for iOS -->
<link rel="apple-touch-startup-image" href="/img/android-chrome-512x512.png" />
<script type="module" crossorigin src="/assets/index-DqJZ0C8s.js"></script>
<script type="module" crossorigin src="/assets/index-Bp9OAjMR.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-BxVhuRti.css">
</head>
<body>