Align stock API with paginated backend and bump to v0.2.2
ci/woodpecker/push/woodpecker Pipeline was successful

This commit is contained in:
2026-04-12 17:57:53 +02:00
parent ae8ad07d87
commit 39dd474813
9 changed files with 277 additions and 83 deletions
+111 -18
View File
@@ -14,14 +14,16 @@ vi.mock('../../src/api/client.js', () => ({
}));
const {
adjustStockEntry,
applyItemUpsert,
listGroupedStockEntries,
listKitchenChanges,
listStockEntries,
lookupItemByIdentifier,
lookupItemDetails,
listKitchenChanges,
patchStockItem,
previewItemUpsert,
updateStockItem,
useStockItem,
} = await import('../../src/api/stock.js');
@@ -30,12 +32,12 @@ describe('api/stock', () => {
apiRequestMock.mockReset();
});
it('listStockEntries forwards optional query filters', async () => {
it('listStockEntries forwards explicit pagination query filters', async () => {
apiRequestMock.mockResolvedValueOnce([]);
await listStockEntries(
{ config: { database: 'db' } },
{ searchName: 'Milk', limit: 20, offset: 40, cursor: 'cursor-1' },
{ searchName: 'Milk', limit: 20, offset: 40 },
);
expect(apiRequestMock).toHaveBeenCalledWith(
@@ -46,18 +48,42 @@ describe('api/stock', () => {
search_name: 'Milk',
limit: 20,
offset: 40,
cursor: 'cursor-1',
},
},
);
});
it('listGroupedStockEntries defaults to expanded=1 and forwards options', async () => {
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, cursor: 'cursor-2' },
{ expanded: 0, searchName: 'Rice', limit: 10, offset: 0 },
);
expect(apiRequestMock).toHaveBeenCalledWith(
@@ -69,12 +95,36 @@ describe('api/stock', () => {
search_name: 'Rice',
limit: 10,
offset: 0,
cursor: 'cursor-2',
},
},
);
});
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('listKitchenChanges returns normalized changes payload', async () => {
apiRequestMock.mockResolvedValueOnce({
since: 'cursor-1',
@@ -257,6 +307,56 @@ describe('api/stock', () => {
});
});
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);
@@ -278,20 +378,13 @@ describe('api/stock', () => {
expect(result).toEqual({ status: 'already_gone' });
});
it('useStockItem falls back to delete on 404/405', async () => {
apiRequestMock
.mockRejectedValueOnce({ status: 404 })
.mockResolvedValueOnce(null);
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: 'fallback_delete' });
expect(apiRequestMock).toHaveBeenNthCalledWith(
2,
{ config: { database: 'db' } },
'kitchen/items/item-1',
{ method: 'DELETE' },
);
expect(result).toEqual({ status: 'already_gone' });
expect(apiRequestMock).toHaveBeenCalledTimes(1);
});
it('useStockItem does not fallback on unrelated client errors', async () => {