import { beforeEach, describe, expect, it, vi } from 'vitest'; const apiRequestMock = vi.fn(); vi.mock('../../src/api/client.js', () => ({ getPath(key) { const paths = { items: 'kitchen/items', changes: 'kitchen/changes', }; return paths[key]; }, apiRequest: (...args) => apiRequestMock(...args), })); const { adjustStockEntry, applyItemUpsert, getStockEntry, listGroupedStockEntries, listKitchenChanges, listStockEntries, lookupItemByIdentifier, lookupItemDetails, patchStockItem, previewItemUpsert, updateStockItem, useStockItem, } = await import('../../src/api/stock.js'); describe('api/stock', () => { beforeEach(() => { apiRequestMock.mockReset(); }); it('listStockEntries forwards explicit pagination query filters', async () => { apiRequestMock.mockResolvedValueOnce([]); await listStockEntries( { config: { database: 'db' } }, { searchName: 'Milk', limit: 20, offset: 40 }, ); expect(apiRequestMock).toHaveBeenCalledWith( { config: { database: 'db' } }, 'kitchen/items', { query: { search_name: 'Milk', limit: 20, offset: 40, }, }, ); }); it('listStockEntries aggregates all pages by default', async () => { apiRequestMock .mockResolvedValueOnce(Array.from({ length: 100 }, (_, id) => ({ id: id + 1 }))) .mockResolvedValueOnce([{ id: 101 }]); const response = await listStockEntries( { config: { database: 'db' } }, { searchName: 'Milk' }, ); expect(response).toHaveLength(101); expect(apiRequestMock).toHaveBeenNthCalledWith( 1, { config: { database: 'db' } }, 'kitchen/items', { query: { search_name: 'Milk', limit: 100, offset: 0 } }, ); expect(apiRequestMock).toHaveBeenNthCalledWith( 2, { config: { database: 'db' } }, 'kitchen/items', { query: { search_name: 'Milk', limit: 100, offset: 100 } }, ); }); it('listGroupedStockEntries forwards explicit pagination options', async () => { apiRequestMock.mockResolvedValueOnce([]); await listGroupedStockEntries( { config: { database: 'db' } }, { expanded: 0, searchName: 'Rice', limit: 10, offset: 0 }, ); expect(apiRequestMock).toHaveBeenCalledWith( { config: { database: 'db' } }, 'kitchen/items/grouped', { query: { expanded: 0, search_name: 'Rice', limit: 10, offset: 0, }, }, ); }); it('listGroupedStockEntries aggregates all pages by default', async () => { apiRequestMock .mockResolvedValueOnce(Array.from({ length: 100 }, (_, id) => ({ id: id + 1 }))) .mockResolvedValueOnce([{ id: 101 }]); const response = await listGroupedStockEntries( { config: { database: 'db' } }, { expanded: 1, searchName: 'Rice' }, ); expect(response).toHaveLength(101); expect(apiRequestMock).toHaveBeenNthCalledWith( 1, { config: { database: 'db' } }, 'kitchen/items/grouped', { query: { expanded: 1, search_name: 'Rice', limit: 100, offset: 0 } }, ); expect(apiRequestMock).toHaveBeenNthCalledWith( 2, { config: { database: 'db' } }, 'kitchen/items/grouped', { query: { expanded: 1, search_name: 'Rice', limit: 100, offset: 100 } }, ); }); it('getStockEntry fetches item without allow_inactive by default', async () => { apiRequestMock.mockResolvedValueOnce({ uuid_b64: 'item-1', name: 'Milk' }); const result = await getStockEntry({ config: { database: 'db' } }, 'item-1'); expect(result).toEqual({ uuid_b64: 'item-1', name: 'Milk' }); expect(apiRequestMock).toHaveBeenCalledWith( { config: { database: 'db' } }, 'kitchen/items/item-1', ); }); it('getStockEntry forwards allow_inactive when requested', async () => { apiRequestMock.mockResolvedValueOnce({ uuid_b64: 'item-2', active: false }); const result = await getStockEntry( { config: { database: 'db' } }, 'item-2', { allowInactive: true }, ); expect(result).toEqual({ uuid_b64: 'item-2', active: false }); expect(apiRequestMock).toHaveBeenCalledWith( { config: { database: 'db' } }, 'kitchen/items/item-2', { query: { allow_inactive: 1 } }, ); }); it('listKitchenChanges returns normalized changes payload', async () => { apiRequestMock.mockResolvedValueOnce({ since: 'cursor-1', next_cursor: 'cursor-2', changes: [{ type: 'item', action: 'upsert', timestamp: '2026-04-10T10:00:00Z' }], }); const result = await listKitchenChanges({ config: { database: 'db' } }, { limit: 10 }); expect(result).toEqual({ since: 'cursor-1', nextCursor: 'cursor-2', changes: [{ type: 'item', action: 'upsert', timestamp: '2026-04-10T10:00:00Z' }], }); expect(apiRequestMock).toHaveBeenCalledWith( { config: { database: 'db' } }, 'kitchen/changes', { query: { since: undefined, limit: 10 } }, ); }); it('listKitchenChanges falls back to empty shape when changes are missing', async () => { apiRequestMock.mockResolvedValueOnce({}); const result = await listKitchenChanges({ config: { database: 'db' } }, {}); expect(result).toEqual({ since: null, nextCursor: null, changes: [], }); }); it('previewItemUpsert normalizes preview response', async () => { apiRequestMock.mockResolvedValueOnce({ status: 'ok', mode: 'preview', operation: 'update', match_type: 'uuid_b64', matched_item: { uuid_b64: 'abc', name: 'Rice' }, payload: { name: 'Rice' }, }); const response = await previewItemUpsert({ config: { database: 'db' } }, { item: { name: 'Rice' } }); expect(response).toEqual({ status: 'ok', mode: 'preview', operation: 'update', matchType: 'uuid_b64', matchedItem: { uuid_b64: 'abc', name: 'Rice' }, item: null, payload: { name: 'Rice' }, }); expect(apiRequestMock).toHaveBeenCalledWith( { config: { database: 'db' } }, 'kitchen/items/upsert', { method: 'POST', body: { item: { name: 'Rice' } }, query: { mode: 'preview' }, }, ); }); it('applyItemUpsert normalizes apply response', async () => { apiRequestMock.mockResolvedValueOnce({ status: 'ok', mode: 'apply', operation: 'create', match_type: null, item: { uuid_b64: 'new1', name: 'Beans' }, }); const response = await applyItemUpsert({ config: { database: 'db' } }, { item: { name: 'Beans' } }); expect(response).toEqual({ status: 'ok', mode: 'apply', operation: 'create', matchType: null, matchedItem: null, item: { uuid_b64: 'new1', name: 'Beans' }, payload: null, }); }); it('lookupItemByIdentifier normalizes lookup metadata fields', async () => { apiRequestMock.mockResolvedValueOnce({ status: 'rate_limited', source: 'openfoodfacts', cache_hit: true, identifier_code: '1234', identifier_type: 'ean_13', retry_after_seconds: 42, payload_fetched_at: '2026-04-11T08:00:00Z', stale_cache: true, item: null, }); const response = await lookupItemByIdentifier( { config: { database: 'db' } }, '1234', ); expect(response).toEqual({ status: 'rate_limited', source: 'openfoodfacts', cacheHit: true, identifierCode: '1234', identifierType: 'ean_13', retryAfterSeconds: 42, payloadFetchedAt: '2026-04-11T08:00:00Z', staleCache: true, item: null, }); }); it('lookupItemDetails maps item lookup response and query flag', async () => { apiRequestMock.mockResolvedValueOnce({ status: 'ok', found: true, update: true, identifier_code: '555', identifier_type: 'ean_13', preview: { name: 'Milk' }, updated_fields: ['name'], off_payload_fetched_at: '2026-04-11T09:00:00Z', retry_after_seconds: null, stale_cache: false, item: { uuid_b64: 'item-1', name: 'Milk' }, }); const response = await lookupItemDetails( { config: { database: 'db' } }, 'item-1', { update: true }, ); expect(apiRequestMock).toHaveBeenCalledWith( { config: { database: 'db' } }, 'kitchen/items/item-1/lookup', { method: 'POST', query: { update: 1 } }, ); expect(response).toEqual({ status: 'ok', found: true, update: true, identifierCode: '555', identifierType: 'ean_13', preview: { name: 'Milk' }, updatedFields: ['name'], offPayloadFetchedAt: '2026-04-11T09:00:00Z', retryAfterSeconds: null, staleCache: false, item: { uuid_b64: 'item-1', name: 'Milk' }, }); }); it('patchStockItem sends PATCH to item endpoint', async () => { apiRequestMock.mockResolvedValueOnce({ uuid_b64: 'item-1', identifier_code: '3830012345678', }); const response = await patchStockItem( { config: { database: 'db' } }, 'item-1', { identifier_code: '3830012345678' }, ); expect(apiRequestMock).toHaveBeenCalledWith( { config: { database: 'db' } }, 'kitchen/items/item-1', { method: 'PATCH', body: { identifier_code: '3830012345678' } }, ); expect(response).toEqual({ uuid_b64: 'item-1', identifier_code: '3830012345678', }); }); it('updateStockItem posts stock event and re-fetches updated item', async () => { apiRequestMock .mockResolvedValueOnce({ status: 'OK', stock: { id: 1 } }) .mockResolvedValueOnce({ uuid_b64: 'item-1', quantity: 2 }); const response = await updateStockItem( { config: { database: 'db' } }, 'item-1', { quantity: 2 }, ); expect(apiRequestMock).toHaveBeenNthCalledWith( 1, { config: { database: 'db' } }, 'kitchen/items/item-1/stock', { method: 'POST', body: { quantity: 2 } }, ); expect(apiRequestMock).toHaveBeenNthCalledWith( 2, { config: { database: 'db' } }, 'kitchen/items/item-1', ); expect(response).toEqual({ uuid_b64: 'item-1', quantity: 2 }); }); it('adjustStockEntry posts stock event and re-fetches updated item', async () => { apiRequestMock .mockResolvedValueOnce({ status: 'OK', stock: { id: 2 } }) .mockResolvedValueOnce({ uuid_b64: 'item-1', level: 'good' }); const response = await adjustStockEntry( { config: { database: 'db' } }, 'item-1', { level: 'good' }, ); expect(apiRequestMock).toHaveBeenNthCalledWith( 1, { config: { database: 'db' } }, 'kitchen/items/item-1/stock', { method: 'POST', body: { level: 'good' } }, ); expect(apiRequestMock).toHaveBeenNthCalledWith( 2, { config: { database: 'db' } }, 'kitchen/items/item-1', ); expect(response).toEqual({ uuid_b64: 'item-1', level: 'good' }); }); it('useStockItem returns used on 204', async () => { apiRequestMock.mockResolvedValueOnce(null); const result = await useStockItem({ config: { database: 'db' } }, 'item-1'); expect(result).toEqual({ status: 'used' }); expect(apiRequestMock).toHaveBeenCalledWith( { config: { database: 'db' } }, 'kitchen/items/item-1/use', { method: 'POST' }, ); }); it('useStockItem returns already_gone on 409', async () => { apiRequestMock.mockRejectedValueOnce({ status: 409, message: 'Item is out of stock.' }); const result = await useStockItem({ config: { database: 'db' } }, 'item-1'); expect(result).toEqual({ status: 'already_gone' }); }); it('useStockItem returns already_gone on 404', async () => { apiRequestMock.mockRejectedValueOnce({ status: 404 }); const result = await useStockItem({ config: { database: 'db' } }, 'item-1'); expect(result).toEqual({ status: 'already_gone' }); expect(apiRequestMock).toHaveBeenCalledTimes(1); }); it('useStockItem does not fallback on unrelated client errors', async () => { apiRequestMock.mockRejectedValueOnce({ status: 422, message: 'validation_error' }); await expect(useStockItem({ config: { database: 'db' } }, 'item-1')).rejects.toMatchObject({ status: 422, message: 'validation_error', }); expect(apiRequestMock).toHaveBeenCalledTimes(1); }); });