useSponsorPrompt.test.tsx 9.3 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248
  1. /**
  2. * Tests for the in-app sponsor-toast hook (#2477 regression guard).
  3. *
  4. * The bug: the 14-day cooldown is backend-owned, but the anchor is only
  5. * persisted by POST /sponsor-prompt/dismiss — and the hook used to call
  6. * dismiss ONLY from the "View supporters" CTA's onClick. A user who saw the
  7. * toast but never clicked it persisted no state, so the toast re-fired on
  8. * every fresh browser session. The fix records-on-show: the hook POSTs
  9. * /dismiss the moment it renders the toast. These tests pin that contract so
  10. * it can't silently regress back to click-only anchoring.
  11. */
  12. import type { ReactNode } from 'react';
  13. import { describe, it, expect, afterEach, beforeEach, vi } from 'vitest';
  14. import { renderHook, waitFor, cleanup } from '@testing-library/react';
  15. import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
  16. import { http, HttpResponse } from 'msw';
  17. import { server } from '../mocks/server';
  18. import { useSponsorPrompt } from '../../hooks/useSponsorPrompt';
  19. // The hook only needs `loading` from auth and `showPersistentToast` from the
  20. // toast context — mock both so the test doesn't drag in the real providers
  21. // (auth bootstrap, toast portal). The real sponsorPromptApi still runs and
  22. // hits MSW, which is exactly what we want to assert on.
  23. vi.mock('../../contexts/AuthContext', () => ({
  24. useAuth: () => ({ loading: false }),
  25. }));
  26. const showPersistentToast = vi.fn();
  27. vi.mock('../../contexts/ToastContext', () => ({
  28. useToast: () => ({ showPersistentToast }),
  29. }));
  30. // The hook reads the printers list (fleet size decides which ask it makes), so
  31. // it now needs a QueryClient. Fresh per test so one test's fleet can't leak
  32. // into the next through the cache.
  33. function wrapper() {
  34. const qc = new QueryClient({
  35. defaultOptions: { queries: { retry: false, gcTime: 0 } },
  36. });
  37. return ({ children }: { children: ReactNode }) => (
  38. <QueryClientProvider client={qc}>{children}</QueryClientProvider>
  39. );
  40. }
  41. /** Make GET /printers/ return exactly `count` printers. */
  42. function withFleet(count: number) {
  43. server.use(
  44. http.get('/api/v1/printers/', () =>
  45. HttpResponse.json(
  46. Array.from({ length: count }, (_, i) => ({
  47. id: i + 1,
  48. name: `Printer ${i + 1}`,
  49. serial_number: `SN${i + 1}`,
  50. ip_address: '192.168.1.10',
  51. model: 'X1C',
  52. is_active: true,
  53. })),
  54. ),
  55. ),
  56. );
  57. }
  58. beforeEach(() => {
  59. showPersistentToast.mockClear();
  60. sessionStorage.clear();
  61. });
  62. afterEach(() => {
  63. cleanup();
  64. });
  65. describe('useSponsorPrompt', () => {
  66. it('records the toast as shown (POSTs /dismiss) as soon as it renders, without a CTA click', async () => {
  67. const dismissed: string[] = [];
  68. server.use(
  69. http.get('/api/v1/sponsor-prompt/check', () =>
  70. HttpResponse.json({
  71. show: true,
  72. milestone: 'prints-500',
  73. family: 'prints',
  74. threshold: 500,
  75. payload: { count: 512 },
  76. }),
  77. ),
  78. http.post('/api/v1/sponsor-prompt/dismiss', async ({ request }) => {
  79. const body = (await request.json()) as { milestone: string };
  80. dismissed.push(body.milestone);
  81. return new HttpResponse(null, { status: 204 });
  82. }),
  83. );
  84. renderHook(() => useSponsorPrompt('EUR'), { wrapper: wrapper() });
  85. // The toast is shown...
  86. await waitFor(() => expect(showPersistentToast).toHaveBeenCalledTimes(1));
  87. // ...and the cooldown is anchored on show, not on any CTA interaction.
  88. await waitFor(() => expect(dismissed).toEqual(['prints-500']));
  89. // The CTA is present for navigation but carries no onClick side effect —
  90. // anchoring no longer depends on the user clicking through.
  91. const options = showPersistentToast.mock.calls[0][3];
  92. expect(options.action.href).toContain('from=app-toast-prints-500');
  93. expect(options.action.onClick).toBeUndefined();
  94. });
  95. it('does not show a toast or anchor the cooldown when the check returns show:false', async () => {
  96. let dismissCalls = 0;
  97. server.use(
  98. http.get('/api/v1/sponsor-prompt/check', () => HttpResponse.json({ show: false })),
  99. http.post('/api/v1/sponsor-prompt/dismiss', () => {
  100. dismissCalls += 1;
  101. return new HttpResponse(null, { status: 204 });
  102. }),
  103. );
  104. renderHook(() => useSponsorPrompt('EUR'), { wrapper: wrapper() });
  105. // Give the async effect a chance to run before asserting the negatives.
  106. await waitFor(() => expect(sessionStorage.getItem('sponsorPromptShown')).toBe('1'));
  107. expect(showPersistentToast).not.toHaveBeenCalled();
  108. expect(dismissCalls).toBe(0);
  109. });
  110. it('does not re-check within the same browser session (sessionStorage guard)', async () => {
  111. let checkCalls = 0;
  112. server.use(
  113. http.get('/api/v1/sponsor-prompt/check', () => {
  114. checkCalls += 1;
  115. return HttpResponse.json({ show: false });
  116. }),
  117. http.post('/api/v1/sponsor-prompt/dismiss', () => new HttpResponse(null, { status: 204 })),
  118. );
  119. const first = renderHook(() => useSponsorPrompt('EUR'), { wrapper: wrapper() });
  120. await waitFor(() => expect(checkCalls).toBe(1));
  121. first.unmount();
  122. // A second mount in the same session (e.g. a route change that remounts
  123. // Layout) must not re-run the check — that per-tab guard is what keeps the
  124. // toast from flashing repeatedly while a session is open.
  125. renderHook(() => useSponsorPrompt('EUR'), { wrapper: wrapper() });
  126. await new Promise((r) => setTimeout(r, 20));
  127. expect(checkCalls).toBe(1);
  128. });
  129. });
  130. /**
  131. * Fleet-size audience split.
  132. *
  133. * A print farm has no use for a "chip in $5" toast — it wants a support
  134. * contract. At/above BUSINESS_FLEET_THRESHOLD configured printers the toast
  135. * makes the commercial ask instead, and points at business.html rather than
  136. * sponsors.html. Same milestone, same cooldown, same single interruption; only
  137. * the ask changes.
  138. */
  139. describe('useSponsorPrompt — fleet-size audience', () => {
  140. beforeEach(() => {
  141. server.use(
  142. http.get('/api/v1/sponsor-prompt/check', () =>
  143. HttpResponse.json({
  144. show: true,
  145. milestone: 'prints-25',
  146. family: 'prints',
  147. threshold: 25,
  148. payload: { count: 30 },
  149. }),
  150. ),
  151. http.post('/api/v1/sponsor-prompt/dismiss', () => new HttpResponse(null, { status: 204 })),
  152. );
  153. });
  154. it('makes the personal ask below the threshold', async () => {
  155. withFleet(4);
  156. renderHook(() => useSponsorPrompt('EUR'), { wrapper: wrapper() });
  157. await waitFor(() => expect(showPersistentToast).toHaveBeenCalledTimes(1));
  158. const [, message, , options] = showPersistentToast.mock.calls[0];
  159. expect(options.action.href).toContain('sponsors.html');
  160. expect(options.action.href).not.toContain('business.html');
  161. expect(message).toContain('30'); // the prints milestone copy, not the fleet copy
  162. });
  163. it('makes the commercial ask at the threshold, pointing at business.html', async () => {
  164. withFleet(5);
  165. renderHook(() => useSponsorPrompt('EUR'), { wrapper: wrapper() });
  166. await waitFor(() => expect(showPersistentToast).toHaveBeenCalledTimes(1));
  167. const [, message, , options] = showPersistentToast.mock.calls[0];
  168. expect(options.action.href).toContain('business.html');
  169. // Attribution still rides on the milestone, so Matomo keeps segmenting it.
  170. expect(options.action.href).toContain('from=app-toast-prints-25');
  171. expect(message).toContain('5'); // fleet size, not the print count
  172. expect(message).toMatch(/support plan/i);
  173. });
  174. it('counts configured printers, not active ones — maintenance mode must not downgrade the ask', async () => {
  175. // Eight printers, five of them in maintenance (is_active: false). This is
  176. // still an eight-printer business; if the split filtered on is_active it
  177. // would see three and pitch them as a hobbyist mid-outage.
  178. server.use(
  179. http.get('/api/v1/printers/', () =>
  180. HttpResponse.json(
  181. Array.from({ length: 8 }, (_, i) => ({
  182. id: i + 1,
  183. name: `Printer ${i + 1}`,
  184. serial_number: `SN${i + 1}`,
  185. ip_address: '192.168.1.10',
  186. model: 'X1C',
  187. is_active: i < 3,
  188. })),
  189. ),
  190. ),
  191. );
  192. renderHook(() => useSponsorPrompt('EUR'), { wrapper: wrapper() });
  193. await waitFor(() => expect(showPersistentToast).toHaveBeenCalledTimes(1));
  194. const [, message, , options] = showPersistentToast.mock.calls[0];
  195. expect(options.action.href).toContain('business.html');
  196. expect(message).toContain('8');
  197. });
  198. it('waits for the fleet to load before deciding — a farm is never pitched as a hobbyist', async () => {
  199. // Slow printers response: if the hook fired the toast before the fleet
  200. // resolved, it would default to 0 printers and make the personal ask.
  201. server.use(
  202. http.get('/api/v1/printers/', async () => {
  203. await new Promise((r) => setTimeout(r, 50));
  204. return HttpResponse.json(
  205. Array.from({ length: 6 }, (_, i) => ({
  206. id: i + 1,
  207. name: `Printer ${i + 1}`,
  208. serial_number: `SN${i + 1}`,
  209. ip_address: '192.168.1.10',
  210. model: 'X1C',
  211. is_active: true,
  212. })),
  213. );
  214. }),
  215. );
  216. renderHook(() => useSponsorPrompt('EUR'), { wrapper: wrapper() });
  217. await waitFor(() => expect(showPersistentToast).toHaveBeenCalledTimes(1), { timeout: 2000 });
  218. expect(showPersistentToast.mock.calls[0][3].action.href).toContain('business.html');
  219. });
  220. });