import { renderHook, act, waitFor } from '@testing-library/react'; jest.mock('@/api/doormile', () => ({ __esModule: true, DOORMILE_TOKEN_KEY: 'doormileToken', DOORMILE_USER_KEY: 'doormileUser', loginAdmin: jest.fn(), logoutAdmin: jest.fn(), readStoredToken: jest.fn(), readStoredUser: jest.fn() })); import { AuthProvider, useAuth } from '@/lib/AuthContext'; import { loginAdmin, logoutAdmin, readStoredToken, readStoredUser } from '@/api/doormile'; const wrapper = ({ children }) => {children}; const renderAuth = () => renderHook(() => useAuth(), { wrapper }); /** * Session state for the console shell. * * What these tests protect is the authorization gate itself: who is considered * signed in, what a refused credential does (show the server's wording, do NOT * throw), and that a sign-out in one tab ends the session in every other tab * rather than leaving a rendered console around a token that is gone. */ describe('AuthContext', () => { let replace; beforeEach(() => { replace = jest.fn(); Object.defineProperty(window, 'location', { writable: true, configurable: true, value: { href: 'http://console.local/deliveries', replace } }); readStoredToken.mockReturnValue(null); readStoredUser.mockReturnValue(null); }); describe('boot', () => { it('should report a signed-out shell when no token is stored', async () => { const { result } = renderAuth(); await waitFor(() => expect(result.current.authChecked).toBe(true)); expect(result.current.isAuthenticated).toBe(false); expect(result.current.user).toBeNull(); expect(result.current.isLoadingAuth).toBe(false); }); it('should restore the signed-in admin from storage', async () => { readStoredToken.mockReturnValue('jwt-abc'); readStoredUser.mockReturnValue({ userid: 7, tenantid: '0' }); const { result } = renderAuth(); await waitFor(() => expect(result.current.authChecked).toBe(true)); expect(result.current.isAuthenticated).toBe(true); expect(result.current.user).toEqual({ userid: 7, tenantid: '0' }); }); it('should treat the token as the credential — no token means no user', async () => { // A stale user record without a token must not produce a rendered console // that 401s on every fetch. readStoredToken.mockReturnValue(null); readStoredUser.mockReturnValue({ userid: 7 }); const { result } = renderAuth(); await waitFor(() => expect(result.current.authChecked).toBe(true)); expect(result.current.isAuthenticated).toBe(false); expect(result.current.user).toBeNull(); }); it('should mark the auth check complete exactly once on mount', async () => { const { result } = renderAuth(); await waitFor(() => expect(result.current.authChecked).toBe(true)); expect(readStoredToken).toHaveBeenCalledTimes(1); }); }); describe('login', () => { it('should sign the operator in on a successful credential', async () => { loginAdmin.mockResolvedValue({ success: true, user: { userid: 7 } }); const { result } = renderAuth(); await waitFor(() => expect(result.current.authChecked).toBe(true)); let outcome; await act(async () => { outcome = await result.current.login('ops@doormile.com', 'hunter2'); }); expect(loginAdmin).toHaveBeenCalledWith('ops@doormile.com', 'hunter2'); expect(outcome).toEqual({ success: true }); expect(result.current.isAuthenticated).toBe(true); expect(result.current.user).toEqual({ userid: 7 }); }); it('should fall back to the stored record when the response omits the user', async () => { loginAdmin.mockResolvedValue({ success: true }); readStoredUser.mockReturnValue({ userid: 9 }); const { result } = renderAuth(); await waitFor(() => expect(result.current.authChecked).toBe(true)); await act(async () => { await result.current.login('ops@doormile.com', 'hunter2'); }); expect(result.current.user).toEqual({ userid: 9 }); }); it("should surface the server's own wording for a refused credential", async () => { // Resolving rather than throwing is deliberate: the form shows the // server's message instead of a generic failure. loginAdmin.mockResolvedValue({ success: false, message: 'Account is locked' }); const { result } = renderAuth(); await waitFor(() => expect(result.current.authChecked).toBe(true)); let outcome; await act(async () => { outcome = await result.current.login('ops@doormile.com', 'wrong'); }); expect(outcome).toEqual({ success: false, message: 'Account is locked' }); expect(result.current.authError).toBe('Account is locked'); expect(result.current.isAuthenticated).toBe(false); expect(result.current.user).toBeNull(); }); it('should use a default message when the server refuses without one', async () => { loginAdmin.mockResolvedValue({ success: false }); const { result } = renderAuth(); await waitFor(() => expect(result.current.authChecked).toBe(true)); let outcome; await act(async () => { outcome = await result.current.login('a@b.c', 'x'); }); expect(outcome.success).toBe(false); expect(outcome.message).toBe('Those credentials were not accepted'); }); it('should report a transport failure without throwing at the form', async () => { loginAdmin.mockRejectedValue({ message: 'Network Error' }); const { result } = renderAuth(); await waitFor(() => expect(result.current.authChecked).toBe(true)); let outcome; await act(async () => { outcome = await result.current.login('a@b.c', 'x'); }); expect(outcome).toEqual({ success: false, message: 'Network Error' }); expect(result.current.isAuthenticated).toBe(false); }); it('should report a reachability message when the rejection carries none', async () => { loginAdmin.mockRejectedValue({}); const { result } = renderAuth(); await waitFor(() => expect(result.current.authChecked).toBe(true)); let outcome; await act(async () => { outcome = await result.current.login('a@b.c', 'x'); }); expect(outcome.message).toBe('Could not reach the Doormile API'); }); it('should clear a previous error when a retry succeeds', async () => { loginAdmin.mockResolvedValueOnce({ success: false, message: 'Invalid credentials' }); loginAdmin.mockResolvedValueOnce({ success: true, user: { userid: 7 } }); const { result } = renderAuth(); await waitFor(() => expect(result.current.authChecked).toBe(true)); await act(async () => { await result.current.login('a@b.c', 'wrong'); }); expect(result.current.authError).toBe('Invalid credentials'); await act(async () => { await result.current.login('a@b.c', 'right'); }); expect(result.current.authError).toBeNull(); }); }); describe('logout', () => { it('should clear the stored session and send the operator to login', async () => { readStoredToken.mockReturnValue('jwt-abc'); readStoredUser.mockReturnValue({ userid: 7 }); const { result } = renderAuth(); await waitFor(() => expect(result.current.isAuthenticated).toBe(true)); act(() => { result.current.logout(); }); expect(logoutAdmin).toHaveBeenCalledTimes(1); expect(result.current.isAuthenticated).toBe(false); expect(result.current.user).toBeNull(); expect(replace).toHaveBeenCalledWith('/login'); }); it('should still clear state when the caller suppresses the redirect', async () => { readStoredToken.mockReturnValue('jwt-abc'); const { result } = renderAuth(); await waitFor(() => expect(result.current.isAuthenticated).toBe(true)); act(() => { result.current.logout(false); }); expect(logoutAdmin).toHaveBeenCalledTimes(1); expect(result.current.isAuthenticated).toBe(false); expect(replace).not.toHaveBeenCalled(); }); }); describe('cross-tab session sync', () => { it('should end this tab session when the token disappears in another tab', async () => { readStoredToken.mockReturnValue('jwt-abc'); readStoredUser.mockReturnValue({ userid: 7 }); const { result } = renderAuth(); await waitFor(() => expect(result.current.isAuthenticated).toBe(true)); readStoredToken.mockReturnValue(null); readStoredUser.mockReturnValue(null); act(() => { window.dispatchEvent(new StorageEvent('storage', { key: 'doormileToken' })); }); await waitFor(() => expect(result.current.isAuthenticated).toBe(false)); }); it('should pick up a sign-IN that happened in another tab', async () => { const { result } = renderAuth(); await waitFor(() => expect(result.current.authChecked).toBe(true)); readStoredToken.mockReturnValue('jwt-abc'); readStoredUser.mockReturnValue({ userid: 7 }); act(() => { window.dispatchEvent(new StorageEvent('storage', { key: 'doormileUser' })); }); await waitFor(() => expect(result.current.isAuthenticated).toBe(true)); }); it('should ignore an unrelated storage key', async () => { readStoredToken.mockReturnValue('jwt-abc'); const { result } = renderAuth(); await waitFor(() => expect(result.current.isAuthenticated).toBe(true)); readStoredToken.mockClear(); act(() => { window.dispatchEvent(new StorageEvent('storage', { key: 'doormilePreferences' })); }); expect(readStoredToken).not.toHaveBeenCalled(); expect(result.current.isAuthenticated).toBe(true); }); it('should stop listening once unmounted', async () => { const removeSpy = jest.spyOn(window, 'removeEventListener'); const { unmount, result } = renderAuth(); await waitFor(() => expect(result.current.authChecked).toBe(true)); unmount(); expect(removeSpy).toHaveBeenCalledWith('storage', expect.any(Function)); }); }); describe('isClient role determination', () => { it('should detect client for info@dailygrubs.com', async () => { readStoredToken.mockReturnValue('jwt-abc'); readStoredUser.mockReturnValue({ email: 'info@dailygrubs.com', tenantid: 12 }); const { result } = renderAuth(); await waitFor(() => expect(result.current.authChecked).toBe(true)); expect(result.current.isClient).toBe(true); }); it('should detect client for user with tenantid', async () => { readStoredToken.mockReturnValue('jwt-abc'); readStoredUser.mockReturnValue({ email: 'client@partner.com', tenantid: 5 }); const { result } = renderAuth(); await waitFor(() => expect(result.current.authChecked).toBe(true)); expect(result.current.isClient).toBe(true); }); it('should not mark super admin / staff with tenantid 0 as client', async () => { readStoredToken.mockReturnValue('jwt-abc'); readStoredUser.mockReturnValue({ email: 'admin@doormile.com', tenantid: '0' }); const { result } = renderAuth(); await waitFor(() => expect(result.current.authChecked).toBe(true)); expect(result.current.isClient).toBe(false); }); }); describe('useAuth outside a provider', () => { it('should throw a named error rather than returning undefined', () => { // Silent undefined here would render an unauthenticated shell as if it // were authorized. const spy = jest.spyOn(console, 'error').mockImplementation(() => {}); expect(() => renderHook(() => useAuth())).toThrow('useAuth must be used within an AuthProvider'); spy.mockRestore(); }); }); });