2026-04-10 15:43:39 +02:00
|
|
|
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 {
|
|
|
|
|
applyItemUpsert,
|
2026-04-12 13:05:14 +02:00
|
|
|
listGroupedStockEntries,
|
|
|
|
|
listStockEntries,
|
2026-04-11 10:14:49 +02:00
|
|
|
lookupItemByIdentifier,
|
|
|
|
|
lookupItemDetails,
|
2026-04-10 15:43:39 +02:00
|
|
|
listKitchenChanges,
|
2026-04-11 10:14:49 +02:00
|
|
|
patchStockItem,
|
2026-04-10 15:43:39 +02:00
|
|
|
previewItemUpsert,
|
|
|
|
|
useStockItem,
|
|
|
|
|
} = await import('../../src/api/stock.js');
|
|
|
|
|
|
|
|
|
|
describe('api/stock', () => {
|
|
|
|
|
beforeEach(() => {
|
|
|
|
|
apiRequestMock.mockReset();
|
|
|
|
|
});
|
|
|
|
|
|
2026-04-12 13:05:14 +02:00
|
|
|
it('listStockEntries forwards optional query filters', async () => {
|
|
|
|
|
apiRequestMock.mockResolvedValueOnce([]);
|
|
|
|
|
|
|
|
|
|
await listStockEntries(
|
|
|
|
|
{ config: { database: 'db' } },
|
|
|
|
|
{ searchName: 'Milk', limit: 20, offset: 40, cursor: 'cursor-1' },
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
expect(apiRequestMock).toHaveBeenCalledWith(
|
|
|
|
|
{ config: { database: 'db' } },
|
|
|
|
|
'kitchen/items',
|
|
|
|
|
{
|
|
|
|
|
query: {
|
|
|
|
|
search_name: 'Milk',
|
|
|
|
|
limit: 20,
|
|
|
|
|
offset: 40,
|
|
|
|
|
cursor: 'cursor-1',
|
|
|
|
|
},
|
|
|
|
|
},
|
|
|
|
|
);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it('listGroupedStockEntries defaults to expanded=1 and forwards options', async () => {
|
|
|
|
|
apiRequestMock.mockResolvedValueOnce([]);
|
|
|
|
|
|
|
|
|
|
await listGroupedStockEntries(
|
|
|
|
|
{ config: { database: 'db' } },
|
|
|
|
|
{ expanded: 0, searchName: 'Rice', limit: 10, offset: 0, cursor: 'cursor-2' },
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
expect(apiRequestMock).toHaveBeenCalledWith(
|
|
|
|
|
{ config: { database: 'db' } },
|
|
|
|
|
'kitchen/items/grouped',
|
|
|
|
|
{
|
|
|
|
|
query: {
|
|
|
|
|
expanded: 0,
|
|
|
|
|
search_name: 'Rice',
|
|
|
|
|
limit: 10,
|
|
|
|
|
offset: 0,
|
|
|
|
|
cursor: 'cursor-2',
|
|
|
|
|
},
|
|
|
|
|
},
|
|
|
|
|
);
|
|
|
|
|
});
|
|
|
|
|
|
2026-04-10 15:43:39 +02:00
|
|
|
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,
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
|
2026-04-11 10:14:49 +02:00
|
|
|
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',
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
|
2026-04-10 15:43:39 +02:00
|
|
|
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 falls back to delete on 404/405', async () => {
|
|
|
|
|
apiRequestMock
|
|
|
|
|
.mockRejectedValueOnce({ status: 404 })
|
|
|
|
|
.mockResolvedValueOnce(null);
|
|
|
|
|
|
|
|
|
|
const result = await useStockItem({ config: { database: 'db' } }, 'item-1');
|
|
|
|
|
|
|
|
|
|
expect(result).toEqual({ status: 'fallback_delete' });
|
|
|
|
|
expect(apiRequestMock).toHaveBeenNthCalledWith(
|
|
|
|
|
2,
|
|
|
|
|
{ config: { database: 'db' } },
|
|
|
|
|
'kitchen/items/item-1',
|
|
|
|
|
{ method: 'DELETE' },
|
|
|
|
|
);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
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);
|
|
|
|
|
});
|
|
|
|
|
});
|