Files
doormilxpress_astryx/tests/lib/AuthContext.test.jsx

309 lines
12 KiB
JavaScript

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 }) => <AuthProvider>{children}</AuthProvider>;
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();
});
});
});