323 lines
13 KiB
JavaScript
323 lines
13 KiB
JavaScript
/**
|
|
* 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);
|
|
});
|
|
});
|
|
});
|