import { renderHook, act } from '@testing-library/react'; jest.mock('@/lib/doormileHooks', () => ({ __esModule: true, useHubs: jest.fn(), useTenantLocations: jest.fn() })); jest.mock('@/lib/AuthContext', () => ({ __esModule: true, useAuth: jest.fn() })); import { ZoneProvider, useZone } from '@/lib/ZoneContext'; import { useHubs, useTenantLocations } from '@/lib/doormileHooks'; import { useAuth } from '@/lib/AuthContext'; const ZONE_STORAGE_KEY = 'doormile_active_zone_id'; const KORAMANGALA = { hubid: '2', hubname: 'Koramangala Hub', city: 'Bengaluru', pincode: '560034', latitude: 12.9352, longitude: 77.6245, status: 'Active' }; const wrapper = ({ children }) => {children}; const setup = ({ user = { tenantid: '0' }, hubs = [KORAMANGALA], locations = [] } = {}) => { useAuth.mockReturnValue({ user }); useHubs.mockReturnValue({ data: hubs, isLoading: false, refetch: jest.fn() }); useTenantLocations.mockReturnValue({ data: locations, isLoading: false, refetch: jest.fn() }); return renderHook(() => useZone(), { wrapper }); }; /** * Zone scoping — which hub or kitchen an operator is currently looking at, and * which rows belong to it. * * This is a data-visibility boundary, not just a filter: on a tenant login it * decides whether another tenant's orders can appear on screen at all. The * tenant check therefore has its own tests, separate from the hub matching. */ describe('ZoneContext', () => { describe('zone list — staff login', () => { it('should offer All Zones plus every active hub', () => { const { result } = setup({ hubs: [KORAMANGALA, { ...KORAMANGALA, hubid: '3', hubname: 'HSR Hub' }] }); expect(result.current.zones.map((z) => z.hubid)).toEqual(['all', '2', '3']); }); it('should hide an inactive hub', () => { const { result } = setup({ hubs: [KORAMANGALA, { ...KORAMANGALA, hubid: '9', status: 'Inactive' }] }); expect(result.current.zones.map((z) => z.hubid)).toEqual(['all', '2']); }); it('should treat a hub with no status as active', () => { const { result } = setup({ hubs: [{ ...KORAMANGALA, status: undefined }] }); expect(result.current.zones).toHaveLength(2); }); it('should still offer All Zones when there are no hubs at all', () => { const { result } = setup({ hubs: [] }); expect(result.current.zones).toHaveLength(1); expect(result.current.isAllZones).toBe(true); }); it('should not load tenant locations for a staff login', () => { setup({ user: { tenantid: '0' } }); expect(useHubs).toHaveBeenCalledWith({ enabled: true }); expect(useTenantLocations).toHaveBeenCalledWith(null, { enabled: false }); }); }); describe('zone list — tenant login', () => { const LOCATION = { tenantlocationid: 55, locationname: 'Indiranagar Kitchen', city: 'Bengaluru', latitude: 12.9784, longitude: 77.6408 }; it('should offer the tenant kitchen hubs instead of global hubs', () => { const { result } = setup({ user: { tenantid: '4' }, locations: [LOCATION] }); expect(result.current.isTenantUser).toBe(true); expect(result.current.zones.map((z) => z.hubname)).toEqual(['All Zones', 'Indiranagar Kitchen']); expect(result.current.zones[1].hubtype).toBe('kitchen_hub'); }); it('should scope the location query to the operator own tenant', () => { setup({ user: { tenantid: '4' }, locations: [LOCATION] }); expect(useTenantLocations).toHaveBeenCalledWith('4', { enabled: true }); expect(useHubs).toHaveBeenCalledWith({ enabled: false }); }); it('should treat tenantid "0" as staff, not as a tenant', () => { const { result } = setup({ user: { tenantid: '0' } }); expect(result.current.isTenantUser).toBe(false); }); it('should fall back to the stored tenantid when the user record has none', () => { localStorage.setItem('tenantid', '4'); const { result } = setup({ user: {}, locations: [LOCATION] }); expect(result.current.isTenantUser).toBe(true); expect(result.current.tenantId).toBe('4'); }); it('should label an unnamed kitchen hub by its id', () => { const { result } = setup({ user: { tenantid: '4' }, locations: [{ tenantlocationid: 55 }] }); expect(result.current.zones[1].hubname).toBe('Kitchen Hub #55'); }); }); describe('zone selection', () => { it('should default to All Zones', () => { const { result } = setup(); expect(result.current.selectedZoneId).toBe('all'); expect(result.current.isAllZones).toBe(true); }); it('should restore the previously selected zone from storage', () => { localStorage.setItem(ZONE_STORAGE_KEY, '2'); const { result } = setup(); expect(result.current.selectedZoneId).toBe('2'); expect(result.current.selectedZone.hubname).toBe('Koramangala Hub'); expect(result.current.isAllZones).toBe(false); }); it('should persist a new selection', () => { const { result } = setup(); act(() => result.current.setSelectedZoneId('2')); expect(localStorage.getItem(ZONE_STORAGE_KEY)).toBe('2'); expect(result.current.selectedZone.hubid).toBe('2'); }); it('should coerce a numeric zone id to a string', () => { const { result } = setup(); act(() => result.current.setSelectedZoneId(2)); expect(result.current.selectedZoneId).toBe('2'); expect(result.current.selectedZone.hubname).toBe('Koramangala Hub'); }); it.each([null, undefined, ''])('should fall back to All Zones for the selection %p', (id) => { const { result } = setup(); act(() => result.current.setSelectedZoneId(id)); expect(result.current.selectedZoneId).toBe('all'); }); it('should fall back to All Zones when the stored zone no longer exists', () => { // A hub that was deleted or deactivated must not leave the console // filtering against a zone that matches nothing. localStorage.setItem(ZONE_STORAGE_KEY, '999'); const { result } = setup(); expect(result.current.selectedZone.hubid).toBe('all'); expect(result.current.isAllZones).toBe(true); }); it('should survive a storage read failure at boot', () => { const getItem = jest.spyOn(Storage.prototype, 'getItem').mockImplementation(() => { throw new Error('storage disabled'); }); expect(() => setup()).not.toThrow(); getItem.mockRestore(); }); it('should not throw when persisting is refused', () => { const setItem = jest.spyOn(Storage.prototype, 'setItem').mockImplementation(() => { throw new Error('QuotaExceededError'); }); const { result } = setup(); expect(() => act(() => result.current.setSelectedZoneId('2'))).not.toThrow(); expect(result.current.selectedZoneId).toBe('2'); setItem.mockRestore(); }); }); describe('matchesZone — All Zones', () => { it('should match any row', () => { const { result } = setup(); expect(result.current.matchesZone({ hubid: '999' })).toBe(true); expect(result.current.matchesZone({})).toBe(true); }); it('should still reject a null row', () => { const { result } = setup(); expect(result.current.matchesZone(null)).toBe(false); expect(result.current.matchesZone(undefined)).toBe(false); }); }); describe('matchesZone — tenant isolation', () => { it('should reject a row belonging to a different tenant even under All Zones', () => { // The data-visibility boundary: tenant scoping is checked BEFORE the // all-zones shortcut, so another tenant's orders can never render. localStorage.setItem('tenantid', '4'); const { result } = setup({ user: { tenantid: '4' }, locations: [{ tenantlocationid: 55 }] }); expect(result.current.isAllZones).toBe(true); expect(result.current.matchesZone({ tenantid: '9' })).toBe(false); expect(result.current.matchesZone({ tenantid: '4' })).toBe(true); }); it('should compare tenant ids across number and string forms', () => { const { result } = setup({ user: { tenantid: '4' }, locations: [{ tenantlocationid: 55 }] }); expect(result.current.matchesZone({ tenantid: 4 })).toBe(true); }); it('should not apply tenant scoping to a staff login', () => { const { result } = setup({ user: { tenantid: '0' } }); expect(result.current.matchesZone({ tenantid: '9' })).toBe(true); }); it('should allow a row that carries no tenant id at all', () => { const { result } = setup({ user: { tenantid: '4' }, locations: [{ tenantlocationid: 55 }] }); expect(result.current.matchesZone({ bookingid: 1 })).toBe(true); }); }); describe('matchesZone — hub matching for orders', () => { const selectKoramangala = () => { localStorage.setItem(ZONE_STORAGE_KEY, '2'); return setup(); }; it.each(['hubid', 'sourcehubid', 'servicinghubid', 'pickuphubid', 'nearesthubid'])( 'should match on a direct %s', (field) => { const { result } = selectKoramangala(); expect(result.current.matchesZone({ [field]: '2' })).toBe(true); } ); // CHANGED. These three used to be asserted as matches, and that was the // bug rather than the contract. // // A staff zone is a HUB. `applocationid` numbers cities, `tenantlocationid` // and `locationid` number a client's own sites — so matching them against a // hub id is a category error that lands orders in cities they never touched. // A delivery row carries several at once (queries.js sets servicinghubid, // applocationid and tenantlocationid together): DM-626241 is servicinghubid // 5, Bangalore Earth Hub, alongside tenantlocationid 3, which read as a hub // id is Hyderabad Mars Hub. // // The old `??` chain hid it by stopping at the first non-null field, so the // collision only fired when the earlier fields happened to be missing. // // A tenant session is the mirror image and keeps its own assertions below: // there the zone IS a tenantlocationid, and hub ids are the wrong space. it.each(['applocationid', 'tenantlocationid', 'locationid'])( 'should NOT match a staff hub zone on a %s — different id space', (field) => { const { result } = selectKoramangala(); expect(result.current.matchesZone({ bookingid: 1, [field]: '2' })).toBe(false); } ); it('should match a numeric hub id against the string zone id', () => { const { result } = selectKoramangala(); expect(result.current.matchesZone({ hubid: 2 })).toBe(true); }); it('should match on the hub pincode appearing in an address', () => { const { result } = selectKoramangala(); expect(result.current.matchesZone({ deliveryaddress: '4th Block, Bengaluru 560034' })).toBe(true); }); it('should match on the hub city appearing in an address, case-insensitively', () => { const { result } = selectKoramangala(); expect(result.current.matchesZone({ pickupaddress: '12 MG Road, BENGALURU' })).toBe(true); }); it('should match on the hub name appearing in an address', () => { const { result } = selectKoramangala(); expect(result.current.matchesZone({ address: 'Near koramangala hub' })).toBe(true); }); it('should match a pickup coordinate inside the 35 km radius', () => { const { result } = selectKoramangala(); expect(result.current.matchesZone({ pickuplatitude: 12.94, pickuplongitude: 77.62 })).toBe(true); }); it('should match a drop coordinate inside the radius even when pickup is far', () => { const { result } = selectKoramangala(); expect( result.current.matchesZone({ pickuplatitude: 19.076, pickuplongitude: 72.877, deliverylatitude: 12.94, deliverylongitude: 77.62 }) ).toBe(true); }); it('should reject a row in another city with no matching id or coordinate', () => { const { result } = selectKoramangala(); expect( result.current.matchesZone({ hubid: '7', deliveryaddress: 'Bandra West, Mumbai 400050', deliverylatitude: 19.076, deliverylongitude: 72.877 }) ).toBe(false); }); it('should reject a coordinate well outside the radius', () => { const { result } = selectKoramangala(); expect(result.current.matchesZone({ latitude: 19.076, longitude: 72.877 })).toBe(false); }); it('should reject a row with nothing to match on', () => { const { result } = selectKoramangala(); expect(result.current.matchesZone({ bookingid: 4021 })).toBe(false); }); it('should ignore an unusable coordinate rather than treating it as origin', () => { // (0,0) is in the Atlantic; a NaN or null coordinate must not silently // become a position that is 35 km from nothing. const { result } = selectKoramangala(); expect(result.current.matchesZone({ pickuplatitude: null, pickuplongitude: null })).toBe(false); expect(result.current.matchesZone({ pickuplatitude: 'abc', pickuplongitude: 'abc' })).toBe(false); }); it('should correctly match a delivery row that carries an assigned rider (milerprofileid) by address/city', () => { const { result } = selectKoramangala(); // Even though milerprofileid and userid are present, this is a delivery row (has bookingid/pickupaddress) // and should match Koramangala Hub's city (Bengaluru). const deliveryRow = { bookingid: 4021, bookingno: 'DM-BK-7959CF19-97503', pickupaddress: '12 MG Road, Bengaluru 560001', deliveryaddress: '44 Indiranagar, Bengaluru 560038', milerprofileid: 11, userid: 91, ridername: 'Vasanth' }; expect(result.current.matchesZone(deliveryRow)).toBe(true); }); }); describe('matchesZone — riders', () => { it('should match a rider assigned to the selected hub', () => { localStorage.setItem(ZONE_STORAGE_KEY, '2'); const { result } = setup(); expect(result.current.matchesZone({ milerprofileid: 11, hubid: '2' })).toBe(true); }); it('should match a rider by their app location', () => { localStorage.setItem(ZONE_STORAGE_KEY, '2'); const { result } = setup(); expect(result.current.matchesZone({ milerprofileid: 11, applocationid: '2' })).toBe(true); }); it('should match a rider by city when they carry no hub id', () => { localStorage.setItem(ZONE_STORAGE_KEY, '2'); const { result } = setup(); expect(result.current.matchesZone({ milerprofileid: 11, city: 'bengaluru' })).toBe(true); }); it('should reject a rider from another city', () => { localStorage.setItem(ZONE_STORAGE_KEY, '2'); const { result } = setup(); expect(result.current.matchesZone({ milerprofileid: 11, city: 'Mumbai' })).toBe(false); }); it('should show every fleet rider to a tenant, whichever kitchen is selected', () => { // Riders belong to the tenant fleet and serve all its kitchen hubs. localStorage.setItem(ZONE_STORAGE_KEY, '55'); const { result } = setup({ user: { tenantid: '4' }, locations: [{ tenantlocationid: 55, city: 'Bengaluru' }] }); expect(result.current.matchesZone({ milerprofileid: 11, tenantid: '4', city: 'Mysuru' })).toBe(true); }); it('should still reject a rider belonging to another tenant', () => { localStorage.setItem(ZONE_STORAGE_KEY, '55'); const { result } = setup({ user: { tenantid: '4' }, locations: [{ tenantlocationid: 55, city: 'Bengaluru' }] }); expect(result.current.matchesZone({ milerprofileid: 11, tenantid: '9' })).toBe(false); }); it.each([ ['milerprofileid', { milerprofileid: 11 }], ['authname', { authname: 'anita' }], ['userid + availabilitystatus', { userid: 91, availabilitystatus: 'available' }] ])('should recognise a rider row by its %s', (_label, row) => { localStorage.setItem(ZONE_STORAGE_KEY, '2'); const { result } = setup(); // A rider row with no location signal is rejected as a rider, not // accidentally address-matched as an order. expect(result.current.matchesZone(row)).toBe(false); }); }); describe('loading state and refetch', () => { it('should surface the hub loading state for a staff login', () => { useAuth.mockReturnValue({ user: { tenantid: '0' } }); useHubs.mockReturnValue({ data: [], isLoading: true, refetch: jest.fn() }); useTenantLocations.mockReturnValue({ data: [], isLoading: false, refetch: jest.fn() }); const { result } = renderHook(() => useZone(), { wrapper }); expect(result.current.isHubsLoading).toBe(true); }); it('should surface the location loading state for a tenant login', () => { useAuth.mockReturnValue({ user: { tenantid: '4' } }); useHubs.mockReturnValue({ data: [], isLoading: false, refetch: jest.fn() }); useTenantLocations.mockReturnValue({ data: [], isLoading: true, refetch: jest.fn() }); const { result } = renderHook(() => useZone(), { wrapper }); expect(result.current.isHubsLoading).toBe(true); }); it('should refetch the source the current login actually reads', () => { const refetchHubs = jest.fn(); const refetchLocations = jest.fn(); useAuth.mockReturnValue({ user: { tenantid: '4' } }); useHubs.mockReturnValue({ data: [], isLoading: false, refetch: refetchHubs }); useTenantLocations.mockReturnValue({ data: [], isLoading: false, refetch: refetchLocations }); const { result } = renderHook(() => useZone(), { wrapper }); act(() => result.current.refetchHubs()); expect(refetchLocations).toHaveBeenCalledTimes(1); expect(refetchHubs).not.toHaveBeenCalled(); }); }); describe('useZone outside a provider', () => { it('should throw a named error rather than returning undefined', () => { const spy = jest.spyOn(console, 'error').mockImplementation(() => {}); expect(() => renderHook(() => useZone())).toThrow('useZone must be used within a ZoneProvider'); spy.mockRestore(); }); }); });