diff --git a/README.md b/README.md index 1799285..5d24055 100644 --- a/README.md +++ b/README.md @@ -163,36 +163,42 @@ Expected shapes today: Returns `{ data: [...] }` or `{ kitchens: [...] }`. - `GET /{database}/kitchen/items?search_name=...` Returns item definitions for autocomplete. + Item payloads now expose category links via `categories` (array of IDs). - `GET /{database}/kitchen/items` Returns the current stock review list. Endpoint is paginated (`limit`/`offset`, backend default `limit=100`); frontend helpers aggregate pages by default unless explicit pagination is passed. -- `GET /{database}/kitchen/items/grouped?expanded=0|1` - Returns grouped stock data; grouped review uses summary-first loading (`expanded=0`) and hydrates item children in background (`expanded=1`). - With `expanded=0`, child `items` can be ID-only stubs (`{ id }`) instead of full item payloads. +- `GET /{database}/kitchen/items/grouped?expanded=true|false` + Returns grouped stock data; grouped review uses summary-first loading (`expanded=false`) and hydrates item children in background (`expanded=true`). + With `expanded=false`, child `items` can be ID-only stubs (`{ id }`) instead of full item payloads. - `GET /{database}/kitchen/items/{uuid_b64}` Returns one item detail payload. + Supports `allow_inactive=true|false` query filtering when needed. - `GET /{database}/kitchen/changes` 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` +- `POST /{database}/kitchen/items/{uuid_b64}/lookup?update=true|false` + Item-scoped OpenFoodFacts lookup used by stock detail to preview (`update=false`) or apply missing fields (`update=true`). +- `POST /{database}/kitchen/items?label=true&preview=true` Returns an image blob, `{ imageUrl }`, or `{ imageSvg }` for in-browser preview. +- `GET /{database}/kitchen/items/{uuid_b64}/label` + Returns rendered label PNG for an existing item. - `POST /{database}/kitchen/items/{uuid_b64}/stock` - Creates a stock event for measured or descriptive updates using `{ quantity }` or `{ level }`. + Creates a stock event for measured or descriptive updates using `{ quantity }` or `{ level }`, + and for non-consumed gone transitions (for example `{ level: "gone", gone_reason: "spoiled" }`). Response shape is `{ status, stock }`; frontend re-fetches the item detail after successful update. - `POST /{database}/kitchen/items/{uuid_b64}/use` - Marks an item used up (`gone`) via stock-event semantics. + Marks an item consumed/used up (`gone`) via stock-event semantics. - `POST /{database}/kitchen/items/{uuid_b64}/print-label` Prints label for an existing item; called from the save flow when `Print` is enabled. - `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. +- `GET /{database}/kitchen/categories` + Returns categories (paged). Frontend now resolves category labels from + `categories_detail` when present, and falls back to this endpoint by ID. ## Notes diff --git a/package-lock.json b/package-lock.json index d8b10e9..3fa5c33 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "lonc-web", - "version": "0.2.4", + "version": "0.2.6", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "lonc-web", - "version": "0.2.4", + "version": "0.2.6", "dependencies": { "@zxing/browser": "^0.1.5", "alpinejs": "^3.14.9", diff --git a/package.json b/package.json index 1100bd1..ee0424d 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "lonc-web", - "version": "0.2.5", + "version": "0.2.6", "private": true, "type": "module", "scripts": { diff --git a/src/api/categories.js b/src/api/categories.js new file mode 100644 index 0000000..65e91cf --- /dev/null +++ b/src/api/categories.js @@ -0,0 +1,90 @@ +import { apiRequest, getPath } from './client.js'; + +const DEFAULT_LIST_PAGE_LIMIT = 100; + +function unwrapListPayload(payload) { + if (Array.isArray(payload)) { + return payload; + } + + return payload?.data || payload?.entries || payload?.items || payload?.categories || []; +} + +function hasExplicitPagination(filters = {}) { + return ( + (filters.limit !== undefined && filters.limit !== null) + || (filters.offset !== undefined && filters.offset !== null) + ); +} + +function buildCategoryQuery(filters = {}) { + const query = {}; + const searchName = filters.searchName || filters.search_name; + if (searchName) { + query.search_name = searchName; + } + + if (filters.active !== undefined && filters.active !== null && filters.active !== '') { + query.active = Boolean(filters.active); + } + + if (filters.orderBy || filters.order_by) { + query.order_by = filters.orderBy || filters.order_by; + } + + if (filters.orderDir || filters.order_dir) { + query.order_dir = filters.orderDir || filters.order_dir; + } + + if (filters.expanded !== undefined && filters.expanded !== null && filters.expanded !== '') { + query.expanded = Boolean(filters.expanded); + } + + return query; +} + +async function fetchAllCategoryPages(store, baseQuery = {}) { + const items = []; + let offset = 0; + + while (true) { + const payload = await apiRequest(store, getPath('categories'), { + query: { + ...baseQuery, + limit: DEFAULT_LIST_PAGE_LIMIT, + offset, + }, + }); + const pageItems = unwrapListPayload(payload); + items.push(...pageItems); + + if (pageItems.length < DEFAULT_LIST_PAGE_LIMIT) { + break; + } + + offset += DEFAULT_LIST_PAGE_LIMIT; + } + + return items; +} + +export async function listCategories(store, filters = {}) { + const baseQuery = buildCategoryQuery(filters); + + if (hasExplicitPagination(filters)) { + const query = { ...baseQuery }; + if (filters.limit !== undefined && filters.limit !== null) { + query.limit = filters.limit; + } + if (filters.offset !== undefined && filters.offset !== null) { + query.offset = filters.offset; + } + + const payload = await apiRequest(store, getPath('categories'), { + query, + }); + return unwrapListPayload(payload); + } + + return fetchAllCategoryPages(store, baseQuery); +} diff --git a/src/api/labels.js b/src/api/labels.js index 514635f..5ac4f31 100644 --- a/src/api/labels.js +++ b/src/api/labels.js @@ -42,7 +42,7 @@ export async function previewLabel(store, body) { method: 'POST', body, accept: 'image/svg+xml, image/png, application/json', - query: { label: 1, preview: 1 }, + query: { label: true, preview: true }, }); const image = normalizeLabelImagePayload(payload); @@ -53,6 +53,19 @@ export async function previewLabel(store, body) { throw new Error('Label preview response did not include an image.'); } +export async function getItemLabel(store, uuidB64) { + const payload = await apiRequest(store, `${getPath('items')}/${uuidB64}/label`, { + method: 'GET', + accept: 'image/png, application/json', + }); + const image = normalizeLabelImagePayload(payload); + if (image) { + return image; + } + + throw new Error('Item label response did not include an image.'); +} + export async function printItemLabel(store, uuidB64) { return apiRequest(store, `${getPath('items')}/${uuidB64}/print-label`, { method: 'POST', diff --git a/src/api/stock.js b/src/api/stock.js index 7f39dae..8fcc69a 100644 --- a/src/api/stock.js +++ b/src/api/stock.js @@ -2,6 +2,35 @@ import { apiRequest, getPath } from './client.js'; const DEFAULT_LIST_PAGE_LIMIT = 100; +function toBooleanFlag(value, defaultValue = false) { + if (value === undefined || value === null || value === '') { + return defaultValue; + } + + if (typeof value === 'boolean') { + return value; + } + + if (typeof value === 'number') { + return value !== 0; + } + + if (typeof value === 'string') { + const normalized = value.trim().toLowerCase(); + if (!normalized) { + return defaultValue; + } + if (['1', 'true', 'yes', 'y', 'on'].includes(normalized)) { + return true; + } + if (['0', 'false', 'no', 'n', 'off'].includes(normalized)) { + return false; + } + } + + return Boolean(value); +} + function unwrapEntryPayload(payload) { return payload?.data || payload?.entry || payload?.item || payload; } @@ -52,7 +81,7 @@ export async function searchItemDefinitions(store, query) { } const payload = await apiRequest(store, `${getPath('items')}/grouped`, { - query: { search_name: query, expanded: 0 }, + query: { search_name: query, expanded: false }, }); if (Array.isArray(payload)) { @@ -90,7 +119,7 @@ export async function listStockEntries(store, filters = {}) { export async function listGroupedStockEntries(store, options = {}) { const baseQuery = {}; - const expanded = options.expanded ?? 1; + const expanded = toBooleanFlag(options.expanded, true); baseQuery.expanded = expanded; const searchName = options.searchName || options.search_name; if (searchName) { @@ -119,7 +148,7 @@ export async function listGroupedStockEntries(store, options = {}) { export async function getStockEntry(store, stockId, { allowInactive = false } = {}) { const path = `${getPath('items')}/${stockId}`; const payload = allowInactive - ? await apiRequest(store, path, { query: { allow_inactive: 1 } }) + ? await apiRequest(store, path, { query: { allow_inactive: true } }) : await apiRequest(store, path); return unwrapEntryPayload(payload); } @@ -128,7 +157,7 @@ export async function createStockEntry(store, body) { const payload = await apiRequest(store, getPath('items'), { method: 'POST', body, - query: { label: 1, print: 1 }, + query: { label: true, print: true }, }); return unwrapEntryPayload(payload); } @@ -211,7 +240,7 @@ export async function lookupItemByIdentifier(store, identifierCode) { export async function lookupItemDetails(store, uuidB64, { update = false } = {}) { const payload = await apiRequest(store, `${getPath('items')}/${uuidB64}/lookup`, { method: 'POST', - query: { update: update ? 1 : 0 }, + query: { update: toBooleanFlag(update, false) }, }); return normalizeItemLookupResponse(payload); @@ -246,7 +275,7 @@ export async function createStockEvent(store, uuidB64, body) { export async function listStockEvents(store, uuidB64, options = {}) { const query = {}; if (options.allowInactive) { - query.allow_inactive = 1; + query.allow_inactive = true; } if (options.limit !== undefined && options.limit !== null) { query.limit = options.limit; @@ -269,6 +298,14 @@ export async function listStockEvents(store, uuidB64, options = {}) { export async function markStockGone(store, uuidB64, reason = 'consumed') { try { + if (reason === 'consumed') { + const result = await useStockItem(store, uuidB64); + if (result.status === 'already_gone') { + return { status: 'already_gone', reason }; + } + return { status: 'gone', reason }; + } + await createStockEvent(store, uuidB64, { level: 'gone', gone_reason: reason, diff --git a/src/app/config.js b/src/app/config.js index 94008eb..174d9ff 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.2.5'; +export const APP_VERSION = typeof __APP_VERSION__ !== 'undefined' ? __APP_VERSION__ : '0.2.6'; export const TRYTON_APPLICATION = 'kitchen'; export const CONNECTION_STATES = { @@ -27,6 +27,7 @@ export const API_PATHS = { kitchens: 'kitchen/kitchens', items: 'kitchen/items', locations: 'kitchen/locations', + categories: 'kitchen/categories', changes: 'kitchen/changes', }; diff --git a/src/features/stock/stock-detail-page.js b/src/features/stock/stock-detail-page.js index 356ead2..92bf648 100644 --- a/src/features/stock/stock-detail-page.js +++ b/src/features/stock/stock-detail-page.js @@ -8,6 +8,7 @@ import { } from '../../api/stock.js'; import { formatPrintErrorMessage, printItemLabel } from '../../api/labels.js'; import { fetchLocations } from '../../api/locations.js'; +import { listCategories } from '../../api/categories.js'; import { getRouteContext } from '../../app/router.js'; import { renderScannerModal } from '../shared/scanner-modal.js'; import { @@ -145,6 +146,21 @@ export function renderStockDetailPage() {
Stock type
+
Main category
+
+
Categories
+
+ + +
@@ -464,6 +480,7 @@ export function stockDetailPageData(store) { entry: null, stockEvents: [], locationPathByUuid: {}, + categoriesById: {}, identifierDraft: '', scannerManualCode: '', adjustment: { @@ -484,9 +501,10 @@ export function stockDetailPageData(store) { const { params } = getRouteContext(); await runAsyncState(this.state, async () => { - const [entry, locations] = await Promise.all([ + const [entry, locations, categories] = await Promise.all([ getStockEntry(store, params.id), fetchLocations(store).catch(() => ({ flat: [] })), + listCategories(store, { expanded: true }).catch(() => []), ]); this.entry = entry; this.identifierDraft = normalizeIdentifierCode(entry?.identifier_code); @@ -495,6 +513,11 @@ export function stockDetailPageData(store) { .filter((location) => location.uuid_b64) .map((location) => [location.uuid_b64, location.pathLabel || location.name]), ); + this.categoriesById = Object.fromEntries( + categories + .filter((category) => category?.id !== undefined && category?.id !== null) + .map((category) => [String(category.id), category]), + ); this.adjustment.level = this.entry?.level || 'plenty'; }).catch(() => {}); this.loadStockHistory().catch(() => {}); @@ -966,6 +989,35 @@ export function stockDetailPageData(store) { return this.locationPathByUuid[locationUuid] || 'Location not resolved'; }, + categoryLabel(category) { + if (!category || typeof category !== 'object') { + return ''; + } + + return String(category.path || category.name || '').trim(); + }, + mainCategoryLabel(entry) { + const categoryId = entry?.category; + if (categoryId === null || categoryId === undefined || categoryId === '') { + return ''; + } + + const mapped = this.categoryLabel(this.categoriesById[String(categoryId)]); + if (mapped) { + return mapped; + } + + return String(categoryId).trim(); + }, + categoryLabels(entry) { + const categoryIds = Array.isArray(entry?.categories) ? entry.categories : []; + const labels = categoryIds.map((categoryId) => { + const mapped = this.categoryLabel(this.categoriesById[String(categoryId)]); + return mapped || String(categoryId || '').trim(); + }); + + return [...new Set(labels.filter(Boolean))]; + }, nutriScoreLabel(entry) { const value = entry?.nutriscore_grade; if (!value) { diff --git a/src/features/stock/stock-list-page.js b/src/features/stock/stock-list-page.js index 88d2045..b051806 100644 --- a/src/features/stock/stock-list-page.js +++ b/src/features/stock/stock-list-page.js @@ -7,6 +7,7 @@ import { updateStockItem, } from '../../api/stock.js'; import { fetchLocations } from '../../api/locations.js'; +import { listCategories } from '../../api/categories.js'; import { STORAGE_KEYS } from '../../app/config.js'; import { clearStoredValue, loadStoredValue, saveStoredValue } from '../shared/storage.js'; import { createAsyncState } from '../shared/ui-state.js'; @@ -221,13 +222,35 @@ function resolveLocationLabel(entry, locationMap) { return locationMap[entry.location_initial_uuid_b64] || 'Location not resolved'; } -function searchBlob(entry, locationMap) { +function categoryLabel(category) { + if (!category || typeof category !== 'object') { + return ''; + } + + return String(category.name || category.path || '').trim(); +} + +function resolveMainCategoryLabel(entry, categoriesById = {}) { + const categoryId = entry?.category; + if (categoryId === null || categoryId === undefined || categoryId === '') { + return ''; + } + + const mapped = categoryLabel(categoriesById[String(categoryId)]); + if (mapped) { + return mapped; + } + return String(categoryId).trim(); +} + +function searchBlob(entry, locationMap, categoriesById = {}) { return [ entry.name, entry.description, entry.level, entry.stock_type, resolveLocationLabel(entry, locationMap), + resolveMainCategoryLabel(entry, categoriesById), entry.uuid_b64, ] .filter(Boolean) @@ -235,10 +258,10 @@ function searchBlob(entry, locationMap) { .toLowerCase(); } -function groupSearchBlob(group, locationMap) { +function groupSearchBlob(group, locationMap, categoriesById = {}) { return [ - searchBlob(group, locationMap), - ...(group.items || []).map((item) => searchBlob(item, locationMap)), + searchBlob(group, locationMap, categoriesById), + ...(group.items || []).map((item) => searchBlob(item, locationMap, categoriesById)), ] .filter(Boolean) .join(' ') @@ -544,6 +567,9 @@ export function renderStockListPage() {
+
+ +
+
+ +
+
+ +
item(s) Latest location: - Quantity: + + + +
@@ -781,6 +816,12 @@ export function renderStockListPage() { +
@@ -876,6 +917,7 @@ export function stockListPageData(store) { locationMap: {}, locationDescendants: {}, locationLineage: {}, + categoriesById: {}, editForms: {}, editErrors: {}, levelOptions: LEVEL_OPTIONS, @@ -918,13 +960,13 @@ export function stockListPageData(store) { : false; if (!restoredFromRuntime) { - const initTasks = [this.loadLocations()]; + const initTasks = [this.loadLocations(), this.loadCategories()]; if (this.viewMode === 'items') { initTasks.push(this.loadEntries()); } else { initTasks.push( this.loadGroupedEntries({ - expanded: 0, + expanded: false, resetVisible: !restoredContext, }), ); @@ -937,6 +979,9 @@ export function stockListPageData(store) { if (!restoredFromRuntime && this.viewMode === 'grouped') { this.hydrateGroupedEntriesInBackground().catch(() => {}); } + if (restoredFromRuntime) { + this.refreshLoadedViewsInBackground().catch(() => {}); + } if (restoredFromRuntime && restoredContext?.focusedItemUuid) { this.refreshFocusedItemInBackground(restoredContext.focusedItemUuid).catch(() => {}); } @@ -1047,6 +1092,7 @@ export function stockListPageData(store) { locationMap: this.locationMap, locationDescendants: this.locationDescendants, locationLineage: this.locationLineage, + categoriesById: this.categoriesById, changeCursor: this.changeCursor, }), }; @@ -1106,6 +1152,9 @@ export function stockListPageData(store) { this.locationLineage = payload.locationLineage && typeof payload.locationLineage === 'object' ? payload.locationLineage : {}; + this.categoriesById = payload.categoriesById && typeof payload.categoriesById === 'object' + ? payload.categoriesById + : {}; this.changeCursor = payload.changeCursor || this.changeCursor; if (this.itemsLoaded) { @@ -1267,7 +1316,7 @@ export function stockListPageData(store) { }, indexEntry(entry) { const indexed = { ...entry }; - indexed._searchBlob = searchBlob(indexed, this.locationMap); + indexed._searchBlob = searchBlob(indexed, this.locationMap, this.categoriesById); return indexed; }, indexGroup(group) { @@ -1278,7 +1327,7 @@ export function stockListPageData(store) { ...group, items: indexedItems, }; - indexed._searchBlob = groupSearchBlob(indexed, this.locationMap); + indexed._searchBlob = groupSearchBlob(indexed, this.locationMap, this.categoriesById); return indexed; }, reindexSearchData() { @@ -1309,7 +1358,7 @@ export function stockListPageData(store) { if (mode === 'grouped') { if (!this.groupedLoaded) { - await this.loadGroupedEntries({ expanded: 0, resetVisible: true }); + await this.loadGroupedEntries({ expanded: false, resetVisible: true }); } this.hydrateGroupedEntriesInBackground().catch(() => {}); return; @@ -1325,7 +1374,7 @@ export function stockListPageData(store) { : this.itemsLoaded); if (this.viewMode === 'grouped') { - await this.loadGroupedEntries({ expanded: 0, background: useBackground }); + await this.loadGroupedEntries({ expanded: false, background: useBackground }); this.hydrateGroupedEntriesInBackground({ force: true }).catch(() => {}); return; } @@ -1417,7 +1466,7 @@ export function stockListPageData(store) { this.invalidateMemo(); this.persistRuntimeCache(); }, - async loadGroupedEntries({ expanded = 1, background = false, resetVisible = false } = {}) { + async loadGroupedEntries({ expanded = true, background = false, resetVisible = false } = {}) { if (!store.isConnected) { return; } @@ -1432,7 +1481,7 @@ export function stockListPageData(store) { try { const loadedGroups = await listGroupedStockEntries(store, { expanded }); - if (expanded === 0) { + if (!expanded) { this.applyGroupedSummary(loadedGroups, { resetVisible }); return; } @@ -1461,7 +1510,7 @@ export function stockListPageData(store) { this.groupedHydrating = true; try { - await this.loadGroupedEntries({ expanded: 1, background: true }); + await this.loadGroupedEntries({ expanded: true, background: true }); } finally { this.groupedHydrating = false; } @@ -1473,7 +1522,7 @@ export function stockListPageData(store) { } if (this.groupedLoaded) { tasks.push( - this.loadGroupedEntries({ expanded: 0, background: true }).then(() => + this.loadGroupedEntries({ expanded: false, background: true }).then(() => this.hydrateGroupedEntriesInBackground({ force: true }), ), ); @@ -1513,6 +1562,25 @@ export function stockListPageData(store) { this.persistRuntimeCache(); } }, + async loadCategories() { + if (!store.isConnected) { + return; + } + + try { + const categories = await listCategories(store, { expanded: true }); + this.categoriesById = Object.fromEntries( + categories + .filter((category) => category?.id !== undefined && category?.id !== null) + .map((category) => [String(category.id), category]), + ); + } catch { + this.categoriesById = {}; + } finally { + this.reindexSearchData(); + this.persistRuntimeCache(); + } + }, resetGroupedVisibleLimit() { this.groupedVisibleLimit = this.groupedPageSize; }, @@ -1526,6 +1594,28 @@ export function stockListPageData(store) { return group.items.filter((item) => !isGroupedChildStub(item)); }, + mainCategoryLabel(entry) { + return resolveMainCategoryLabel(entry, this.categoriesById); + }, + mainCategoryBadgeLabel(entry) { + return `Main category: ${this.mainCategoryLabel(entry)}`; + }, + groupSummaryMetricLabel(group) { + if (group?.stock_type === 'measured') { + return 'Quantity'; + } + if (group?.stock_type === 'descriptive') { + return 'Stock level'; + } + if (group?.stock_type === 'binary') { + return 'Stock state'; + } + return 'Stock'; + }, + groupSummaryMetricValue(group) { + // Use backend-provided grouped fields as the single source of truth. + return quantityLabel(group); + }, hasGroupedChildStubs(group) { if (!Array.isArray(group?.items)) { return false; @@ -2269,7 +2359,7 @@ export function stockListPageData(store) { ? `${item.name} was already out of stock and removed from the group.` : `${item.name} was ${actionLabel} and removed from the group.`, }); - this.loadGroupedEntries({ expanded: 0, background: true }).catch(() => {}); + this.loadGroupedEntries({ expanded: false, background: true }).catch(() => {}); } catch (error) { this.editErrors[item.id] = error.message || 'Removal failed.'; } diff --git a/tests/api/categories.test.js b/tests/api/categories.test.js new file mode 100644 index 0000000..b3f46c5 --- /dev/null +++ b/tests/api/categories.test.js @@ -0,0 +1,67 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +const apiRequestMock = vi.fn(); + +vi.mock('../../src/api/client.js', () => ({ + getPath(key) { + const paths = { + categories: 'kitchen/categories', + }; + return paths[key]; + }, + apiRequest: (...args) => apiRequestMock(...args), +})); + +const { listCategories } = await import('../../src/api/categories.js'); + +describe('api/categories', () => { + beforeEach(() => { + apiRequestMock.mockReset(); + }); + + it('forwards explicit pagination and filters', async () => { + apiRequestMock.mockResolvedValueOnce([]); + + await listCategories( + { config: { database: 'db' } }, + { searchName: 'dairy', active: true, limit: 10, offset: 20, orderBy: 'name', orderDir: 'asc' }, + ); + + expect(apiRequestMock).toHaveBeenCalledWith( + { config: { database: 'db' } }, + 'kitchen/categories', + { + query: { + search_name: 'dairy', + active: true, + order_by: 'name', + order_dir: 'asc', + limit: 10, + offset: 20, + }, + }, + ); + }); + + it('aggregates category pages by default', async () => { + apiRequestMock + .mockResolvedValueOnce(Array.from({ length: 100 }, (_, id) => ({ id: id + 1 }))) + .mockResolvedValueOnce([{ id: 101 }]); + + const response = await listCategories({ config: { database: 'db' } }, {}); + + expect(response).toHaveLength(101); + expect(apiRequestMock).toHaveBeenNthCalledWith( + 1, + { config: { database: 'db' } }, + 'kitchen/categories', + { query: { limit: 100, offset: 0 } }, + ); + expect(apiRequestMock).toHaveBeenNthCalledWith( + 2, + { config: { database: 'db' } }, + 'kitchen/categories', + { query: { limit: 100, offset: 100 } }, + ); + }); +}); diff --git a/tests/api/client.test.js b/tests/api/client.test.js index 8e85094..171e0b8 100644 --- a/tests/api/client.test.js +++ b/tests/api/client.test.js @@ -61,12 +61,12 @@ describe('api/client', () => { const url = buildKitchenApiUrl(store, 'kitchen/items/grouped', { search_name: 'Milk + eggs', - expanded: 1, + expanded: true, ignored: '', }); expect(url).toBe( - 'https://api.example.com/my%20db/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=true', ); }); @@ -88,14 +88,14 @@ describe('api/client', () => { name: 'Rice', }, query: { - label: 1, + label: true, }, }); expect(payload).toEqual({ ok: true }); const [url, request] = fetchSpy.mock.calls[0]; - expect(url).toBe('/kitchen-db/kitchen/items?label=1'); + expect(url).toBe('/kitchen-db/kitchen/items?label=true'); expect(request.method).toBe('POST'); expect(request.body).toBe('{"name":"Rice"}'); expect(request.headers.get('Accept')).toBe('application/json'); diff --git a/tests/api/labels.test.js b/tests/api/labels.test.js index cf1e8b9..5c78646 100644 --- a/tests/api/labels.test.js +++ b/tests/api/labels.test.js @@ -1,6 +1,72 @@ -import { describe, expect, it } from 'vitest'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; -import { formatPrintErrorMessage } from '../../src/api/labels.js'; +const apiRequestMock = vi.fn(); + +vi.mock('../../src/api/client.js', () => ({ + getPath(key) { + const paths = { + items: 'kitchen/items', + }; + return paths[key]; + }, + apiRequest: (...args) => apiRequestMock(...args), +})); + +const { + formatPrintErrorMessage, + getItemLabel, + previewLabel, +} = await import('../../src/api/labels.js'); + +describe('api/labels', () => { + beforeEach(() => { + apiRequestMock.mockReset(); + }); + + it('previewLabel uses boolean label/preview query flags', async () => { + apiRequestMock.mockResolvedValueOnce({ + label: 'YWJj', + }); + + const response = await previewLabel({ config: { database: 'db' } }, { name: 'Rice' }); + + expect(apiRequestMock).toHaveBeenCalledWith( + { config: { database: 'db' } }, + 'kitchen/items', + { + method: 'POST', + body: { name: 'Rice' }, + accept: 'image/svg+xml, image/png, application/json', + query: { label: true, preview: true }, + }, + ); + expect(response).toEqual({ + objectUrl: 'data:image/png;base64,YWJj', + contentType: 'image/png', + }); + }); + + it('getItemLabel fetches PNG from /label endpoint', async () => { + apiRequestMock.mockResolvedValueOnce({ + label: 'YWJj', + }); + + const response = await getItemLabel({ config: { database: 'db' } }, 'item-1'); + + expect(apiRequestMock).toHaveBeenCalledWith( + { config: { database: 'db' } }, + 'kitchen/items/item-1/label', + { + method: 'GET', + accept: 'image/png, application/json', + }, + ); + expect(response).toEqual({ + objectUrl: 'data:image/png;base64,YWJj', + contentType: 'image/png', + }); + }); +}); describe('api/labels formatPrintErrorMessage', () => { it('maps printer_unavailable payload to user-friendly message', () => { diff --git a/tests/api/stock.test.js b/tests/api/stock.test.js index 6ce7d28..9c56e0e 100644 --- a/tests/api/stock.test.js +++ b/tests/api/stock.test.js @@ -20,6 +20,7 @@ const { listGroupedStockEntries, listKitchenChanges, listStockEntries, + markStockGone, lookupItemByIdentifier, lookupItemDetails, patchStockItem, @@ -84,7 +85,7 @@ describe('api/stock', () => { await listGroupedStockEntries( { config: { database: 'db' } }, - { expanded: 0, searchName: 'Rice', limit: 10, offset: 0 }, + { expanded: false, searchName: 'Rice', limit: 10, offset: 0 }, ); expect(apiRequestMock).toHaveBeenCalledWith( @@ -92,7 +93,7 @@ describe('api/stock', () => { 'kitchen/items/grouped', { query: { - expanded: 0, + expanded: false, search_name: 'Rice', limit: 10, offset: 0, @@ -108,7 +109,7 @@ describe('api/stock', () => { const response = await listGroupedStockEntries( { config: { database: 'db' } }, - { expanded: 1, searchName: 'Rice' }, + { expanded: true, searchName: 'Rice' }, ); expect(response).toHaveLength(101); @@ -116,13 +117,13 @@ describe('api/stock', () => { 1, { config: { database: 'db' } }, 'kitchen/items/grouped', - { query: { expanded: 1, search_name: 'Rice', limit: 100, offset: 0 } }, + { query: { expanded: true, search_name: 'Rice', limit: 100, offset: 0 } }, ); expect(apiRequestMock).toHaveBeenNthCalledWith( 2, { config: { database: 'db' } }, 'kitchen/items/grouped', - { query: { expanded: 1, search_name: 'Rice', limit: 100, offset: 100 } }, + { query: { expanded: true, search_name: 'Rice', limit: 100, offset: 100 } }, ); }); @@ -151,7 +152,7 @@ describe('api/stock', () => { expect(apiRequestMock).toHaveBeenCalledWith( { config: { database: 'db' } }, 'kitchen/items/item-2', - { query: { allow_inactive: 1 } }, + { query: { allow_inactive: true } }, ); }); @@ -297,7 +298,7 @@ describe('api/stock', () => { expect(apiRequestMock).toHaveBeenCalledWith( { config: { database: 'db' } }, 'kitchen/items/item-1/lookup', - { method: 'POST', query: { update: 1 } }, + { method: 'POST', query: { update: true } }, ); expect(response).toEqual({ status: 'ok', @@ -426,4 +427,30 @@ describe('api/stock', () => { }); expect(apiRequestMock).toHaveBeenCalledTimes(1); }); + + it('markStockGone uses /use endpoint for consumed reason', async () => { + apiRequestMock.mockResolvedValueOnce(null); + + const result = await markStockGone({ config: { database: 'db' } }, 'item-1', 'consumed'); + + expect(result).toEqual({ status: 'gone', reason: 'consumed' }); + expect(apiRequestMock).toHaveBeenCalledWith( + { config: { database: 'db' } }, + 'kitchen/items/item-1/use', + { method: 'POST' }, + ); + }); + + it('markStockGone uses /stock endpoint for non-consumed reasons', async () => { + apiRequestMock.mockResolvedValueOnce({ status: 'OK', stock: { id: 3 } }); + + const result = await markStockGone({ config: { database: 'db' } }, 'item-1', 'spoiled'); + + expect(result).toEqual({ status: 'gone', reason: 'spoiled' }); + expect(apiRequestMock).toHaveBeenCalledWith( + { config: { database: 'db' } }, + 'kitchen/items/item-1/stock', + { method: 'POST', body: { level: 'gone', gone_reason: 'spoiled' } }, + ); + }); }); diff --git a/tests/features/stock/mark-gone.test.js b/tests/features/stock/mark-gone.test.js index 25cf90c..2c289ed 100644 --- a/tests/features/stock/mark-gone.test.js +++ b/tests/features/stock/mark-gone.test.js @@ -130,6 +130,6 @@ describe('stock mark-gone behavior', () => { message: 'Beans was marked used and removed from the group.', }); expect(listGroupedStockEntriesMock).toHaveBeenCalledTimes(1); - expect(listGroupedStockEntriesMock).toHaveBeenCalledWith(store, { expanded: 0 }); + expect(listGroupedStockEntriesMock).toHaveBeenCalledWith(store, { expanded: false }); }); }); diff --git a/tests/features/stock/stock-list-page-grouped.test.js b/tests/features/stock/stock-list-page-grouped.test.js index 058c26d..cb0fa3e 100644 --- a/tests/features/stock/stock-list-page-grouped.test.js +++ b/tests/features/stock/stock-list-page-grouped.test.js @@ -7,6 +7,7 @@ const getStockEntryMock = vi.fn(); const updateStockItemMock = vi.fn(); const useStockItemMock = vi.fn(); const fetchLocationsMock = vi.fn(); +const listCategoriesMock = vi.fn(); vi.mock('../../../src/api/stock.js', () => ({ listStockEntries: (...args) => listStockEntriesMock(...args), @@ -21,6 +22,10 @@ vi.mock('../../../src/api/locations.js', () => ({ fetchLocations: (...args) => fetchLocationsMock(...args), })); +vi.mock('../../../src/api/categories.js', () => ({ + listCategories: (...args) => listCategoriesMock(...args), +})); + const { stockListPageData } = await import('../../../src/features/stock/stock-list-page.js'); function createGroupedSummary() { @@ -112,6 +117,8 @@ describe('stock list grouped-first behavior', () => { updateStockItemMock.mockReset(); useStockItemMock.mockReset(); fetchLocationsMock.mockReset(); + listCategoriesMock.mockReset(); + listCategoriesMock.mockResolvedValue([]); globalThis.window = createWindowMock(); globalThis.requestAnimationFrame = (callback) => callback(); @@ -133,6 +140,7 @@ describe('stock list grouped-first behavior', () => { listStockEntriesMock.mockResolvedValueOnce([]); listKitchenChangesMock.mockResolvedValue({ since: null, nextCursor: null, changes: [] }); fetchLocationsMock.mockResolvedValue({ flat: [], tree: [] }); + listCategoriesMock.mockResolvedValue([]); const store = { isConnected: true, addAlert: vi.fn() }; const data = stockListPageData(store); @@ -141,12 +149,12 @@ describe('stock list grouped-first behavior', () => { await data.init(); expect(data.viewMode).toBe('grouped'); - expect(listGroupedStockEntriesMock).toHaveBeenNthCalledWith(1, store, { expanded: 0 }); + expect(listGroupedStockEntriesMock).toHaveBeenNthCalledWith(1, store, { expanded: false }); expect(listStockEntriesMock).not.toHaveBeenCalled(); await Promise.resolve(); - expect(listGroupedStockEntriesMock).toHaveBeenNthCalledWith(2, store, { expanded: 1 }); + expect(listGroupedStockEntriesMock).toHaveBeenNthCalledWith(2, store, { expanded: true }); await data.switchView('items'); expect(listStockEntriesMock).toHaveBeenCalledTimes(1); @@ -162,7 +170,7 @@ describe('stock list grouped-first behavior', () => { .mockResolvedValueOnce(createGroupedExpanded()); const data = stockListPageData({ isConnected: true, addAlert: vi.fn() }); - await data.loadGroupedEntries({ expanded: 0, resetVisible: true }); + await data.loadGroupedEntries({ expanded: false, resetVisible: true }); expect(data.groupDisplayItems(data.groupedEntries[0])).toEqual([]); expect(data.hasGroupedChildStubs(data.groupedEntries[0])).toBe(true); @@ -226,7 +234,7 @@ describe('stock list grouped-first behavior', () => { data.groupedLoaded = true; data.groupedEntries = createGroupedSummary().map((group) => data.indexGroup(group)); - const pending = data.loadGroupedEntries({ expanded: 0, background: true }); + const pending = data.loadGroupedEntries({ expanded: false, background: true }); expect(data.state.isRefreshing).toBe(true); expect(data.groupedEntries).toHaveLength(1); @@ -357,8 +365,10 @@ describe('stock list grouped-first behavior', () => { await Promise.resolve(); await Promise.resolve(); - expect(listGroupedStockEntriesMock).not.toHaveBeenCalled(); - expect(listStockEntriesMock).not.toHaveBeenCalled(); + expect(listGroupedStockEntriesMock).toHaveBeenNthCalledWith(1, store, { expanded: false }); + expect(listGroupedStockEntriesMock).toHaveBeenNthCalledWith(2, store, { expanded: true }); + expect(listStockEntriesMock).toHaveBeenCalledTimes(1); + expect(listStockEntriesMock).toHaveBeenCalledWith(store); expect(fetchLocationsMock).not.toHaveBeenCalled(); expect(getStockEntryMock).toHaveBeenCalledWith(store, 'item-100'); expect(returnVisit.entries[0].quantity).toBe(2);