/** * The Doormile admin API client: bearer-token attachment, 401 session ending, * and the error-normalisation contract every write path depends on. * * axios is mocked at the module boundary so the two interceptors can be invoked * directly — they are the unit under test, so nothing about their logic is * stubbed, only the transport underneath them. */ jest.mock('axios', () => { const handlers = { request: null, responseOk: null, responseErr: null }; const instance = { interceptors: { request: { use: (fn) => { handlers.request = fn; } }, response: { use: (ok, err) => { handlers.responseOk = ok; handlers.responseErr = err; } } } }; const mockAxios = { create: jest.fn(() => instance), __handlers: handlers, __instance: instance }; return { __esModule: true, default: mockAxios }; }); import axios from 'axios'; import doormileAxios, { DOORMILE_TOKEN_KEY, DOORMILE_USER_KEY, DOORMILE_SESSION_KEYS, DOORMILE_ASSISTANT_KEYS, DOORMILE_API_URL, DOORMILE_BASE_URL, clearStoredSession, readStoredUser, readStoredToken, errorMessage } from '@/api/doormile/client'; const handlers = axios.__handlers; /** Replaces window.location with a spyable stand-in (jsdom forbids assignment). */ const stubLocation = (href) => { const replace = jest.fn(); Object.defineProperty(window, 'location', { writable: true, configurable: true, value: { href, replace } }); return replace; }; const rejection = (fn) => fn().then( () => { throw new Error('expected the interceptor to reject'); }, (err) => err ); describe('doormile API client', () => { describe('base URL', () => { it('should point at the real production API by default', () => { expect(DOORMILE_API_URL).toBe('https://api.doormile.com/api/v1'); expect(DOORMILE_BASE_URL).toBe(DOORMILE_API_URL); }); it('should create the axios instance against the resolved base URL', () => { jest.isolateModules(() => { globalThis.__VITE_IMPORT_META__ = { env: { DEV: false } }; // eslint-disable-next-line global-require const reloaded = require('@/api/doormile/client'); expect(axios.create).toHaveBeenCalledWith({ baseURL: reloaded.DOORMILE_BASE_URL }); }); }); it('should let VITE_DOORMILE_URL redirect the console at a staging backend', () => { jest.isolateModules(() => { globalThis.__VITE_IMPORT_META__ = { env: { DEV: false, VITE_DOORMILE_URL: 'http://localhost:9000/api/v1' } }; // eslint-disable-next-line global-require const reloaded = require('@/api/doormile/client'); expect(reloaded.DOORMILE_BASE_URL).toBe('http://localhost:9000/api/v1'); }); }); it('should warn loudly in development when the backend is overridden', () => { const warn = jest.spyOn(console, 'warn').mockImplementation(() => {}); jest.isolateModules(() => { globalThis.__VITE_IMPORT_META__ = { env: { DEV: true, VITE_DOORMILE_URL: 'http://localhost:9000/api/v1' } }; // eslint-disable-next-line global-require require('@/api/doormile/client'); }); expect(warn).toHaveBeenCalledWith(expect.stringContaining('http://localhost:9000/api/v1')); expect(warn).toHaveBeenCalledWith(expect.stringContaining(DOORMILE_API_URL)); }); it('should stay silent in development when no override is set', () => { const warn = jest.spyOn(console, 'warn').mockImplementation(() => {}); jest.isolateModules(() => { globalThis.__VITE_IMPORT_META__ = { env: { DEV: true } }; // eslint-disable-next-line global-require require('@/api/doormile/client'); }); expect(warn).not.toHaveBeenCalled(); }); }); describe('request interceptor', () => { it('should attach the stored token as a Bearer credential', () => { localStorage.setItem(DOORMILE_TOKEN_KEY, 'jwt-abc'); const config = handlers.request({ headers: {} }); expect(config.headers.Authorization).toBe('Bearer jwt-abc'); }); it('should leave the request unauthenticated when no token is stored', () => { // Login itself is issued through this instance; forcing a header would // send `Bearer null` on the one call that must not carry one. const config = handlers.request({ headers: {} }); expect(config.headers.Authorization).toBeUndefined(); }); it('should preserve headers the caller already set', () => { localStorage.setItem(DOORMILE_TOKEN_KEY, 'jwt-abc'); const config = handlers.request({ headers: { 'Content-Type': 'multipart/form-data' } }); expect(config.headers['Content-Type']).toBe('multipart/form-data'); expect(config.headers.Authorization).toBe('Bearer jwt-abc'); }); it('should return the same config object the caller passed', () => { const config = { headers: {}, url: '/admin/bookings' }; expect(handlers.request(config)).toBe(config); }); }); describe('response interceptor — success', () => { it('should pass a successful response through untouched', () => { const response = { status: 200, data: { success: true } }; expect(handlers.responseOk(response)).toBe(response); }); }); describe('response interceptor — 401 handling', () => { it('should end the session and send the operator back to login on a 401', () => { const replace = stubLocation('http://console.local/deliveries'); localStorage.setItem(DOORMILE_TOKEN_KEY, 'jwt-abc'); localStorage.setItem(DOORMILE_USER_KEY, '{"id":1}'); return rejection(() => handlers.responseErr({ response: { status: 401, data: { message: 'expired' } } })).then(() => { expect(localStorage.getItem(DOORMILE_TOKEN_KEY)).toBeNull(); expect(localStorage.getItem(DOORMILE_USER_KEY)).toBeNull(); expect(replace).toHaveBeenCalledWith('/login'); }); }); it('should NOT redirect when the 401 came from the login page itself', async () => { // A refused password must show the server's message in place, not bounce // the operator through a page reload that loses what they typed. const replace = stubLocation('http://console.local/login'); localStorage.setItem(DOORMILE_TOKEN_KEY, 'jwt-abc'); await rejection(() => handlers.responseErr({ response: { status: 401, data: { message: 'bad password' } } })); expect(replace).not.toHaveBeenCalled(); expect(localStorage.getItem(DOORMILE_TOKEN_KEY)).toBe('jwt-abc'); }); it.each([400, 403, 404, 409, 500])( 'should leave the session alone on a %d, which is not an auth failure', async (status) => { const replace = stubLocation('http://console.local/deliveries'); localStorage.setItem(DOORMILE_TOKEN_KEY, 'jwt-abc'); await rejection(() => handlers.responseErr({ response: { status, data: { message: 'nope' } } })); expect(replace).not.toHaveBeenCalled(); expect(localStorage.getItem(DOORMILE_TOKEN_KEY)).toBe('jwt-abc'); } ); }); describe('response interceptor — error normalisation', () => { beforeEach(() => stubLocation('http://console.local/deliveries')); it("should reject with the server's own body so callers can read err.message", async () => { const err = await rejection(() => handlers.responseErr({ response: { status: 400, data: { success: false, message: 'status is required' } } }) ); expect(err.message).toBe('status is required'); expect(err.success).toBe(false); }); it('should keep the HTTP status reachable as httpStatus', async () => { // Without this a 404, a 500 and a validation failure collapse into one // generic failure — exactly the distinction the write paths need. const err = await rejection(() => handlers.responseErr({ response: { status: 404, data: { message: 'not found' } } }) ); expect(err.httpStatus).toBe(404); }); it('should make httpStatus non-enumerable so it never leaks into a spread', async () => { const err = await rejection(() => handlers.responseErr({ response: { status: 409, data: { message: 'conflict' } } }) ); expect(Object.keys(err)).not.toContain('httpStatus'); expect(JSON.parse(JSON.stringify(err))).not.toHaveProperty('httpStatus'); expect({ ...err }.httpStatus).toBeUndefined(); expect(err.httpStatus).toBe(409); }); it('should wrap a plain-text error body as { message }', async () => { const err = await rejection(() => handlers.responseErr({ response: { status: 500, data: 'Internal Server Error' } }) ); expect(err.message).toBe('Internal Server Error'); expect(err.httpStatus).toBe(500); }); it.each([ ['a network failure with no response', { message: 'Network Error' }], ['a response carrying a null body', { response: { status: 502, data: null } }], ['a response carrying no body at all', { response: { status: 502 } }] ])('should reject with the original error for %s', async (_label, raw) => { const err = await rejection(() => handlers.responseErr(raw)); expect(err).toBe(raw); }); }); describe('clearStoredSession', () => { it('should remove the credential, the identity and the assistant transcript', () => { localStorage.setItem(DOORMILE_TOKEN_KEY, 'jwt-abc'); localStorage.setItem(DOORMILE_USER_KEY, '{"id":1}'); DOORMILE_SESSION_KEYS.forEach((key) => localStorage.setItem(key, 'x')); DOORMILE_ASSISTANT_KEYS.forEach((key) => localStorage.setItem(key, 'x')); clearStoredSession(); [DOORMILE_TOKEN_KEY, DOORMILE_USER_KEY, ...DOORMILE_SESSION_KEYS, ...DOORMILE_ASSISTANT_KEYS].forEach((key) => { expect(localStorage.getItem(key)).toBeNull(); }); }); it('should clear tenantid, which decides staff-vs-tenant scope', () => { // A stale tenantid is readable by the next operator before they sign in. expect(DOORMILE_SESSION_KEYS).toContain('tenantid'); localStorage.setItem('tenantid', '42'); clearStoredSession(); expect(localStorage.getItem('tenantid')).toBeNull(); }); it('should clear the assistant transcript, which contains customer data', () => { expect(DOORMILE_ASSISTANT_KEYS).toEqual( expect.arrayContaining(['doormileBotHistory', 'doormileBotConversations']) ); }); it('should leave non-session preferences in place', () => { // The panel width is a preference, not session data — a shared warehouse // terminal should keep its layout across sign-ins. localStorage.setItem('doormileBotPanelWidth', '420'); localStorage.setItem('doormilePreferences', '{"compactDensity":true}'); clearStoredSession(); expect(localStorage.getItem('doormileBotPanelWidth')).toBe('420'); expect(localStorage.getItem('doormilePreferences')).toBe('{"compactDensity":true}'); }); it('should be safe to call when nothing is stored', () => { expect(() => clearStoredSession()).not.toThrow(); }); }); describe('readStoredUser / readStoredToken', () => { it('should return the parsed admin record', () => { localStorage.setItem(DOORMILE_USER_KEY, JSON.stringify({ userid: 7, tenantid: '0' })); expect(readStoredUser()).toEqual({ userid: 7, tenantid: '0' }); }); it('should return null when signed out', () => { expect(readStoredUser()).toBeNull(); expect(readStoredToken()).toBeNull(); }); it('should return null — not throw — for a corrupt stored record', () => { // A half-written record must not take the whole console down on boot. localStorage.setItem(DOORMILE_USER_KEY, '{not json'); expect(readStoredUser()).toBeNull(); }); it('should return the raw token string', () => { localStorage.setItem(DOORMILE_TOKEN_KEY, 'jwt-abc'); expect(readStoredToken()).toBe('jwt-abc'); }); }); describe('errorMessage', () => { it("should prefer the normalised body's own message", () => { expect(errorMessage({ message: 'status is required' })).toBe('status is required'); }); it('should fall back to a raw axios error body', () => { expect(errorMessage({ response: { data: { message: 'server said no' } } })).toBe('server said no'); }); it('should prefer err.message over the nested response body', () => { expect(errorMessage({ message: 'outer', response: { data: { message: 'inner' } } })).toBe('outer'); }); it.each([null, undefined, {}, { message: '' }])( 'should fall back to the default text for %p', (err) => { expect(errorMessage(err)).toBe('Something went wrong'); } ); it('should honour a caller-supplied fallback', () => { expect(errorMessage(null, 'Could not load deliveries')).toBe('Could not load deliveries'); }); }); describe('module surface', () => { it('should export the configured axios instance as the default', () => { expect(doormileAxios).toBe(axios.__instance); }); }); });