Files
lonc/tests/api/stock.test.js

457 lines
13 KiB
JavaScript

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,
markStockGone,
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: false, searchName: 'Rice', limit: 10, offset: 0 },
);
expect(apiRequestMock).toHaveBeenCalledWith(
{ config: { database: 'db' } },
'kitchen/items/grouped',
{
query: {
expanded: false,
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: true, searchName: 'Rice' },
);
expect(response).toHaveLength(101);
expect(apiRequestMock).toHaveBeenNthCalledWith(
1,
{ config: { database: 'db' } },
'kitchen/items/grouped',
{ query: { expanded: true, search_name: 'Rice', limit: 100, offset: 0 } },
);
expect(apiRequestMock).toHaveBeenNthCalledWith(
2,
{ config: { database: 'db' } },
'kitchen/items/grouped',
{ query: { expanded: true, 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: true } },
);
});
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: true } },
);
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);
});
it('markStockGone uses /use endpoint for consumed reason', async () => {
apiRequestMock.mockResolvedValueOnce(null);
const result = await markStockGone({ config: { database: 'db' } }, 'item-1', 'consumed');
expect(result).toEqual({ status: 'gone', reason: 'consumed' });
expect(apiRequestMock).toHaveBeenCalledWith(
{ config: { database: 'db' } },
'kitchen/items/item-1/use',
{ method: 'POST' },
);
});
it('markStockGone uses /stock endpoint for non-consumed reasons', async () => {
apiRequestMock.mockResolvedValueOnce({ status: 'OK', stock: { id: 3 } });
const result = await markStockGone({ config: { database: 'db' } }, 'item-1', 'spoiled');
expect(result).toEqual({ status: 'gone', reason: 'spoiled' });
expect(apiRequestMock).toHaveBeenCalledWith(
{ config: { database: 'db' } },
'kitchen/items/item-1/stock',
{ method: 'POST', body: { level: 'gone', gone_reason: 'spoiled' } },
);
});
});