Implement upsert label flow and use-based mark gone handling
ci/woodpecker/push/woodpecker Pipeline was successful

This commit is contained in:
2026-04-10 15:43:39 +02:00
parent caa6ca6ce1
commit 1dc1bb4912
24 changed files with 948 additions and 76 deletions
+4 -6
View File
@@ -48,17 +48,15 @@ describe('api/client', () => {
it('returns configured path constants', () => {
expect(getPath('items')).toBe('kitchen/items');
expect(getPath('userApplication')).toBe('user/application/');
expect(getPath('changes')).toBe('kitchen/changes');
});
it('builds kitchen urls with encoded path segments and query values', () => {
it('builds database-scoped kitchen urls with encoded query values', () => {
const store = createStore({
config: {
baseUrl: 'https://api.example.com',
database: 'my db',
},
activeKitchen: {
id: 'kitchen/01',
},
});
const url = buildKitchenApiUrl(store, 'kitchen/items/grouped', {
@@ -68,7 +66,7 @@ describe('api/client', () => {
});
expect(url).toBe(
'https://api.example.com/my%20db/kitchen/kitchen%2F01/kitchen/items/grouped?search_name=Milk+%2B+eggs&expanded=1',
'https://api.example.com/my%20db/kitchen/items/grouped?search_name=Milk+%2B+eggs&expanded=1',
);
});
@@ -97,7 +95,7 @@ describe('api/client', () => {
expect(payload).toEqual({ ok: true });
const [url, request] = fetchSpy.mock.calls[0];
expect(url).toBe('/kitchen-db/kitchen/kitchen-1/kitchen/items?label=1');
expect(url).toBe('/kitchen-db/kitchen/items?label=1');
expect(request.method).toBe('POST');
expect(request.body).toBe('{"name":"Rice"}');
expect(request.headers.get('Accept')).toBe('application/json');
+161
View File
@@ -0,0 +1,161 @@
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,
listKitchenChanges,
previewItemUpsert,
useStockItem,
} = await import('../../src/api/stock.js');
describe('api/stock', () => {
beforeEach(() => {
apiRequestMock.mockReset();
});
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('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);
});
});
@@ -0,0 +1,143 @@
import { describe, expect, it, vi } from 'vitest';
const listKitchenChangesMock = vi.fn();
const getStockEntryMock = vi.fn();
const fetchLocationsMock = vi.fn();
vi.mock('../../../src/api/stock.js', () => ({
listKitchenChanges: (...args) => listKitchenChangesMock(...args),
getStockEntry: (...args) => getStockEntryMock(...args),
}));
vi.mock('../../../src/api/locations.js', () => ({
fetchLocations: (...args) => fetchLocationsMock(...args),
}));
const { dashboardPageData, renderDashboardPage } = await import('../../../src/features/dashboard/dashboard-page.js');
describe('features/dashboard/dashboard-page', () => {
it('renders dashboard with recent changes section', () => {
const html = renderDashboardPage();
expect(html).toContain('Recent changes');
expect(html).toContain('x-data="dashboardPage()"');
expect(html).toContain('Saved means the backend created or updated a record.');
});
it('loads recent changes on init and renders item-focused state lines', async () => {
listKitchenChangesMock.mockResolvedValueOnce({
since: null,
nextCursor: null,
changes: [{
type: 'item',
action: 'upsert',
timestamp: '2026-04-10T10:00:00Z',
item: {
uuid_b64: 'u1',
name: 'Rice',
stock_type: 'measured',
quantity: 3,
uom_symbol: 'kg',
level: 'good',
expire_date: '2026-04-21',
location_initial_uuid_b64: 'loc1',
},
}],
});
fetchLocationsMock.mockResolvedValueOnce({
flat: [{ uuid_b64: 'loc1', pathLabel: 'Pantry / Shelf A' }],
});
const store = {
isConnected: true,
setActiveKitchen: vi.fn(),
addAlert: vi.fn(),
};
const data = dashboardPageData(store);
await data.init();
expect(listKitchenChangesMock).toHaveBeenCalledWith(store, { limit: 10 });
expect(data.recentChanges).toHaveLength(1);
expect(data.changesState.error).toBe('');
expect(data.changeHeadline(data.recentChanges[0])).toBe('Item saved: Rice');
expect(data.changeStateLine(data.recentChanges[0])).toContain('Quantity: 3 kg');
expect(data.changeStateLine(data.recentChanges[0])).toContain('Location: Pantry / Shelf A');
expect(getStockEntryMock).not.toHaveBeenCalled();
});
it('resolves stock event item context via item lookup when needed', async () => {
listKitchenChangesMock.mockResolvedValueOnce({
since: null,
nextCursor: null,
changes: [{
type: 'stock',
action: 'upsert',
timestamp: '2026-04-10T10:00:00Z',
stock: {
item_uuid_b64: 'item-uuid-1',
quantity: 0.5,
uom_symbol: 'kg',
level: 'some',
location_uuid_b64: 'loc2',
},
}],
});
getStockEntryMock.mockResolvedValueOnce({
uuid_b64: 'item-uuid-1',
name: 'Flour',
stock_type: 'measured',
expire_date: '2026-05-02',
});
fetchLocationsMock.mockResolvedValueOnce({
flat: [{ uuid_b64: 'loc2', pathLabel: 'Pantry / Bin 2' }],
});
const data = dashboardPageData({
isConnected: true,
setActiveKitchen: vi.fn(),
addAlert: vi.fn(),
});
await data.refreshChanges();
expect(data.changeHeadline(data.recentChanges[0])).toBe('Stock saved: Flour');
expect(data.changeStateLine(data.recentChanges[0])).toContain('Quantity: 0.5 kg');
expect(data.changeStateLine(data.recentChanges[0])).toContain('Level: Some');
expect(data.changeStateLine(data.recentChanges[0])).toContain('Location: Pantry / Bin 2');
expect(getStockEntryMock).toHaveBeenCalledWith(expect.anything(), 'item-uuid-1');
});
it('keeps empty state when API returns no changes', async () => {
listKitchenChangesMock.mockResolvedValueOnce({
since: null,
nextCursor: null,
changes: [],
});
fetchLocationsMock.mockResolvedValueOnce({ flat: [] });
const data = dashboardPageData({
isConnected: true,
setActiveKitchen: vi.fn(),
addAlert: vi.fn(),
});
await data.refreshChanges();
expect(data.recentChanges).toEqual([]);
expect(data.changesState.error).toBe('');
});
it('captures refresh errors in async state', async () => {
listKitchenChangesMock.mockRejectedValueOnce(new Error('Feed unavailable'));
const data = dashboardPageData({
isConnected: true,
setActiveKitchen: vi.fn(),
addAlert: vi.fn(),
});
await data.refreshChanges();
expect(data.changesState.error).toBe('Feed unavailable');
expect(data.recentChanges).toEqual([]);
});
});
@@ -0,0 +1,86 @@
import { describe, expect, it, vi } from 'vitest';
const applyItemUpsertMock = vi.fn();
const previewItemUpsertMock = vi.fn();
vi.mock('../../../src/api/stock.js', () => ({
applyItemUpsert: (...args) => applyItemUpsertMock(...args),
previewItemUpsert: (...args) => previewItemUpsertMock(...args),
searchItemDefinitions: vi.fn(async () => []),
}));
vi.mock('../../../src/api/labels.js', () => ({
previewLabel: vi.fn(async () => ({ objectUrl: 'blob:preview' })),
}));
vi.mock('../../../src/api/locations.js', () => ({
fetchLocations: vi.fn(async () => ({ flat: [], tree: [] })),
}));
const { labelCreatePageData } = await import('../../../src/features/labels/label-create-page.js');
describe('label create upsert-first submit', () => {
it('builds upsert payload with selected template uuid', () => {
const store = {
isConnected: false,
activeKitchen: { id: 7 },
addAlert: vi.fn(),
};
const data = labelCreatePageData(store);
data.form = {
...data.form,
itemUuidB64: 'uuid-template-1',
name: 'Beans',
description: 'Dry beans',
stockType: 'measured',
quantity: '2',
uom: 'kg',
level: '',
productionDate: '2026-04-10',
expirationDate: '2026-08-10',
locationId: '',
identifierCode: '12345',
};
const payload = data.buildUpsertPayload();
expect(payload.uuid_b64).toBe('uuid-template-1');
expect(payload.identifier_code).toBe('12345');
expect(payload.item.name).toBe('Beans');
expect(payload.item.quantity_initial).toBe(2);
});
it('create uses applyItemUpsert and sets operation-aware success message', async () => {
applyItemUpsertMock.mockResolvedValueOnce({
operation: 'update',
item: { name: 'Rice' },
});
const addAlert = vi.fn();
const store = {
isConnected: false,
activeKitchen: { id: 3 },
addAlert,
};
const data = labelCreatePageData(store);
data.validateBeforeSubmit = () => true;
data.form = {
...data.form,
name: 'Rice',
stockType: 'binary',
locationId: '',
productionDate: '2026-04-10',
itemUuidB64: 'uuid-rice-1',
};
await data.create();
expect(applyItemUpsertMock).toHaveBeenCalledTimes(1);
expect(applyItemUpsertMock.mock.calls[0][1].uuid_b64).toBe('uuid-rice-1');
expect(data.successMessage).toBe('Rice was updated successfully.');
expect(addAlert).toHaveBeenCalledWith({
type: 'success',
message: 'Rice was updated successfully.',
});
});
});
+71
View File
@@ -0,0 +1,71 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
const useStockItemMock = vi.fn();
const getStockEntryMock = vi.fn();
vi.mock('../../../src/api/stock.js', () => ({
useStockItem: (...args) => useStockItemMock(...args),
getStockEntry: (...args) => getStockEntryMock(...args),
adjustStockEntry: vi.fn(),
listStockEntries: vi.fn(),
listGroupedStockEntries: vi.fn(),
updateStockItem: vi.fn(),
}));
vi.mock('../../../src/api/locations.js', () => ({
fetchLocations: vi.fn(async () => ({ flat: [], tree: [] })),
}));
const { stockDetailPageData } = await import('../../../src/features/stock/stock-detail-page.js');
const { stockListPageData } = await import('../../../src/features/stock/stock-list-page.js');
describe('stock mark-gone behavior', () => {
beforeEach(() => {
useStockItemMock.mockReset();
getStockEntryMock.mockReset();
globalThis.window = {
__loncApp: {
navigate: vi.fn(),
},
};
});
afterEach(() => {
vi.restoreAllMocks();
delete globalThis.window;
});
it('stock detail markGone uses /use and shows info for already gone', async () => {
useStockItemMock.mockResolvedValueOnce({ status: 'already_gone' });
const addAlert = vi.fn();
const data = stockDetailPageData({ addAlert });
data.entry = { uuid_b64: 'item-1', name: 'Rice' };
await data.markGone();
expect(useStockItemMock).toHaveBeenCalledWith({ addAlert }, 'item-1');
expect(addAlert).toHaveBeenCalledWith({
type: 'info',
message: 'Rice was already out of stock.',
});
expect(globalThis.window.__loncApp.navigate).toHaveBeenCalledWith('/stock');
});
it('stock list markGone removes entry and uses /use path', async () => {
useStockItemMock.mockResolvedValueOnce({ status: 'used' });
const addAlert = vi.fn();
const data = stockListPageData({ addAlert, isConnected: false });
data.entries = [{ id: 1, uuid_b64: 'item-1', name: 'Flour' }];
data.editForms = { 1: { level: 'plenty', quantity: 1 } };
data.editErrors = {};
await data.markGone(data.entries[0]);
expect(useStockItemMock).toHaveBeenCalledWith({ addAlert, isConnected: false }, 'item-1');
expect(data.entries).toEqual([]);
expect(addAlert).toHaveBeenCalledWith({
type: 'success',
message: 'Flour was marked gone and removed from the list.',
});
});
});