updates on the reverse logistics

This commit is contained in:
2026-10-06 17:30:06 +05:30
parent 806f4a2b3f
commit 6e73b59ca9
12 changed files with 1069 additions and 21 deletions

View 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();
});
});