diff --git a/README.md b/README.md
index e4e326f..f9bd70b 100644
--- a/README.md
+++ b/README.md
@@ -171,6 +171,10 @@ Expected shapes today:
Returns `{ since, next_cursor, changes }` feed payload for item/stock updates.
- `POST /{database}/kitchen/items/upsert?mode=preview|apply`
Used by label submit flow for create-or-update behavior and conflict-safe matching.
+- `POST /{database}/kitchen/items/lookup`
+ Identifier lookup response includes source/freshness metadata (`source`, `cache_hit`, `stale_cache`, `payload_fetched_at`, `retry_after_seconds`) used for richer user feedback.
+- `POST /{database}/kitchen/items/{uuid_b64}/lookup?update=0|1`
+ Item-scoped OpenFoodFacts lookup used by stock detail to preview (`update=0`) or apply missing fields (`update=1`).
- `POST /{database}/kitchen/items?label=1`
Used for label image preview rendering.
- `POST /{database}/kitchen/items?label=1&preview=1`
@@ -183,6 +187,8 @@ Expected shapes today:
Prints label for an existing item; called from the save flow when `Print` is enabled.
- `DELETE /{database}/kitchen/items/{uuid_b64}`
Compatibility fallback when `/use` is not available on the backend.
+- `PATCH /{database}/kitchen/items/{uuid_b64}`
+ Used for item-level edits from stock detail (for example identifier code updates).
- `GET /{database}/kitchen/locations`
Returns a nested location tree.
@@ -193,3 +199,4 @@ Expected shapes today:
- Kitchen context now lives in the URL path instead of a custom header.
- The API client now builds database-scoped kitchen routes by default; it always keeps bearer authentication handling separate from URL shaping.
- Label submit uses upsert-first apply semantics and an optional `Print` checkbox (default on for the current page session).
+- Stock detail supports inline identifier editing and OpenFoodFacts refresh/apply actions with rate-limit and cache-freshness hints.
diff --git a/package-lock.json b/package-lock.json
index b34b814..d458545 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -1,12 +1,12 @@
{
"name": "lonc-web",
- "version": "0.1.4",
+ "version": "0.2.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "lonc-web",
- "version": "0.1.4",
+ "version": "0.2.0",
"dependencies": {
"@zxing/browser": "^0.1.5",
"alpinejs": "^3.14.9",
diff --git a/package.json b/package.json
index d24f754..3954513 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "lonc-web",
- "version": "0.1.4",
+ "version": "0.2.0",
"private": true,
"type": "module",
"scripts": {
diff --git a/src/api/stock.js b/src/api/stock.js
index fea63d7..4a4036c 100644
--- a/src/api/stock.js
+++ b/src/api/stock.js
@@ -77,6 +77,26 @@ function normalizeIdentifierLookupResponse(payload) {
identifierType: payload?.identifier_type || null,
item: payload?.item || null,
payloadFetchedAt: payload?.payload_fetched_at || null,
+ retryAfterSeconds:
+ Number.isInteger(payload?.retry_after_seconds) ? payload.retry_after_seconds : null,
+ staleCache: Boolean(payload?.stale_cache),
+ };
+}
+
+function normalizeItemLookupResponse(payload) {
+ return {
+ status: payload?.status || null,
+ found: Boolean(payload?.found),
+ update: Boolean(payload?.update),
+ identifierCode: payload?.identifier_code || null,
+ identifierType: payload?.identifier_type || null,
+ preview: payload?.preview || null,
+ updatedFields: Array.isArray(payload?.updated_fields) ? payload.updated_fields : [],
+ offPayloadFetchedAt: payload?.off_payload_fetched_at || null,
+ retryAfterSeconds:
+ Number.isInteger(payload?.retry_after_seconds) ? payload.retry_after_seconds : null,
+ staleCache: Boolean(payload?.stale_cache),
+ item: payload?.item || null,
};
}
@@ -111,6 +131,23 @@ export async function lookupItemByIdentifier(store, identifierCode) {
return normalizeIdentifierLookupResponse(payload);
}
+export async function lookupItemDetails(store, uuidB64, { update = false } = {}) {
+ const payload = await apiRequest(store, `${getPath('items')}/${uuidB64}/lookup`, {
+ method: 'POST',
+ query: { update: update ? 1 : 0 },
+ });
+
+ return normalizeItemLookupResponse(payload);
+}
+
+export async function patchStockItem(store, uuidB64, body) {
+ const payload = await apiRequest(store, `${getPath('items')}/${uuidB64}`, {
+ method: 'PATCH',
+ body,
+ });
+ return unwrapEntryPayload(payload);
+}
+
export async function updateStockItem(store, uuidB64, body) {
const payload = await apiRequest(store, `${getPath('items')}/${uuidB64}/stock`, {
method: 'POST',
diff --git a/src/app/config.js b/src/app/config.js
index 26ecc8b..b113193 100644
--- a/src/app/config.js
+++ b/src/app/config.js
@@ -1,5 +1,5 @@
export const APP_NAME = 'Lonc';
-export const APP_VERSION = typeof __APP_VERSION__ !== 'undefined' ? __APP_VERSION__ : '0.1.2';
+export const APP_VERSION = typeof __APP_VERSION__ !== 'undefined' ? __APP_VERSION__ : '0.2.0';
export const TRYTON_APPLICATION = 'kitchen';
export const CONNECTION_STATES = {
diff --git a/src/features/labels/label-create-page.js b/src/features/labels/label-create-page.js
index 1d592e4..3077d19 100644
--- a/src/features/labels/label-create-page.js
+++ b/src/features/labels/label-create-page.js
@@ -739,8 +739,67 @@ export function labelCreatePageData(store) {
return 'Lookup failed on the server. You can still fill the form manually.';
}
+ if (status === 'rate_limited') {
+ return 'Lookup is temporarily rate-limited. Try again shortly.';
+ }
+
return 'Lookup response could not be applied to this form.';
},
+ lookupStatusMessageWithDetails(response, identifierCode) {
+ const base = this.lookupStatusMessage(response?.status, identifierCode);
+ if (response?.status !== 'rate_limited') {
+ return base;
+ }
+
+ if (!Number.isInteger(response?.retryAfterSeconds) || response.retryAfterSeconds <= 0) {
+ return base;
+ }
+
+ return `${base} Retry in ${response.retryAfterSeconds}s.`;
+ },
+ lookupSourceLabel(source) {
+ if (!source) {
+ return '';
+ }
+
+ const labels = {
+ item: 'existing item',
+ cache: 'cache',
+ openfoodfacts: 'OpenFoodFacts',
+ };
+
+ return labels[source] || source;
+ },
+ lookupSuccessMessage(response) {
+ const parts = ['Lookup applied product details'];
+ const metadata = [];
+
+ if (response?.source) {
+ metadata.push(`source: ${this.lookupSourceLabel(response.source)}`);
+ }
+ if (response?.cacheHit) {
+ metadata.push('cache hit');
+ }
+ if (response?.staleCache) {
+ metadata.push('stale cache');
+ }
+ if (response?.payloadFetchedAt) {
+ const fetchedAt = new Date(response.payloadFetchedAt);
+ metadata.push(
+ `fetched: ${
+ Number.isNaN(fetchedAt.getTime())
+ ? response.payloadFetchedAt
+ : fetchedAt.toLocaleString()
+ }`,
+ );
+ }
+
+ if (metadata.length) {
+ parts.push(`(${metadata.join(', ')})`);
+ }
+
+ return `${parts.join(' ')}.`;
+ },
normalizeScannerError(error) {
const message = String(error?.message || '');
const normalized = message.toLowerCase();
@@ -875,7 +934,7 @@ export function labelCreatePageData(store) {
await runAsyncState(this.lookupState, async () => {
const response = await lookupItemByIdentifier(store, identifierCode);
if (response.status !== 'ok') {
- const message = this.lookupStatusMessage(response.status, identifierCode);
+ const message = this.lookupStatusMessageWithDetails(response, identifierCode);
this.lookupState.error = message;
store.addAlert({
type: response.status === 'not_found' ? 'info' : 'warning',
@@ -932,11 +991,9 @@ export function labelCreatePageData(store) {
this.suggestions = [];
this.persistDraft();
- const sourceSuffix = response.source ? ` (${response.source})` : '';
- const cacheSuffix = response.cacheHit ? ', cache hit' : '';
store.addAlert({
type: 'success',
- message: `Lookup applied product details${sourceSuffix}${cacheSuffix}.`,
+ message: this.lookupSuccessMessage(response),
});
}).catch((error) => {
store.addAlert({
diff --git a/src/features/stock/stock-detail-page.js b/src/features/stock/stock-detail-page.js
index a577ae7..72c7e46 100644
--- a/src/features/stock/stock-detail-page.js
+++ b/src/features/stock/stock-detail-page.js
@@ -1,6 +1,8 @@
import {
adjustStockEntry,
getStockEntry,
+ lookupItemDetails,
+ patchStockItem,
useStockItem,
} from '../../api/stock.js';
import { formatPrintErrorMessage, printItemLabel } from '../../api/labels.js';
@@ -27,6 +29,10 @@ function parseDateValue(value) {
return new Date(year, month - 1, day);
}
+function normalizeIdentifierCode(value) {
+ return String(value || '').replace(/\s+/g, '').trim();
+}
+
function expirationInfo(entry) {
if (!entry?.expire_date) {
return {
@@ -134,6 +140,67 @@ export function renderStockDetailPage() {
+
+
Identifier
+
+
+
+
+
Used for OpenFoodFacts lookups and product metadata refresh.
+
+
+
+
+
+
+
OpenFoodFacts
+
+
+
+
+
+ Save an identifier code first to enable lookup refresh.
+
+
+
+
+
+
Nutrition
@@ -298,12 +365,19 @@ export function stockDetailPageData(store) {
state: createAsyncState(),
adjustmentState: createAsyncState(),
printState: createAsyncState(),
+ identifierState: createAsyncState(),
+ lookupDetailsState: createAsyncState(),
printFeedback: {
type: '',
message: '',
},
+ offLookupFeedback: {
+ type: '',
+ message: '',
+ },
entry: null,
locationPathByUuid: {},
+ identifierDraft: '',
adjustment: {
mode: 'increment',
quantity: '1',
@@ -321,6 +395,7 @@ export function stockDetailPageData(store) {
fetchLocations(store).catch(() => ({ flat: [] })),
]);
this.entry = entry;
+ this.identifierDraft = normalizeIdentifierCode(entry?.identifier_code);
this.locationPathByUuid = Object.fromEntries(
(locations.flat || [])
.filter((location) => location.uuid_b64)
@@ -329,6 +404,149 @@ export function stockDetailPageData(store) {
this.adjustment.level = this.entry?.level || 'plenty';
}).catch(() => {});
},
+ normalizedIdentifierDraft() {
+ return normalizeIdentifierCode(this.identifierDraft);
+ },
+ hasIdentifierCode() {
+ return Boolean(this.normalizedIdentifierDraft());
+ },
+ async reloadEntry(uuidB64) {
+ const refreshed = await getStockEntry(store, uuidB64);
+ this.entry = refreshed;
+ this.identifierDraft = normalizeIdentifierCode(refreshed?.identifier_code);
+ this.adjustment.level = this.entry?.level || 'plenty';
+ },
+ itemLookupStatusMessage(response) {
+ const retryAfter = Number.isInteger(response?.retryAfterSeconds) && response.retryAfterSeconds > 0
+ ? ` Retry in ${response.retryAfterSeconds}s.`
+ : '';
+
+ if (response?.status === 'missing_identifier') {
+ return 'Save an identifier code before running lookup.';
+ }
+ if (response?.status === 'not_found') {
+ return `No OpenFoodFacts result found for code ${this.normalizedIdentifierDraft() || 'unknown'}.`;
+ }
+ if (response?.status === 'rate_limited') {
+ return `OpenFoodFacts lookup is temporarily rate-limited.${retryAfter}`;
+ }
+ if (response?.status === 'lookup_failed') {
+ return 'OpenFoodFacts lookup failed. Try again shortly or continue manually.';
+ }
+
+ return 'Lookup response could not be applied.';
+ },
+ itemLookupSuccessMessage(response) {
+ const parts = [
+ response?.update
+ ? 'Applied missing fields from OpenFoodFacts.'
+ : 'Fetched OpenFoodFacts details preview.',
+ ];
+ const source = response?.item?.external_source || this.entry?.external_source;
+
+ if (source) {
+ parts.push(`Source: ${source}.`);
+ }
+
+ if (Array.isArray(response?.updatedFields) && response.updatedFields.length) {
+ parts.push(`Updated: ${response.updatedFields.join(', ')}.`);
+ }
+
+ if (response?.staleCache) {
+ parts.push('Using stale cache data.');
+ } else {
+ parts.push('Cache freshness: current.');
+ }
+
+ if (response?.offPayloadFetchedAt) {
+ const fetchedAt = new Date(response.offPayloadFetchedAt);
+ parts.push(
+ `Fetched at: ${
+ Number.isNaN(fetchedAt.getTime())
+ ? response.offPayloadFetchedAt
+ : fetchedAt.toLocaleString()
+ }.`,
+ );
+ }
+
+ return parts.join(' ');
+ },
+ async saveIdentifierCode() {
+ if (!this.entry?.uuid_b64) {
+ return;
+ }
+
+ this.identifierState.error = '';
+ await runAsyncState(this.identifierState, async () => {
+ const identifierCode = this.normalizedIdentifierDraft();
+ const updated = await patchStockItem(store, this.entry.uuid_b64, {
+ identifier_code: identifierCode || null,
+ });
+
+ this.entry = updated;
+ this.identifierDraft = normalizeIdentifierCode(updated?.identifier_code || identifierCode);
+ this.offLookupFeedback = {
+ type: '',
+ message: '',
+ };
+ store.addAlert({
+ type: 'success',
+ message: identifierCode
+ ? `Identifier code saved for ${this.entry.name}.`
+ : `Identifier code cleared for ${this.entry.name}.`,
+ });
+ }).catch(() => {});
+ },
+ async runItemLookup(update) {
+ if (!this.entry?.uuid_b64) {
+ return;
+ }
+
+ const identifierCode = this.normalizedIdentifierDraft();
+ if (!identifierCode) {
+ this.offLookupFeedback = {
+ type: 'warning',
+ message: 'Save an identifier code before running lookup refresh.',
+ };
+ return;
+ }
+
+ this.lookupDetailsState.error = '';
+ await runAsyncState(this.lookupDetailsState, async () => {
+ const response = await lookupItemDetails(store, this.entry.uuid_b64, { update });
+ if (response.status !== 'ok') {
+ const message = this.itemLookupStatusMessage(response);
+ this.offLookupFeedback = {
+ type: 'warning',
+ message,
+ };
+ store.addAlert({ type: 'warning', message });
+ return;
+ }
+
+ if (update) {
+ await this.reloadEntry(this.entry.uuid_b64);
+ } else if (response.item) {
+ this.entry = response.item;
+ this.identifierDraft = normalizeIdentifierCode(response.item.identifier_code || identifierCode);
+ }
+
+ const message = this.itemLookupSuccessMessage(response);
+ this.offLookupFeedback = {
+ type: 'success',
+ message,
+ };
+ store.addAlert({
+ type: 'success',
+ message,
+ });
+ }).catch((error) => {
+ this.offLookupFeedback = {
+ type: 'warning',
+ message: error?.message || 'OpenFoodFacts lookup failed.',
+ };
+ });
+ },
async submitMeasuredAdjustment() {
if (!this.entry) {
return;
@@ -351,6 +569,7 @@ export function stockDetailPageData(store) {
this.entry = await adjustStockEntry(store, this.entry.uuid_b64, {
quantity: exactQuantity,
});
+ this.identifierDraft = normalizeIdentifierCode(this.entry?.identifier_code);
store.addAlert({ type: 'success', message: 'Stock quantity updated.' });
}).catch(() => {});
},
@@ -371,6 +590,7 @@ export function stockDetailPageData(store) {
this.entry = await adjustStockEntry(store, this.entry.uuid_b64, {
level: this.adjustment.level,
});
+ this.identifierDraft = normalizeIdentifierCode(this.entry?.identifier_code);
store.addAlert({ type: 'success', message: 'Stock level updated.' });
}).catch(() => {});
},
diff --git a/tests/api/stock.test.js b/tests/api/stock.test.js
index 131f559..3ffa1bd 100644
--- a/tests/api/stock.test.js
+++ b/tests/api/stock.test.js
@@ -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);
diff --git a/tests/features/labels/identifier-lookup-feedback.test.js b/tests/features/labels/identifier-lookup-feedback.test.js
new file mode 100644
index 0000000..75d54f1
--- /dev/null
+++ b/tests/features/labels/identifier-lookup-feedback.test.js
@@ -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,
+ });
+ });
+});
diff --git a/tests/features/stock/mark-gone.test.js b/tests/features/stock/mark-gone.test.js
index acd9370..2412e24 100644
--- a/tests/features/stock/mark-gone.test.js
+++ b/tests/features/stock/mark-gone.test.js
@@ -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(),
diff --git a/tests/features/stock/off-lookup-and-identifier.test.js b/tests/features/stock/off-lookup-and-identifier.test.js
new file mode 100644
index 0000000..c20d729
--- /dev/null
+++ b/tests/features/stock/off-lookup-and-identifier.test.js
@@ -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');
+ });
+});
diff --git a/tests/features/stock/print-label.test.js b/tests/features/stock/print-label.test.js
index 8caccf7..e4c60c5 100644
--- a/tests/features/stock/print-label.test.js
+++ b/tests/features/stock/print-label.test.js
@@ -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(),