/** * 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' }); }); }); });