updates on the reverse logistics
This commit is contained in:
@@ -444,7 +444,10 @@ describe('deliveries data layer', () => {
|
||||
activeLength: 1,
|
||||
coveredLength: 1,
|
||||
cancelLength: 1,
|
||||
skippedLength: 0
|
||||
skippedLength: 0,
|
||||
// Reverse logistics tabs (In return / Returned).
|
||||
rtoLength: 0,
|
||||
returnedLength: 0
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
191
tests/integration/returns.test.jsx
Normal file
191
tests/integration/returns.test.jsx
Normal file
@@ -0,0 +1,191 @@
|
||||
import React from 'react';
|
||||
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||
import { MemoryRouter } from 'react-router-dom';
|
||||
|
||||
/**
|
||||
* Reverse logistics (RTO) in the console: the shared dialogs, the Returns page
|
||||
* and the delivery-status mapping. HTTP is mocked with the backend's shapes
|
||||
* (POST /admin/consignments/:id/rto[/cancel|/complete], GET /admin/returns).
|
||||
*/
|
||||
|
||||
beforeAll(() => {
|
||||
global.ResizeObserver = global.ResizeObserver || class { observe() {} unobserve() {} disconnect() {} };
|
||||
});
|
||||
|
||||
jest.mock('lucide-react', () =>
|
||||
new Proxy({}, { get: (_t, prop) => (prop === '__esModule' ? true : (props) => <span data-testid={`icon-${String(prop)}`} {...props} />) })
|
||||
);
|
||||
|
||||
jest.mock('@/api/doormile', () => ({
|
||||
initiateRto: jest.fn(),
|
||||
cancelRto: jest.fn(),
|
||||
completeRto: jest.fn(),
|
||||
getReturns: jest.fn(),
|
||||
}));
|
||||
|
||||
jest.mock('@/api/doormile/notify', () => ({
|
||||
OpenToast: jest.fn(),
|
||||
messageOf: (err, fallback = 'Something went wrong') => err?.response?.data?.message || err?.message || fallback,
|
||||
}));
|
||||
|
||||
let mockAuth = { user: { email: 'ops@doormile.com', role: 'admin' }, isClient: false };
|
||||
jest.mock('@/lib/AuthContext', () => ({ useAuth: () => mockAuth }));
|
||||
|
||||
import * as api from '@/api/doormile';
|
||||
import { ResolveRtoModal, StartRtoModal, rtoActionsFor } from '@/components/doormile/RtoDialogs';
|
||||
import Returns, { returnAgeDays } from '@/pages/doormile/returns/Returns';
|
||||
import { deriveDeliveryStatus } from '@/api/doormile/queries';
|
||||
|
||||
const wrap = (ui) => {
|
||||
const qc = new QueryClient({ defaultOptions: { queries: { retry: false }, mutations: { retry: false } } });
|
||||
return render(
|
||||
<QueryClientProvider client={qc}>
|
||||
<MemoryRouter>{ui}</MemoryRouter>
|
||||
</QueryClientProvider>
|
||||
);
|
||||
};
|
||||
|
||||
const ROW = { consignmentid: 61, orderid: 'DM-000123', orderstatus: 'active' };
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
mockAuth = { user: { email: 'ops@doormile.com', role: 'admin' }, isClient: false };
|
||||
api.initiateRto.mockResolvedValue({ success: true, data: { started: true } });
|
||||
api.cancelRto.mockResolvedValue({ success: true, data: {} });
|
||||
api.completeRto.mockResolvedValue({ success: true, data: {} });
|
||||
});
|
||||
|
||||
describe('which rows may start or close a return', () => {
|
||||
it('starts only from a parcel in hand; closes only one in return', () => {
|
||||
expect(rtoActionsFor({ consignmentid: 1, orderstatus: 'active' })).toEqual({ canStart: true, canResolve: false });
|
||||
expect(rtoActionsFor({ consignmentid: 1, orderstatus: 'picked' }).canStart).toBe(true);
|
||||
expect(rtoActionsFor({ consignmentid: 1, orderstatus: 'skipped' }).canStart).toBe(true);
|
||||
expect(rtoActionsFor({ consignmentid: 1, orderstatus: 'delivered' }).canStart).toBe(false);
|
||||
expect(rtoActionsFor({ consignmentid: 1, orderstatus: 'pending' }).canStart).toBe(false);
|
||||
// No parcel yet (not picked up) — nothing to return.
|
||||
expect(rtoActionsFor({ consignmentid: null, orderstatus: 'active' }).canStart).toBe(false);
|
||||
expect(rtoActionsFor({ consignmentid: 1, orderstatus: 'rto' })).toEqual({ canStart: false, canResolve: true });
|
||||
});
|
||||
|
||||
it('maps the backend RTO statuses to their own tabs, leaving legacy keys alone', () => {
|
||||
expect(deriveDeliveryStatus('RTO_Initiated')).toBe('rto');
|
||||
expect(deriveDeliveryStatus('Returned_to_Sender')).toBe('returned');
|
||||
expect(deriveDeliveryStatus('rto')).toBe('skipped');
|
||||
expect(deriveDeliveryStatus('returned')).toBe('cancelled');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Start return dialog', () => {
|
||||
it('needs a reason, and a note for Other, then starts the return', async () => {
|
||||
wrap(<StartRtoModal row={ROW} onClose={jest.fn()} />);
|
||||
const start = screen.getByRole('button', { name: /Start return/ });
|
||||
expect(start).toBeDisabled();
|
||||
|
||||
fireEvent.change(screen.getByLabelText('Return reason'), { target: { value: 'other' } });
|
||||
expect(start).toBeDisabled(); // Other needs a description
|
||||
fireEvent.change(screen.getByLabelText('Return note'), { target: { value: 'Shop closed for good' } });
|
||||
expect(start).not.toBeDisabled();
|
||||
|
||||
fireEvent.click(start);
|
||||
await waitFor(() =>
|
||||
expect(api.initiateRto).toHaveBeenCalledWith(61, { reason: 'other', note: 'Shop closed for good' })
|
||||
);
|
||||
});
|
||||
|
||||
it('uses a caller-given label as the subtitle (Exceptions passes "Consignment #id")', () => {
|
||||
wrap(<StartRtoModal row={{ consignmentid: 9106, label: 'Consignment #9106' }} onClose={jest.fn()} />);
|
||||
expect(screen.getByText('Consignment #9106')).toBeInTheDocument();
|
||||
expect(screen.queryByText(/Order consignment/)).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('pre-selects a reason when given one', () => {
|
||||
wrap(<StartRtoModal row={{ ...ROW, defaultReason: 'receiver_refused' }} onClose={jest.fn()} />);
|
||||
expect(screen.getByLabelText('Return reason')).toHaveValue('receiver_refused');
|
||||
expect(screen.getByRole('button', { name: /Start return/ })).not.toBeDisabled();
|
||||
});
|
||||
|
||||
it('shows the server refusal and stays open', async () => {
|
||||
api.initiateRto.mockRejectedValue({ response: { status: 400, data: { message: 'a parcel that is delivered cannot be returned' } } });
|
||||
const onClose = jest.fn();
|
||||
wrap(<StartRtoModal row={ROW} onClose={onClose} />);
|
||||
fireEvent.change(screen.getByLabelText('Return reason'), { target: { value: 'receiver_refused' } });
|
||||
fireEvent.click(screen.getByRole('button', { name: /Start return/ }));
|
||||
expect(await screen.findByRole('alert')).toHaveTextContent('cannot be returned');
|
||||
expect(onClose).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Resolve return dialog', () => {
|
||||
it.each([
|
||||
['cancel', 'Re-attempt delivery', 'cancelRto'],
|
||||
['complete', 'Mark returned', 'completeRto'],
|
||||
])('%s calls the matching endpoint', async (mode, button, fn) => {
|
||||
const onClose = jest.fn();
|
||||
wrap(<ResolveRtoModal row={{ ...ROW, orderstatus: 'rto' }} mode={mode} onClose={onClose} />);
|
||||
fireEvent.change(screen.getByLabelText('Resolution note'), { target: { value: 'checked with client' } });
|
||||
fireEvent.click(screen.getByRole('button', { name: button }));
|
||||
await waitFor(() => expect(api[fn]).toHaveBeenCalledWith(61, 'checked with client'));
|
||||
await waitFor(() => expect(onClose).toHaveBeenCalled());
|
||||
});
|
||||
});
|
||||
|
||||
describe('Returns page', () => {
|
||||
const RETURNS = {
|
||||
success: true,
|
||||
total: 2,
|
||||
data: [
|
||||
{
|
||||
consignmentid: 61, trackingno: 'DMX00000061', tenantname: 'Acme Foods', status: 'RTO_Initiated',
|
||||
returnreason: 'Receiver refused', attemptcount: 1, returninitiatedat: '2026-10-01T10:00:00+05:30',
|
||||
returndeliveredat: null, milername: 'Ravi K', pickuppincode: '641001', deliverypincode: '641002',
|
||||
},
|
||||
{
|
||||
consignmentid: 62, trackingno: 'DMX00000062', tenantname: 'Beta Co', status: 'Returned_to_Sender',
|
||||
returnreason: 'Delivery attempts exhausted', attemptcount: 3, returninitiatedat: '2026-09-28T10:00:00+05:30',
|
||||
returndeliveredat: '2026-09-30T10:00:00+05:30', milername: '', pickuppincode: '641005', deliverypincode: '641009',
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
it('lists returns, starting on "In return", with actions only for open ones', async () => {
|
||||
api.getReturns.mockResolvedValue(RETURNS);
|
||||
wrap(<Returns />);
|
||||
expect(await screen.findByText('DMX00000061')).toBeInTheDocument();
|
||||
expect(api.getReturns.mock.calls[0][0]).toMatchObject({ status: 'initiated', pageno: 1, pagesize: 50 });
|
||||
expect(screen.getAllByRole('button', { name: 'Mark returned' })).toHaveLength(1);
|
||||
expect(screen.getAllByRole('button', { name: 'Re-attempt delivery' })).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('asks the server for the chosen tab', async () => {
|
||||
api.getReturns.mockResolvedValue(RETURNS);
|
||||
wrap(<Returns />);
|
||||
await screen.findByText('DMX00000061');
|
||||
// The tab row renders before the table, whose header also says Returned.
|
||||
fireEvent.click(screen.getAllByText('Returned')[0]);
|
||||
await waitFor(() => expect(api.getReturns.mock.calls.at(-1)[0]).toMatchObject({ status: 'returned', pageno: 1 }));
|
||||
});
|
||||
|
||||
it('is read-only for a client login', async () => {
|
||||
mockAuth = { user: { email: 'ops@acme.test', role: 'manager', tenantid: 7 }, isClient: true };
|
||||
api.getReturns.mockResolvedValue(RETURNS);
|
||||
wrap(<Returns />);
|
||||
await screen.findByText('DMX00000061');
|
||||
expect(screen.queryByRole('button', { name: 'Mark returned' })).not.toBeInTheDocument();
|
||||
expect(screen.queryByText('Actions')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('says plainly when the backend has no returns yet', async () => {
|
||||
api.getReturns.mockRejectedValue({ response: { status: 404 } });
|
||||
wrap(<Returns />);
|
||||
expect(await screen.findByRole('alert')).toHaveTextContent('deploy the current doormile_backend build');
|
||||
});
|
||||
|
||||
it('counts days a return has been open, or took', () => {
|
||||
const now = new Date('2026-10-05T10:00:00+05:30');
|
||||
const dayjs = jest.requireActual('dayjs');
|
||||
expect(returnAgeDays(RETURNS.data[0], dayjs(now))).toBe(4);
|
||||
expect(returnAgeDays(RETURNS.data[1], dayjs(now))).toBe(2);
|
||||
expect(returnAgeDays({}, dayjs(now))).toBeNull();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user