mirror of
https://github.com/maziggy/bambuddy.git
synced 2026-09-30 03:01:21 +02:00
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:
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user