457 lines
13 KiB
JavaScript
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' } },
|
|
);
|
|
});
|
|
});
|