| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233 |
- import { describe, it, expect, vi, beforeEach } from 'vitest';
- import { renderHook, waitFor, act } from '@testing-library/react';
- import React from 'react';
- import { OnboardingProvider, useOnboarding } from '../contexts/OnboardingContext';
- import * as AuthContextModule from '../contexts/AuthContext';
- import * as ApiClient from '../api/client';
- import type { Permission, UserResponse } from '../api/client';
- function wrap({ children }: { children: React.ReactNode }) {
- return <OnboardingProvider>{children}</OnboardingProvider>;
- }
- function mockAuth(opts: { authEnabled: boolean; user?: UserResponse | null; loading?: boolean }) {
- const user: UserResponse | null = opts.user ?? null;
- vi.spyOn(AuthContextModule, 'useAuth').mockReturnValue({
- user,
- authEnabled: opts.authEnabled,
- requiresSetup: false,
- loading: opts.loading ?? false,
- isAdmin: false,
- login: vi.fn(),
- loginWithToken: vi.fn(),
- logout: vi.fn(),
- refreshUser: vi.fn(),
- refreshAuth: vi.fn(),
- hasPermission: (_: Permission) => false,
- hasAnyPermission: (..._: Permission[]) => false,
- hasAllPermissions: (..._: Permission[]) => false,
- canModify: () => false,
- });
- }
- const fakeUser: UserResponse = {
- id: 1,
- username: 'tester',
- email: null,
- role: 'admin',
- is_active: true,
- is_admin: true,
- auth_source: 'local',
- groups: [],
- permissions: [],
- created_at: '2024-01-01T00:00:00Z',
- };
- beforeEach(() => {
- vi.restoreAllMocks();
- // localStorage is mocked in setup.ts — reset call counts per test.
- vi.mocked(window.localStorage.getItem).mockReset();
- vi.mocked(window.localStorage.setItem).mockReset();
- vi.mocked(window.localStorage.removeItem).mockReset();
- });
- describe('OnboardingProvider — auth on, user logged in', () => {
- it('GETs the user state and exposes status / snoozedUntil from the response', async () => {
- mockAuth({ authEnabled: true, user: fakeUser });
- vi.spyOn(ApiClient.api, 'getOnboarding').mockResolvedValue({
- status: 'completed_tour',
- snoozed_until: null,
- });
- const { result } = renderHook(() => useOnboarding(), { wrapper: wrap });
- await waitFor(() => {
- expect(result.current.isLoaded).toBe(true);
- });
- expect(result.current.status).toBe('completed_tour');
- expect(result.current.snoozedUntil).toBeNull();
- expect(result.current.loadFailed).toBe(false);
- });
- it('sets loadFailed when the GET errors so the welcome modal stays hidden', async () => {
- mockAuth({ authEnabled: true, user: fakeUser });
- vi.spyOn(ApiClient.api, 'getOnboarding').mockRejectedValue(new Error('5xx'));
- const { result } = renderHook(() => useOnboarding(), { wrapper: wrap });
- await waitFor(() => {
- expect(result.current.isLoaded).toBe(true);
- });
- expect(result.current.loadFailed).toBe(true);
- expect(result.current.status).toBeNull();
- });
- it('marks loadFailed=true when auth is on but there is no active user — "me" cannot be queried', async () => {
- mockAuth({ authEnabled: true, user: null });
- const spy = vi.spyOn(ApiClient.api, 'getOnboarding');
- const { result } = renderHook(() => useOnboarding(), { wrapper: wrap });
- await waitFor(() => {
- expect(result.current.isLoaded).toBe(true);
- });
- expect(result.current.loadFailed).toBe(true);
- expect(spy).not.toHaveBeenCalled();
- });
- it('PATCHes the backend when setStatus is called and updates local state from the response', async () => {
- mockAuth({ authEnabled: true, user: fakeUser });
- vi.spyOn(ApiClient.api, 'getOnboarding').mockResolvedValue({
- status: null,
- snoozed_until: null,
- });
- const patchSpy = vi.spyOn(ApiClient.api, 'updateOnboarding').mockResolvedValue({
- status: 'dismissed',
- snoozed_until: null,
- });
- const { result } = renderHook(() => useOnboarding(), { wrapper: wrap });
- await waitFor(() => expect(result.current.isLoaded).toBe(true));
- await act(async () => {
- await result.current.setStatus('dismissed');
- });
- expect(patchSpy).toHaveBeenCalledWith({ status: 'dismissed' });
- expect(result.current.status).toBe('dismissed');
- });
- it('keeps the UI responsive when the PATCH errors — falls through to local state so the modal still closes', async () => {
- mockAuth({ authEnabled: true, user: fakeUser });
- vi.spyOn(ApiClient.api, 'getOnboarding').mockResolvedValue({
- status: null,
- snoozed_until: null,
- });
- vi.spyOn(ApiClient.api, 'updateOnboarding').mockRejectedValue(new Error('5xx'));
- const { result } = renderHook(() => useOnboarding(), { wrapper: wrap });
- await waitFor(() => expect(result.current.isLoaded).toBe(true));
- await act(async () => {
- await result.current.setStatus('dismissed');
- });
- expect(result.current.status).toBe('dismissed');
- });
- it('includes snoozed_until in the PATCH body when status is snoozed', async () => {
- mockAuth({ authEnabled: true, user: fakeUser });
- vi.spyOn(ApiClient.api, 'getOnboarding').mockResolvedValue({
- status: null,
- snoozed_until: null,
- });
- const patchSpy = vi.spyOn(ApiClient.api, 'updateOnboarding').mockResolvedValue({
- status: 'snoozed',
- snoozed_until: '2026-06-15T00:00:00Z',
- });
- const { result } = renderHook(() => useOnboarding(), { wrapper: wrap });
- await waitFor(() => expect(result.current.isLoaded).toBe(true));
- await act(async () => {
- await result.current.setStatus('snoozed', '2026-06-15T00:00:00Z');
- });
- expect(patchSpy).toHaveBeenCalledWith({
- status: 'snoozed',
- snoozed_until: '2026-06-15T00:00:00Z',
- });
- expect(result.current.snoozedUntil).toBe('2026-06-15T00:00:00Z');
- });
- });
- describe('OnboardingProvider — auth off (localStorage path)', () => {
- it('reads status from localStorage on mount', async () => {
- mockAuth({ authEnabled: false });
- vi.mocked(window.localStorage.getItem).mockImplementation((key: string) => {
- if (key === 'bambuddy.onboarding_status') return 'dismissed';
- return null;
- });
- const { result } = renderHook(() => useOnboarding(), { wrapper: wrap });
- await waitFor(() => expect(result.current.isLoaded).toBe(true));
- expect(result.current.status).toBe('dismissed');
- expect(result.current.loadFailed).toBe(false);
- });
- it('writes status to localStorage when setStatus is called', async () => {
- mockAuth({ authEnabled: false });
- vi.mocked(window.localStorage.getItem).mockReturnValue(null);
- const patchSpy = vi.spyOn(ApiClient.api, 'updateOnboarding');
- const { result } = renderHook(() => useOnboarding(), { wrapper: wrap });
- await waitFor(() => expect(result.current.isLoaded).toBe(true));
- await act(async () => {
- await result.current.setStatus('dismissed');
- });
- expect(window.localStorage.setItem).toHaveBeenCalledWith(
- 'bambuddy.onboarding_status',
- 'dismissed',
- );
- expect(patchSpy).not.toHaveBeenCalled();
- expect(result.current.status).toBe('dismissed');
- });
- it('writes snoozed_until to localStorage when status is snoozed, removes it otherwise', async () => {
- mockAuth({ authEnabled: false });
- vi.mocked(window.localStorage.getItem).mockReturnValue(null);
- const { result } = renderHook(() => useOnboarding(), { wrapper: wrap });
- await waitFor(() => expect(result.current.isLoaded).toBe(true));
- await act(async () => {
- await result.current.setStatus('snoozed', '2026-06-15T00:00:00Z');
- });
- expect(window.localStorage.setItem).toHaveBeenCalledWith(
- 'bambuddy.onboarding_snoozed_until',
- '2026-06-15T00:00:00Z',
- );
- expect(result.current.snoozedUntil).toBe('2026-06-15T00:00:00Z');
- await act(async () => {
- await result.current.setStatus('dismissed');
- });
- expect(window.localStorage.removeItem).toHaveBeenCalledWith(
- 'bambuddy.onboarding_snoozed_until',
- );
- expect(result.current.snoozedUntil).toBeNull();
- });
- });
- describe('OnboardingProvider — waiting on auth', () => {
- it('does not fire the GET while authLoading is true', () => {
- mockAuth({ authEnabled: true, user: fakeUser, loading: true });
- const spy = vi.spyOn(ApiClient.api, 'getOnboarding');
- const { result } = renderHook(() => useOnboarding(), { wrapper: wrap });
- expect(spy).not.toHaveBeenCalled();
- expect(result.current.isLoaded).toBe(false);
- });
- });
|