Files
doormilxpress_astryx/tests/api/endpoints.test.js
2026-08-27 13:40:48 +05:30

379 lines
16 KiB
JavaScript

/**
* The one-function-per-route admin API layer.
*
* The transport is mocked (there is no real backend in a unit run), but nothing
* else is: the URL built, the query string assembled, the body sent, the
* envelope unwrapped and the pagination draining are all the behaviour under
* test. A wrong URL or a wrong body key here is a production bug that no page
* test would catch.
*/
jest.mock('@/api/doormile/client', () => ({
__esModule: true,
default: { get: jest.fn(), post: jest.fn(), put: jest.fn(), patch: jest.fn(), delete: jest.fn() },
DOORMILE_TOKEN_KEY: 'doormileToken',
DOORMILE_USER_KEY: 'doormileUser',
clearStoredSession: jest.fn()
}));
const clientModule = jest.requireMock('@/api/doormile/client');
const mockClient = clientModule.default;
const mockClearStoredSession = clientModule.clearStoredSession;
import {
loginAdmin,
logoutAdmin,
getDashboard,
getReports,
getLocationsSummary,
getProfile,
updateProfilePassword,
getAdminCustomers,
createAdminCustomer,
updateAdminCustomer,
getHubs,
createHub,
deleteHub,
getMilers,
notifyMiler,
getMilerLogs,
getBookings,
getBookingsPage,
getBooking,
assignMilerToBooking,
assignVehicleToBooking,
batchAssignBookings,
cancelBooking,
bulkCancelBookings,
getConsignments,
updateConsignmentStatus,
getTenantLocations
} from '@/api/doormile/endpoints';
/** `{ data: { data: rows, total } }` — the list envelope the backend returns. */
const listPage = (rows, total) => ({ data: { success: true, data: rows, total } });
const rowsOfSize = (n, offset = 0) => Array.from({ length: n }, (_, i) => ({ id: offset + i + 1 }));
describe('doormile endpoints', () => {
describe('loginAdmin', () => {
it('should post the credentials to the admin login route', async () => {
mockClient.post.mockResolvedValue({ data: { success: true, token: 't', user: { userid: 1 } } });
await loginAdmin('ops@doormile.com', 'hunter2');
expect(mockClient.post).toHaveBeenCalledWith('/admin/login', {
email: 'ops@doormile.com',
password: 'hunter2'
});
});
it('should store the token and the admin record on success', async () => {
mockClient.post.mockResolvedValue({ data: { success: true, token: 'jwt-abc', user: { userid: 7, tenantid: '0' } } });
const result = await loginAdmin('ops@doormile.com', 'hunter2');
expect(localStorage.getItem('doormileToken')).toBe('jwt-abc');
expect(JSON.parse(localStorage.getItem('doormileUser'))).toEqual({ userid: 7, tenantid: '0' });
expect(result).toEqual({ success: true, token: 'jwt-abc', user: { userid: 7, tenantid: '0' } });
});
it('should store nothing when the server refuses the credentials', async () => {
// A half-written session (token stored on a failed login) is worse than no
// session: the console renders and 401s on every fetch.
mockClient.post.mockResolvedValue({ data: { success: false, message: 'Invalid credentials' } });
const result = await loginAdmin('ops@doormile.com', 'wrong');
expect(localStorage.getItem('doormileToken')).toBeNull();
expect(localStorage.getItem('doormileUser')).toBeNull();
expect(result).toEqual({ success: false, message: 'Invalid credentials' });
});
it('should propagate a transport failure to the caller', async () => {
mockClient.post.mockRejectedValue({ message: 'Network Error' });
await expect(loginAdmin('a@b.c', 'x')).rejects.toMatchObject({ message: 'Network Error' });
expect(localStorage.getItem('doormileToken')).toBeNull();
});
});
describe('logoutAdmin', () => {
it('should clear the whole stored session, not just the token', () => {
logoutAdmin();
expect(mockClearStoredSession).toHaveBeenCalledTimes(1);
});
});
describe('envelope unwrapping', () => {
it('should return response.data.data for a GET', async () => {
mockClient.get.mockResolvedValue({ data: { success: true, data: { totalOrders: 12 } } });
await expect(getDashboard()).resolves.toEqual({ totalOrders: 12 });
});
it('should return the full response.data for a mutation, so callers can read success', async () => {
mockClient.post.mockResolvedValue({ data: { success: true, message: 'Hub created' } });
await expect(createHub({ hubname: 'Koramangala' })).resolves.toEqual({
success: true,
message: 'Hub created'
});
});
it('should return the parsed profile', async () => {
mockClient.get.mockResolvedValue({ data: { data: { userid: 7 } } });
await expect(getProfile()).resolves.toEqual({ userid: 7 });
expect(mockClient.get).toHaveBeenCalledWith('/admin/profile');
});
});
describe('query-string building', () => {
beforeEach(() => mockClient.get.mockResolvedValue({ data: { data: [] } }));
it('should include every supplied report filter', async () => {
await getReports('2026-08-01', '2026-08-27', '4', '9', '2');
expect(mockClient.get).toHaveBeenCalledWith(
'/admin/reports?from=2026-08-01&to=2026-08-27&tenantid=4&locationid=9&hubid=2'
);
});
it('should omit undefined, null and empty filters rather than sending blanks', async () => {
// `?tenantid=` is not the same request as no tenant filter — one asks the
// backend to match the empty string.
await getReports('2026-08-01', '2026-08-27', undefined, null, '');
expect(mockClient.get).toHaveBeenCalledWith('/admin/reports?from=2026-08-01&to=2026-08-27');
});
it('should produce no query string at all when every filter is absent', async () => {
await getReports();
expect(mockClient.get).toHaveBeenCalledWith('/admin/reports');
});
it('should keep a zero filter value, which is a real id', async () => {
await getLocationsSummary(0, 0, '2026-08-01', '2026-08-27');
expect(mockClient.get).toHaveBeenCalledWith(
expect.stringContaining('tenantid=0')
);
});
it('should url-encode a filter value', async () => {
await getMilerLogs(3, '2026-08-01 00:00', '2026-08-27 23:59', 50);
const url = mockClient.get.mock.calls[0][0];
expect(url).toContain('/admin/milers/3/logs?');
expect(url).not.toMatch(/ /);
});
});
describe('fetchAllPages (getAdminCustomers)', () => {
it('should return a single page when it already holds every row', async () => {
mockClient.get.mockResolvedValueOnce(listPage(rowsOfSize(12), 12));
await expect(getAdminCustomers()).resolves.toHaveLength(12);
expect(mockClient.get).toHaveBeenCalledTimes(1);
expect(mockClient.get).toHaveBeenCalledWith('/admin/customers?pageno=1&pagesize=100');
});
it('should drain every page rather than silently truncating the list', async () => {
// The regression this protects: 37 customers came back as 20, and the
// Customers page reported "Total Customers 20" with no way to tell.
mockClient.get
.mockResolvedValueOnce(listPage(rowsOfSize(100), 237))
.mockResolvedValueOnce(listPage(rowsOfSize(100, 100), 237))
.mockResolvedValueOnce(listPage(rowsOfSize(37, 200), 237));
await expect(getAdminCustomers()).resolves.toHaveLength(237);
expect(mockClient.get).toHaveBeenCalledTimes(3);
expect(mockClient.get).toHaveBeenNthCalledWith(3, '/admin/customers?pageno=3&pagesize=100');
});
it('should stop early when a page comes back empty', async () => {
mockClient.get
.mockResolvedValueOnce(listPage(rowsOfSize(100), 500))
.mockResolvedValueOnce(listPage([], 500));
await expect(getAdminCustomers()).resolves.toHaveLength(100);
expect(mockClient.get).toHaveBeenCalledTimes(2);
});
it('should cap the drain so a wrong `total` cannot spin forever', async () => {
mockClient.get.mockResolvedValue(listPage(rowsOfSize(100), 10_000_000));
await getAdminCustomers();
expect(mockClient.get.mock.calls.length).toBeLessThanOrEqual(12);
});
it('should return the first page when the envelope carries no usable total', async () => {
mockClient.get.mockResolvedValueOnce({ data: { data: rowsOfSize(20) } });
await expect(getAdminCustomers()).resolves.toHaveLength(20);
expect(mockClient.get).toHaveBeenCalledTimes(1);
});
it('should return an empty array — not undefined — for an empty resource', async () => {
mockClient.get.mockResolvedValueOnce({ data: { data: null, total: 0 } });
await expect(getAdminCustomers()).resolves.toEqual([]);
});
it('should propagate a failure on the first page', async () => {
mockClient.get.mockRejectedValueOnce({ message: 'boom', httpStatus: 500 });
await expect(getAdminCustomers()).rejects.toMatchObject({ message: 'boom' });
});
});
describe('getBookings', () => {
it('should drain all pages when the first page is full', async () => {
mockClient.get
.mockResolvedValueOnce(listPage(rowsOfSize(100), 150))
.mockResolvedValueOnce(listPage(rowsOfSize(50, 100), 150));
await expect(getBookings(1, 100)).resolves.toHaveLength(150);
});
it('should return the first page when it is not full', async () => {
mockClient.get.mockResolvedValueOnce(listPage(rowsOfSize(37), 37));
await expect(getBookings(1, 100)).resolves.toHaveLength(37);
expect(mockClient.get).toHaveBeenCalledTimes(1);
});
it('should honour an explicit small page beyond the first', async () => {
mockClient.get.mockResolvedValueOnce(listPage(rowsOfSize(25, 25), 200));
await expect(getBookings(2, 25)).resolves.toHaveLength(25);
expect(mockClient.get).toHaveBeenCalledWith('/admin/bookings?pageno=2&pagesize=25');
});
it('should stop draining rather than reject when a later page fails', async () => {
// Partial rows beat a blank Deliveries page.
mockClient.get
.mockResolvedValueOnce(listPage(rowsOfSize(100), 300))
.mockRejectedValueOnce({ message: 'gateway timeout' });
await expect(getBookings(1, 100)).resolves.toHaveLength(100);
});
it('should return an empty array for an account with no bookings', async () => {
mockClient.get.mockResolvedValueOnce({ data: { data: [], total: 0 } });
await expect(getBookings(1, 100)).resolves.toEqual([]);
});
});
describe('getBookingsPage', () => {
it('should keep the envelope total instead of throwing it away', async () => {
mockClient.get.mockResolvedValue(listPage(rowsOfSize(50), 1234));
await expect(getBookingsPage(1, 50)).resolves.toEqual({
rows: expect.any(Array),
total: 1234,
page: 1
});
});
it('should fall back to the row count when the backend omits total', async () => {
// Degrading to "this is everything" beats NaN-driven pagination.
mockClient.get.mockResolvedValue({ data: { data: rowsOfSize(7) } });
await expect(getBookingsPage(1, 50)).resolves.toMatchObject({ total: 7, page: 1 });
});
it('should fall back to the requested page number when the backend omits page', async () => {
mockClient.get.mockResolvedValue({ data: { data: [], total: 0 } });
await expect(getBookingsPage(4, 50)).resolves.toMatchObject({ page: 4 });
});
});
describe('booking write paths', () => {
beforeEach(() => mockClient.post.mockResolvedValue({ data: { success: true } }));
it('should assign a miler through the booking-scoped assign route', async () => {
await assignMilerToBooking(4021, { mileruserid: 91 });
expect(mockClient.post).toHaveBeenCalledWith('/admin/bookings/4021/assign-miler', { mileruserid: 91 });
});
it('should cancel through the booking-level cancel action, not a status PUT', async () => {
await cancelBooking(4021, { reason: 'customer unreachable' });
expect(mockClient.post).toHaveBeenCalledWith('/admin/bookings/4021/cancel', {
reason: 'customer unreachable'
});
expect(mockClient.put).not.toHaveBeenCalled();
});
it('should assign a vehicle through its own route', async () => {
await assignVehicleToBooking(4021, { vehicleid: 5 });
expect(mockClient.post).toHaveBeenCalledWith('/admin/bookings/4021/assign-vehicle', { vehicleid: 5 });
});
it('should batch-assign with a default cap of five stops per rider', async () => {
await batchAssignBookings([1, 2, 3]);
expect(mockClient.post).toHaveBeenCalledWith('/hub/bookings/batch-assign', {
bookingids: [1, 2, 3],
max_per_rider: 5
});
});
it('should honour an explicit per-rider cap', async () => {
await batchAssignBookings([1, 2], 2);
expect(mockClient.post).toHaveBeenCalledWith('/hub/bookings/batch-assign', {
bookingids: [1, 2],
max_per_rider: 2
});
});
it('should bulk-cancel through the dedicated route', async () => {
await bulkCancelBookings([1, 2, 3]);
expect(mockClient.post).toHaveBeenCalledWith('/admin/bookings/bulk-cancel', { bookingIds: [1, 2, 3] });
});
it('should surface a rejected write to the caller', async () => {
mockClient.post.mockRejectedValue({ message: 'booking already cancelled', httpStatus: 409 });
await expect(cancelBooking(4021, {})).rejects.toMatchObject({ httpStatus: 409 });
});
});
describe('consignment write path', () => {
it('should PUT the status to the consignment-scoped route', async () => {
mockClient.put.mockResolvedValue({ data: { success: true } });
await updateConsignmentStatus(40, { status: 'Delivered' });
expect(mockClient.put).toHaveBeenCalledWith('/admin/consignments/40/status', { status: 'Delivered' });
});
it('should read the consignment list from its own resource', async () => {
mockClient.get.mockResolvedValue({ data: { data: [{ consignmentid: 40 }] } });
await expect(getConsignments()).resolves.toEqual([{ consignmentid: 40 }]);
expect(mockClient.get).toHaveBeenCalledWith('/admin/consignments');
});
});
describe('resource routes', () => {
beforeEach(() => {
mockClient.get.mockResolvedValue({ data: { data: [] } });
mockClient.post.mockResolvedValue({ data: { success: true } });
mockClient.patch.mockResolvedValue({ data: { success: true } });
mockClient.delete.mockResolvedValue({ data: { success: true } });
});
it.each([
['getHubs', () => getHubs(), '/admin/hubs'],
['getMilers', () => getMilers(), '/admin/milers'],
['getBooking', () => getBooking(4021), '/admin/bookings/4021'],
['getTenantLocations', () => getTenantLocations(4), '/admin/tenants/4/locations']
])('%s should read from %s', async (_name, call, url) => {
await call();
expect(mockClient.get).toHaveBeenCalledWith(url);
});
it('should create a customer with POST and update one with PATCH', async () => {
await createAdminCustomer({ customername: 'Anita' });
expect(mockClient.post).toHaveBeenCalledWith('/admin/customers', { customername: 'Anita' });
await updateAdminCustomer(9, { phone: '9876543210' });
expect(mockClient.patch).toHaveBeenCalledWith('/admin/customers/9', { phone: '9876543210' });
});
it('should delete a hub through its own id-scoped route', async () => {
await deleteHub(3);
expect(mockClient.delete).toHaveBeenCalledWith('/admin/hubs/3');
});
it('should notify a miler by milerprofileid with a title and message', async () => {
await notifyMiler(11, 'New assignment', 'Order 4021 assigned to you');
expect(mockClient.post).toHaveBeenCalledWith(
expect.stringContaining('/admin/milers/11/notify'),
expect.objectContaining({ title: 'New assignment', message: 'Order 4021 assigned to you' })
);
});
it('should change a password through the profile route with both fields', async () => {
mockClient.put.mockResolvedValue({ data: { success: true } });
await updateProfilePassword('old-secret', 'new-secret');
const [, body] = mockClient.put.mock.calls[0];
expect(body).toMatchObject({ current_password: 'old-secret', new_password: 'new-secret' });
});
});
});