updated the test
This commit is contained in:
282
tests/lib/AuthContext.test.jsx
Normal file
282
tests/lib/AuthContext.test.jsx
Normal file
@@ -0,0 +1,282 @@
|
||||
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('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();
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user