Add inactive item fallback for dashboard change feed lookups
ci/woodpecker/push/woodpecker Pipeline was successful

This commit is contained in:
2026-04-12 22:46:28 +02:00
parent 79f4138b95
commit 6ca09cdf1f
4 changed files with 98 additions and 4 deletions
+5 -2
View File
@@ -116,8 +116,11 @@ export async function listGroupedStockEntries(store, options = {}) {
return fetchAllListPages(store, `${getPath('items')}/grouped`, baseQuery); return fetchAllListPages(store, `${getPath('items')}/grouped`, baseQuery);
} }
export async function getStockEntry(store, stockId) { export async function getStockEntry(store, stockId, { allowInactive = false } = {}) {
const payload = await apiRequest(store, `${getPath('items')}/${stockId}`); const path = `${getPath('items')}/${stockId}`;
const payload = allowInactive
? await apiRequest(store, path, { query: { allow_inactive: 1 } })
: await apiRequest(store, path);
return unwrapEntryPayload(payload); return unwrapEntryPayload(payload);
} }
+12 -1
View File
@@ -165,7 +165,18 @@ export function dashboardPageData(store) {
if (missingItemUuids.length) { if (missingItemUuids.length) {
const results = await Promise.allSettled( const results = await Promise.allSettled(
missingItemUuids.map((uuid) => getStockEntry(store, uuid)), missingItemUuids.map(async (uuid) => {
try {
return await getStockEntry(store, uuid);
} catch (error) {
const status = error?.status || error?.cause?.status;
if (status !== 404) {
throw error;
}
return getStockEntry(store, uuid, { allowInactive: true });
}
}),
); );
results.forEach((result) => { results.forEach((result) => {
+30
View File
@@ -16,6 +16,7 @@ vi.mock('../../src/api/client.js', () => ({
const { const {
adjustStockEntry, adjustStockEntry,
applyItemUpsert, applyItemUpsert,
getStockEntry,
listGroupedStockEntries, listGroupedStockEntries,
listKitchenChanges, listKitchenChanges,
listStockEntries, listStockEntries,
@@ -125,6 +126,35 @@ describe('api/stock', () => {
); );
}); });
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 () => { it('listKitchenChanges returns normalized changes payload', async () => {
apiRequestMock.mockResolvedValueOnce({ apiRequestMock.mockResolvedValueOnce({
since: 'cursor-1', since: 'cursor-1',
@@ -1,4 +1,4 @@
import { describe, expect, it, vi } from 'vitest'; import { beforeEach, describe, expect, it, vi } from 'vitest';
const listKitchenChangesMock = vi.fn(); const listKitchenChangesMock = vi.fn();
const getStockEntryMock = vi.fn(); const getStockEntryMock = vi.fn();
@@ -16,6 +16,12 @@ vi.mock('../../../src/api/locations.js', () => ({
const { dashboardPageData, renderDashboardPage } = await import('../../../src/features/dashboard/dashboard-page.js'); const { dashboardPageData, renderDashboardPage } = await import('../../../src/features/dashboard/dashboard-page.js');
describe('features/dashboard/dashboard-page', () => { describe('features/dashboard/dashboard-page', () => {
beforeEach(() => {
listKitchenChangesMock.mockReset();
getStockEntryMock.mockReset();
fetchLocationsMock.mockReset();
});
it('renders dashboard with recent changes section', () => { it('renders dashboard with recent changes section', () => {
const html = renderDashboardPage(); const html = renderDashboardPage();
expect(html).toContain('Recent changes'); expect(html).toContain('Recent changes');
@@ -107,6 +113,50 @@ describe('features/dashboard/dashboard-page', () => {
expect(getStockEntryMock).toHaveBeenCalledWith(expect.anything(), 'item-uuid-1'); expect(getStockEntryMock).toHaveBeenCalledWith(expect.anything(), 'item-uuid-1');
}); });
it('retries stock event item lookup with allowInactive after 404', async () => {
listKitchenChangesMock.mockResolvedValueOnce({
since: null,
nextCursor: null,
changes: [{
type: 'stock',
action: 'upsert',
timestamp: '2026-04-10T10:00:00Z',
stock: {
item_uuid_b64: 'item-uuid-2',
quantity: 1,
uom_symbol: 'pcs',
},
}],
});
getStockEntryMock
.mockRejectedValueOnce(Object.assign(new Error('Not found'), { status: 404 }))
.mockResolvedValueOnce({
uuid_b64: 'item-uuid-2',
name: 'Archived pasta',
stock_type: 'measured',
});
fetchLocationsMock.mockResolvedValueOnce({ flat: [] });
const store = {
isConnected: true,
setActiveKitchen: vi.fn(),
addAlert: vi.fn(),
};
const data = dashboardPageData(store);
await data.refreshChanges();
expect(getStockEntryMock).toHaveBeenNthCalledWith(1, store, 'item-uuid-2');
expect(getStockEntryMock).toHaveBeenNthCalledWith(
2,
store,
'item-uuid-2',
{ allowInactive: true },
);
expect(data.changeHeadline(data.recentChanges[0])).toBe('Stock saved: Archived pasta');
expect(data.changeStateLine(data.recentChanges[0])).toContain('Quantity: 1 pcs');
});
it('keeps empty state when API returns no changes', async () => { it('keeps empty state when API returns no changes', async () => {
listKitchenChangesMock.mockResolvedValueOnce({ listKitchenChangesMock.mockResolvedValueOnce({
since: null, since: null,