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

+
+ + +
+ + +
+

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(),