Upgrade OFF lookup UX and stock detail identifier editing
ci/woodpecker/push/woodpecker Pipeline was successful
ci/woodpecker/pr/woodpecker Pipeline was successful

This commit is contained in:
2026-04-11 10:14:49 +02:00
parent ea8a95b95d
commit 977c62818c
12 changed files with 645 additions and 8 deletions
+98
View File
@@ -15,7 +15,10 @@ vi.mock('../../src/api/client.js', () => ({
const {
applyItemUpsert,
lookupItemByIdentifier,
lookupItemDetails,
listKitchenChanges,
patchStockItem,
previewItemUpsert,
useStockItem,
} = await import('../../src/api/stock.js');
@@ -112,6 +115,101 @@ describe('api/stock', () => {
});
});
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',
});
});
it('useStockItem returns used on 204', async () => {
apiRequestMock.mockResolvedValueOnce(null);
@@ -0,0 +1,87 @@
import { describe, expect, it, vi } from 'vitest';
const lookupItemByIdentifierMock = vi.fn();
vi.mock('../../../src/api/stock.js', () => ({
applyItemUpsert: vi.fn(),
previewItemUpsert: vi.fn(),
searchItemDefinitions: vi.fn(async () => []),
lookupItemByIdentifier: (...args) => lookupItemByIdentifierMock(...args),
}));
vi.mock('../../../src/api/labels.js', () => ({
previewLabel: vi.fn(async () => ({ objectUrl: 'blob:preview' })),
printItemLabel: vi.fn(async () => null),
formatPrintErrorMessage: (error) => error?.message || 'Printing failed.',
}));
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 identifier lookup feedback', () => {
it('shows retry hint for rate-limited lookup responses', () => {
const data = labelCreatePageData({
isConnected: false,
activeKitchen: { id: 1 },
addAlert: vi.fn(),
});
const message = data.lookupStatusMessageWithDetails(
{ status: 'rate_limited', retryAfterSeconds: 30 },
'3830012345678',
);
expect(message).toContain('rate-limited');
expect(message).toContain('Retry in 30s');
});
it('builds metadata-aware success message with source/cache/freshness context', () => {
const data = labelCreatePageData({
isConnected: false,
activeKitchen: { id: 1 },
addAlert: vi.fn(),
});
const message = data.lookupSuccessMessage({
source: 'openfoodfacts',
cacheHit: true,
staleCache: true,
payloadFetchedAt: '2026-04-11T09:00:00Z',
});
expect(message).toContain('OpenFoodFacts');
expect(message).toContain('cache hit');
expect(message).toContain('stale cache');
expect(message).toContain('fetched:');
});
it('applies non-ok lookup status as warning message with details', async () => {
lookupItemByIdentifierMock.mockResolvedValueOnce({
status: 'rate_limited',
retryAfterSeconds: 45,
source: 'openfoodfacts',
cacheHit: false,
staleCache: false,
item: null,
});
const addAlert = vi.fn();
const data = labelCreatePageData({
isConnected: false,
activeKitchen: { id: 1 },
addAlert,
});
data.form.identifierCode = '3830012345678';
await data.lookupIdentifierDetails();
expect(data.lookupState.error).toContain('Retry in 45s');
expect(addAlert).toHaveBeenCalledWith({
type: 'warning',
message: data.lookupState.error,
});
});
});
+2
View File
@@ -7,6 +7,8 @@ vi.mock('../../../src/api/stock.js', () => ({
useStockItem: (...args) => useStockItemMock(...args),
getStockEntry: (...args) => getStockEntryMock(...args),
adjustStockEntry: vi.fn(),
lookupItemDetails: vi.fn(),
patchStockItem: vi.fn(),
listStockEntries: vi.fn(),
listGroupedStockEntries: vi.fn(),
updateStockItem: vi.fn(),
@@ -0,0 +1,127 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
const lookupItemDetailsMock = vi.fn();
const patchStockItemMock = vi.fn();
const getStockEntryMock = vi.fn();
vi.mock('../../../src/api/stock.js', () => ({
adjustStockEntry: vi.fn(),
getStockEntry: (...args) => getStockEntryMock(...args),
lookupItemDetails: (...args) => lookupItemDetailsMock(...args),
patchStockItem: (...args) => patchStockItemMock(...args),
useStockItem: vi.fn(),
}));
vi.mock('../../../src/api/labels.js', () => ({
printItemLabel: vi.fn(),
formatPrintErrorMessage: (error) => error?.message || 'Printing failed.',
}));
vi.mock('../../../src/api/locations.js', () => ({
fetchLocations: vi.fn(async () => ({ flat: [], tree: [] })),
}));
const { stockDetailPageData } = await import('../../../src/features/stock/stock-detail-page.js');
describe('stock detail identifier and OFF lookup', () => {
beforeEach(() => {
lookupItemDetailsMock.mockReset();
patchStockItemMock.mockReset();
getStockEntryMock.mockReset();
globalThis.window = {
__loncApp: {
navigate: vi.fn(),
},
};
});
afterEach(() => {
vi.restoreAllMocks();
delete globalThis.window;
});
it('saves normalized identifier code via PATCH', async () => {
patchStockItemMock.mockResolvedValueOnce({
uuid_b64: 'item-1',
name: 'Milk',
identifier_code: '3830012345678',
});
const addAlert = vi.fn();
const store = { addAlert, isConnected: false };
const data = stockDetailPageData(store);
data.entry = { uuid_b64: 'item-1', name: 'Milk', identifier_code: '' };
data.identifierDraft = ' 3830 0123 45678 ';
await data.saveIdentifierCode();
expect(patchStockItemMock).toHaveBeenCalledWith(store, 'item-1', {
identifier_code: '3830012345678',
});
expect(data.identifierDraft).toBe('3830012345678');
expect(addAlert).toHaveBeenCalledWith({
type: 'success',
message: 'Identifier code saved for Milk.',
});
});
it('refreshes OFF details and surfaces stale-cache metadata', async () => {
lookupItemDetailsMock.mockResolvedValueOnce({
status: 'ok',
update: false,
updatedFields: ['name', 'nutrition_facts'],
staleCache: true,
offPayloadFetchedAt: '2026-04-11T09:00:00Z',
item: {
uuid_b64: 'item-1',
name: 'Milk',
identifier_code: '3830012345678',
},
});
const addAlert = vi.fn();
const store = { addAlert, isConnected: false };
const data = stockDetailPageData(store);
data.entry = { uuid_b64: 'item-1', name: 'Milk', identifier_code: '3830012345678' };
data.identifierDraft = '3830012345678';
await data.runItemLookup(false);
expect(lookupItemDetailsMock).toHaveBeenCalledWith(store, 'item-1', { update: false });
expect(data.offLookupFeedback.type).toBe('success');
expect(data.offLookupFeedback.message).toContain('Using stale cache data.');
expect(addAlert).toHaveBeenCalledWith({
type: 'success',
message: data.offLookupFeedback.message,
});
});
it('apply missing fields reloads entry after successful lookup', async () => {
lookupItemDetailsMock.mockResolvedValueOnce({
status: 'ok',
update: true,
updatedFields: ['description'],
staleCache: false,
offPayloadFetchedAt: null,
item: null,
});
getStockEntryMock.mockResolvedValueOnce({
uuid_b64: 'item-1',
name: 'Milk',
identifier_code: '3830012345678',
description: 'Whole milk',
});
const addAlert = vi.fn();
const store = { addAlert, isConnected: false };
const data = stockDetailPageData(store);
data.entry = { uuid_b64: 'item-1', name: 'Milk', identifier_code: '3830012345678' };
data.identifierDraft = '3830012345678';
await data.runItemLookup(true);
expect(getStockEntryMock).toHaveBeenCalledWith(store, 'item-1');
expect(data.entry.description).toBe('Whole milk');
expect(data.offLookupFeedback.type).toBe('success');
});
});
+2
View File
@@ -12,6 +12,8 @@ vi.mock('../../../src/api/stock.js', () => ({
getStockEntry: vi.fn(),
adjustStockEntry: vi.fn(),
useStockItem: vi.fn(),
lookupItemDetails: vi.fn(),
patchStockItem: vi.fn(),
listStockEntries: vi.fn(async () => []),
listGroupedStockEntries: vi.fn(async () => []),
updateStockItem: vi.fn(),