OnboardingContext.test.tsx 8.2 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233
  1. import { describe, it, expect, vi, beforeEach } from 'vitest';
  2. import { renderHook, waitFor, act } from '@testing-library/react';
  3. import React from 'react';
  4. import { OnboardingProvider, useOnboarding } from '../contexts/OnboardingContext';
  5. import * as AuthContextModule from '../contexts/AuthContext';
  6. import * as ApiClient from '../api/client';
  7. import type { Permission, UserResponse } from '../api/client';
  8. function wrap({ children }: { children: React.ReactNode }) {
  9. return <OnboardingProvider>{children}</OnboardingProvider>;
  10. }
  11. function mockAuth(opts: { authEnabled: boolean; user?: UserResponse | null; loading?: boolean }) {
  12. const user: UserResponse | null = opts.user ?? null;
  13. vi.spyOn(AuthContextModule, 'useAuth').mockReturnValue({
  14. user,
  15. authEnabled: opts.authEnabled,
  16. requiresSetup: false,
  17. loading: opts.loading ?? false,
  18. isAdmin: false,
  19. login: vi.fn(),
  20. loginWithToken: vi.fn(),
  21. logout: vi.fn(),
  22. refreshUser: vi.fn(),
  23. refreshAuth: vi.fn(),
  24. hasPermission: (_: Permission) => false,
  25. hasAnyPermission: (..._: Permission[]) => false,
  26. hasAllPermissions: (..._: Permission[]) => false,
  27. canModify: () => false,
  28. });
  29. }
  30. const fakeUser: UserResponse = {
  31. id: 1,
  32. username: 'tester',
  33. email: null,
  34. role: 'admin',
  35. is_active: true,
  36. is_admin: true,
  37. auth_source: 'local',
  38. groups: [],
  39. permissions: [],
  40. created_at: '2024-01-01T00:00:00Z',
  41. };
  42. beforeEach(() => {
  43. vi.restoreAllMocks();
  44. // localStorage is mocked in setup.ts — reset call counts per test.
  45. vi.mocked(window.localStorage.getItem).mockReset();
  46. vi.mocked(window.localStorage.setItem).mockReset();
  47. vi.mocked(window.localStorage.removeItem).mockReset();
  48. });
  49. describe('OnboardingProvider — auth on, user logged in', () => {
  50. it('GETs the user state and exposes status / snoozedUntil from the response', async () => {
  51. mockAuth({ authEnabled: true, user: fakeUser });
  52. vi.spyOn(ApiClient.api, 'getOnboarding').mockResolvedValue({
  53. status: 'completed_tour',
  54. snoozed_until: null,
  55. });
  56. const { result } = renderHook(() => useOnboarding(), { wrapper: wrap });
  57. await waitFor(() => {
  58. expect(result.current.isLoaded).toBe(true);
  59. });
  60. expect(result.current.status).toBe('completed_tour');
  61. expect(result.current.snoozedUntil).toBeNull();
  62. expect(result.current.loadFailed).toBe(false);
  63. });
  64. it('sets loadFailed when the GET errors so the welcome modal stays hidden', async () => {
  65. mockAuth({ authEnabled: true, user: fakeUser });
  66. vi.spyOn(ApiClient.api, 'getOnboarding').mockRejectedValue(new Error('5xx'));
  67. const { result } = renderHook(() => useOnboarding(), { wrapper: wrap });
  68. await waitFor(() => {
  69. expect(result.current.isLoaded).toBe(true);
  70. });
  71. expect(result.current.loadFailed).toBe(true);
  72. expect(result.current.status).toBeNull();
  73. });
  74. it('marks loadFailed=true when auth is on but there is no active user — "me" cannot be queried', async () => {
  75. mockAuth({ authEnabled: true, user: null });
  76. const spy = vi.spyOn(ApiClient.api, 'getOnboarding');
  77. const { result } = renderHook(() => useOnboarding(), { wrapper: wrap });
  78. await waitFor(() => {
  79. expect(result.current.isLoaded).toBe(true);
  80. });
  81. expect(result.current.loadFailed).toBe(true);
  82. expect(spy).not.toHaveBeenCalled();
  83. });
  84. it('PATCHes the backend when setStatus is called and updates local state from the response', async () => {
  85. mockAuth({ authEnabled: true, user: fakeUser });
  86. vi.spyOn(ApiClient.api, 'getOnboarding').mockResolvedValue({
  87. status: null,
  88. snoozed_until: null,
  89. });
  90. const patchSpy = vi.spyOn(ApiClient.api, 'updateOnboarding').mockResolvedValue({
  91. status: 'dismissed',
  92. snoozed_until: null,
  93. });
  94. const { result } = renderHook(() => useOnboarding(), { wrapper: wrap });
  95. await waitFor(() => expect(result.current.isLoaded).toBe(true));
  96. await act(async () => {
  97. await result.current.setStatus('dismissed');
  98. });
  99. expect(patchSpy).toHaveBeenCalledWith({ status: 'dismissed' });
  100. expect(result.current.status).toBe('dismissed');
  101. });
  102. it('keeps the UI responsive when the PATCH errors — falls through to local state so the modal still closes', async () => {
  103. mockAuth({ authEnabled: true, user: fakeUser });
  104. vi.spyOn(ApiClient.api, 'getOnboarding').mockResolvedValue({
  105. status: null,
  106. snoozed_until: null,
  107. });
  108. vi.spyOn(ApiClient.api, 'updateOnboarding').mockRejectedValue(new Error('5xx'));
  109. const { result } = renderHook(() => useOnboarding(), { wrapper: wrap });
  110. await waitFor(() => expect(result.current.isLoaded).toBe(true));
  111. await act(async () => {
  112. await result.current.setStatus('dismissed');
  113. });
  114. expect(result.current.status).toBe('dismissed');
  115. });
  116. it('includes snoozed_until in the PATCH body when status is snoozed', async () => {
  117. mockAuth({ authEnabled: true, user: fakeUser });
  118. vi.spyOn(ApiClient.api, 'getOnboarding').mockResolvedValue({
  119. status: null,
  120. snoozed_until: null,
  121. });
  122. const patchSpy = vi.spyOn(ApiClient.api, 'updateOnboarding').mockResolvedValue({
  123. status: 'snoozed',
  124. snoozed_until: '2026-06-15T00:00:00Z',
  125. });
  126. const { result } = renderHook(() => useOnboarding(), { wrapper: wrap });
  127. await waitFor(() => expect(result.current.isLoaded).toBe(true));
  128. await act(async () => {
  129. await result.current.setStatus('snoozed', '2026-06-15T00:00:00Z');
  130. });
  131. expect(patchSpy).toHaveBeenCalledWith({
  132. status: 'snoozed',
  133. snoozed_until: '2026-06-15T00:00:00Z',
  134. });
  135. expect(result.current.snoozedUntil).toBe('2026-06-15T00:00:00Z');
  136. });
  137. });
  138. describe('OnboardingProvider — auth off (localStorage path)', () => {
  139. it('reads status from localStorage on mount', async () => {
  140. mockAuth({ authEnabled: false });
  141. vi.mocked(window.localStorage.getItem).mockImplementation((key: string) => {
  142. if (key === 'bambuddy.onboarding_status') return 'dismissed';
  143. return null;
  144. });
  145. const { result } = renderHook(() => useOnboarding(), { wrapper: wrap });
  146. await waitFor(() => expect(result.current.isLoaded).toBe(true));
  147. expect(result.current.status).toBe('dismissed');
  148. expect(result.current.loadFailed).toBe(false);
  149. });
  150. it('writes status to localStorage when setStatus is called', async () => {
  151. mockAuth({ authEnabled: false });
  152. vi.mocked(window.localStorage.getItem).mockReturnValue(null);
  153. const patchSpy = vi.spyOn(ApiClient.api, 'updateOnboarding');
  154. const { result } = renderHook(() => useOnboarding(), { wrapper: wrap });
  155. await waitFor(() => expect(result.current.isLoaded).toBe(true));
  156. await act(async () => {
  157. await result.current.setStatus('dismissed');
  158. });
  159. expect(window.localStorage.setItem).toHaveBeenCalledWith(
  160. 'bambuddy.onboarding_status',
  161. 'dismissed',
  162. );
  163. expect(patchSpy).not.toHaveBeenCalled();
  164. expect(result.current.status).toBe('dismissed');
  165. });
  166. it('writes snoozed_until to localStorage when status is snoozed, removes it otherwise', async () => {
  167. mockAuth({ authEnabled: false });
  168. vi.mocked(window.localStorage.getItem).mockReturnValue(null);
  169. const { result } = renderHook(() => useOnboarding(), { wrapper: wrap });
  170. await waitFor(() => expect(result.current.isLoaded).toBe(true));
  171. await act(async () => {
  172. await result.current.setStatus('snoozed', '2026-06-15T00:00:00Z');
  173. });
  174. expect(window.localStorage.setItem).toHaveBeenCalledWith(
  175. 'bambuddy.onboarding_snoozed_until',
  176. '2026-06-15T00:00:00Z',
  177. );
  178. expect(result.current.snoozedUntil).toBe('2026-06-15T00:00:00Z');
  179. await act(async () => {
  180. await result.current.setStatus('dismissed');
  181. });
  182. expect(window.localStorage.removeItem).toHaveBeenCalledWith(
  183. 'bambuddy.onboarding_snoozed_until',
  184. );
  185. expect(result.current.snoozedUntil).toBeNull();
  186. });
  187. });
  188. describe('OnboardingProvider — waiting on auth', () => {
  189. it('does not fire the GET while authLoading is true', () => {
  190. mockAuth({ authEnabled: true, user: fakeUser, loading: true });
  191. const spy = vi.spyOn(ApiClient.api, 'getOnboarding');
  192. const { result } = renderHook(() => useOnboarding(), { wrapper: wrap });
  193. expect(spy).not.toHaveBeenCalled();
  194. expect(result.current.isLoaded).toBe(false);
  195. });
  196. });