Files
doormilxpress_astryx/tests/api/client.test.js

364 lines
15 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,
friendlyErrorMessage
} 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('friendlyErrorMessage', () => {
const withStatus = (body, status) => Object.defineProperty(body, 'httpStatus', { value: status });
it('should explain an unreachable server instead of showing "Network Error"', () => {
const msg = friendlyErrorMessage({ message: 'Network Error' }, 'create this order');
expect(msg).toMatch(/^We couldn't create this order because the Doormile server couldn't be reached/);
expect(msg).not.toMatch(/Network Error/);
});
it('should explain a timeout', () => {
expect(friendlyErrorMessage({ code: 'ECONNABORTED', message: 'timeout of 0ms exceeded' }, 'create this order'))
.toMatch(/took too long to respond/);
});
it('should keep the server reason for a validation failure, since the user can fix it', () => {
expect(friendlyErrorMessage(withStatus({ message: 'Pickup pincode 110001 is outside our service cities' }, 400), 'create this order'))
.toBe("We couldn't create this order: Pickup pincode 110001 is outside our service cities");
});
it('should hide server internals behind a plain message on a 5xx', () => {
const msg = friendlyErrorMessage(withStatus({ message: 'relation "bookings" does not exist' }, 500), 'create this order');
expect(msg).toMatch(/problem on our server/);
expect(msg).not.toMatch(/relation/);
});
it.each([
[401, /session has expired/],
[403, /don't have permission to create this order/],
[404, /no longer exists/],
[429, /Too many requests/]
])('should explain HTTP %i', (status, pattern) => {
expect(friendlyErrorMessage(withStatus({}, status), 'create this order')).toMatch(pattern);
});
it('should fall back to a check-your-details message on a 4xx with no reason', () => {
expect(friendlyErrorMessage(withStatus({}, 422), 'create this order'))
.toBe("We couldn't create this order. Please check the details you entered and try again.");
});
});
describe('module surface', () => {
it('should export the configured axios instance as the default', () => {
expect(doormileAxios).toBe(axios.__instance);
});
});
});