Refactor stock API to replace numeric flags with boolean values, add getItemLabel endpoint, and update tests/documentation
#10
@@ -163,30 +163,33 @@ Expected shapes today:
|
|||||||
Returns `{ data: [...] }` or `{ kitchens: [...] }`.
|
Returns `{ data: [...] }` or `{ kitchens: [...] }`.
|
||||||
- `GET /{database}/kitchen/items?search_name=...`
|
- `GET /{database}/kitchen/items?search_name=...`
|
||||||
Returns item definitions for autocomplete.
|
Returns item definitions for autocomplete.
|
||||||
|
Item payloads now expose category links via `categories` (array of IDs).
|
||||||
- `GET /{database}/kitchen/items`
|
- `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.
|
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`
|
- `GET /{database}/kitchen/items/grouped?expanded=true|false`
|
||||||
Returns grouped stock data; grouped review uses summary-first loading (`expanded=0`) and hydrates item children in background (`expanded=1`).
|
Returns grouped stock data; grouped review uses summary-first loading (`expanded=false`) and hydrates item children in background (`expanded=true`).
|
||||||
With `expanded=0`, child `items` can be ID-only stubs (`{ id }`) instead of full item payloads.
|
With `expanded=false`, child `items` can be ID-only stubs (`{ id }`) instead of full item payloads.
|
||||||
- `GET /{database}/kitchen/items/{uuid_b64}`
|
- `GET /{database}/kitchen/items/{uuid_b64}`
|
||||||
Returns one item detail payload.
|
Returns one item detail payload.
|
||||||
|
Supports `allow_inactive=true|false` query filtering when needed.
|
||||||
- `GET /{database}/kitchen/changes`
|
- `GET /{database}/kitchen/changes`
|
||||||
Returns `{ since, next_cursor, changes }` feed payload for item/stock updates.
|
Returns `{ since, next_cursor, changes }` feed payload for item/stock updates.
|
||||||
- `POST /{database}/kitchen/items/upsert?mode=preview|apply`
|
- `POST /{database}/kitchen/items/upsert?mode=preview|apply`
|
||||||
Used by label submit flow for create-or-update behavior and conflict-safe matching.
|
Used by label submit flow for create-or-update behavior and conflict-safe matching.
|
||||||
- `POST /{database}/kitchen/items/lookup`
|
- `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.
|
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`
|
- `POST /{database}/kitchen/items/{uuid_b64}/lookup?update=true|false`
|
||||||
Item-scoped OpenFoodFacts lookup used by stock detail to preview (`update=0`) or apply missing fields (`update=1`).
|
Item-scoped OpenFoodFacts lookup used by stock detail to preview (`update=false`) or apply missing fields (`update=true`).
|
||||||
- `POST /{database}/kitchen/items?label=1`
|
- `POST /{database}/kitchen/items?label=true&preview=true`
|
||||||
Used for label image preview rendering.
|
|
||||||
- `POST /{database}/kitchen/items?label=1&preview=1`
|
|
||||||
Returns an image blob, `{ imageUrl }`, or `{ imageSvg }` for in-browser preview.
|
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`
|
- `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.
|
Response shape is `{ status, stock }`; frontend re-fetches the item detail after successful update.
|
||||||
- `POST /{database}/kitchen/items/{uuid_b64}/use`
|
- `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`
|
- `POST /{database}/kitchen/items/{uuid_b64}/print-label`
|
||||||
Prints label for an existing item; called from the save flow when `Print` is enabled.
|
Prints label for an existing item; called from the save flow when `Print` is enabled.
|
||||||
- `PATCH /{database}/kitchen/items/{uuid_b64}`
|
- `PATCH /{database}/kitchen/items/{uuid_b64}`
|
||||||
|
|||||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "lonc-web",
|
"name": "lonc-web",
|
||||||
"version": "0.2.4",
|
"version": "0.2.6",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "lonc-web",
|
"name": "lonc-web",
|
||||||
"version": "0.2.4",
|
"version": "0.2.6",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@zxing/browser": "^0.1.5",
|
"@zxing/browser": "^0.1.5",
|
||||||
"alpinejs": "^3.14.9",
|
"alpinejs": "^3.14.9",
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "lonc-web",
|
"name": "lonc-web",
|
||||||
"version": "0.2.5",
|
"version": "0.2.6",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|||||||
+14
-1
@@ -42,7 +42,7 @@ export async function previewLabel(store, body) {
|
|||||||
method: 'POST',
|
method: 'POST',
|
||||||
body,
|
body,
|
||||||
accept: 'image/svg+xml, image/png, application/json',
|
accept: 'image/svg+xml, image/png, application/json',
|
||||||
query: { label: 1, preview: 1 },
|
query: { label: true, preview: true },
|
||||||
});
|
});
|
||||||
|
|
||||||
const image = normalizeLabelImagePayload(payload);
|
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.');
|
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) {
|
export async function printItemLabel(store, uuidB64) {
|
||||||
return apiRequest(store, `${getPath('items')}/${uuidB64}/print-label`, {
|
return apiRequest(store, `${getPath('items')}/${uuidB64}/print-label`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
|
|||||||
+43
-6
@@ -2,6 +2,35 @@ import { apiRequest, getPath } from './client.js';
|
|||||||
|
|
||||||
const DEFAULT_LIST_PAGE_LIMIT = 100;
|
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) {
|
function unwrapEntryPayload(payload) {
|
||||||
return payload?.data || payload?.entry || payload?.item || 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`, {
|
const payload = await apiRequest(store, `${getPath('items')}/grouped`, {
|
||||||
query: { search_name: query, expanded: 0 },
|
query: { search_name: query, expanded: false },
|
||||||
});
|
});
|
||||||
|
|
||||||
if (Array.isArray(payload)) {
|
if (Array.isArray(payload)) {
|
||||||
@@ -90,7 +119,7 @@ export async function listStockEntries(store, filters = {}) {
|
|||||||
|
|
||||||
export async function listGroupedStockEntries(store, options = {}) {
|
export async function listGroupedStockEntries(store, options = {}) {
|
||||||
const baseQuery = {};
|
const baseQuery = {};
|
||||||
const expanded = options.expanded ?? 1;
|
const expanded = toBooleanFlag(options.expanded, true);
|
||||||
baseQuery.expanded = expanded;
|
baseQuery.expanded = expanded;
|
||||||
const searchName = options.searchName || options.search_name;
|
const searchName = options.searchName || options.search_name;
|
||||||
if (searchName) {
|
if (searchName) {
|
||||||
@@ -119,7 +148,7 @@ export async function listGroupedStockEntries(store, options = {}) {
|
|||||||
export async function getStockEntry(store, stockId, { allowInactive = false } = {}) {
|
export async function getStockEntry(store, stockId, { allowInactive = false } = {}) {
|
||||||
const path = `${getPath('items')}/${stockId}`;
|
const path = `${getPath('items')}/${stockId}`;
|
||||||
const payload = allowInactive
|
const payload = allowInactive
|
||||||
? await apiRequest(store, path, { query: { allow_inactive: 1 } })
|
? await apiRequest(store, path, { query: { allow_inactive: true } })
|
||||||
: await apiRequest(store, path);
|
: await apiRequest(store, path);
|
||||||
return unwrapEntryPayload(payload);
|
return unwrapEntryPayload(payload);
|
||||||
}
|
}
|
||||||
@@ -128,7 +157,7 @@ export async function createStockEntry(store, body) {
|
|||||||
const payload = await apiRequest(store, getPath('items'), {
|
const payload = await apiRequest(store, getPath('items'), {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
body,
|
body,
|
||||||
query: { label: 1, print: 1 },
|
query: { label: true, print: true },
|
||||||
});
|
});
|
||||||
return unwrapEntryPayload(payload);
|
return unwrapEntryPayload(payload);
|
||||||
}
|
}
|
||||||
@@ -211,7 +240,7 @@ export async function lookupItemByIdentifier(store, identifierCode) {
|
|||||||
export async function lookupItemDetails(store, uuidB64, { update = false } = {}) {
|
export async function lookupItemDetails(store, uuidB64, { update = false } = {}) {
|
||||||
const payload = await apiRequest(store, `${getPath('items')}/${uuidB64}/lookup`, {
|
const payload = await apiRequest(store, `${getPath('items')}/${uuidB64}/lookup`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
query: { update: update ? 1 : 0 },
|
query: { update: toBooleanFlag(update, false) },
|
||||||
});
|
});
|
||||||
|
|
||||||
return normalizeItemLookupResponse(payload);
|
return normalizeItemLookupResponse(payload);
|
||||||
@@ -246,7 +275,7 @@ export async function createStockEvent(store, uuidB64, body) {
|
|||||||
export async function listStockEvents(store, uuidB64, options = {}) {
|
export async function listStockEvents(store, uuidB64, options = {}) {
|
||||||
const query = {};
|
const query = {};
|
||||||
if (options.allowInactive) {
|
if (options.allowInactive) {
|
||||||
query.allow_inactive = 1;
|
query.allow_inactive = true;
|
||||||
}
|
}
|
||||||
if (options.limit !== undefined && options.limit !== null) {
|
if (options.limit !== undefined && options.limit !== null) {
|
||||||
query.limit = options.limit;
|
query.limit = options.limit;
|
||||||
@@ -269,6 +298,14 @@ export async function listStockEvents(store, uuidB64, options = {}) {
|
|||||||
|
|
||||||
export async function markStockGone(store, uuidB64, reason = 'consumed') {
|
export async function markStockGone(store, uuidB64, reason = 'consumed') {
|
||||||
try {
|
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, {
|
await createStockEvent(store, uuidB64, {
|
||||||
level: 'gone',
|
level: 'gone',
|
||||||
gone_reason: reason,
|
gone_reason: reason,
|
||||||
|
|||||||
+1
-1
@@ -1,5 +1,5 @@
|
|||||||
export const APP_NAME = 'Lonc';
|
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 TRYTON_APPLICATION = 'kitchen';
|
||||||
|
|
||||||
export const CONNECTION_STATES = {
|
export const CONNECTION_STATES = {
|
||||||
|
|||||||
@@ -924,7 +924,7 @@ export function stockListPageData(store) {
|
|||||||
} else {
|
} else {
|
||||||
initTasks.push(
|
initTasks.push(
|
||||||
this.loadGroupedEntries({
|
this.loadGroupedEntries({
|
||||||
expanded: 0,
|
expanded: false,
|
||||||
resetVisible: !restoredContext,
|
resetVisible: !restoredContext,
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
@@ -1309,7 +1309,7 @@ export function stockListPageData(store) {
|
|||||||
|
|
||||||
if (mode === 'grouped') {
|
if (mode === 'grouped') {
|
||||||
if (!this.groupedLoaded) {
|
if (!this.groupedLoaded) {
|
||||||
await this.loadGroupedEntries({ expanded: 0, resetVisible: true });
|
await this.loadGroupedEntries({ expanded: false, resetVisible: true });
|
||||||
}
|
}
|
||||||
this.hydrateGroupedEntriesInBackground().catch(() => {});
|
this.hydrateGroupedEntriesInBackground().catch(() => {});
|
||||||
return;
|
return;
|
||||||
@@ -1325,7 +1325,7 @@ export function stockListPageData(store) {
|
|||||||
: this.itemsLoaded);
|
: this.itemsLoaded);
|
||||||
|
|
||||||
if (this.viewMode === 'grouped') {
|
if (this.viewMode === 'grouped') {
|
||||||
await this.loadGroupedEntries({ expanded: 0, background: useBackground });
|
await this.loadGroupedEntries({ expanded: false, background: useBackground });
|
||||||
this.hydrateGroupedEntriesInBackground({ force: true }).catch(() => {});
|
this.hydrateGroupedEntriesInBackground({ force: true }).catch(() => {});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -1417,7 +1417,7 @@ export function stockListPageData(store) {
|
|||||||
this.invalidateMemo();
|
this.invalidateMemo();
|
||||||
this.persistRuntimeCache();
|
this.persistRuntimeCache();
|
||||||
},
|
},
|
||||||
async loadGroupedEntries({ expanded = 1, background = false, resetVisible = false } = {}) {
|
async loadGroupedEntries({ expanded = true, background = false, resetVisible = false } = {}) {
|
||||||
if (!store.isConnected) {
|
if (!store.isConnected) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -1432,7 +1432,7 @@ export function stockListPageData(store) {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
const loadedGroups = await listGroupedStockEntries(store, { expanded });
|
const loadedGroups = await listGroupedStockEntries(store, { expanded });
|
||||||
if (expanded === 0) {
|
if (!expanded) {
|
||||||
this.applyGroupedSummary(loadedGroups, { resetVisible });
|
this.applyGroupedSummary(loadedGroups, { resetVisible });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -1461,7 +1461,7 @@ export function stockListPageData(store) {
|
|||||||
|
|
||||||
this.groupedHydrating = true;
|
this.groupedHydrating = true;
|
||||||
try {
|
try {
|
||||||
await this.loadGroupedEntries({ expanded: 1, background: true });
|
await this.loadGroupedEntries({ expanded: true, background: true });
|
||||||
} finally {
|
} finally {
|
||||||
this.groupedHydrating = false;
|
this.groupedHydrating = false;
|
||||||
}
|
}
|
||||||
@@ -1473,7 +1473,7 @@ export function stockListPageData(store) {
|
|||||||
}
|
}
|
||||||
if (this.groupedLoaded) {
|
if (this.groupedLoaded) {
|
||||||
tasks.push(
|
tasks.push(
|
||||||
this.loadGroupedEntries({ expanded: 0, background: true }).then(() =>
|
this.loadGroupedEntries({ expanded: false, background: true }).then(() =>
|
||||||
this.hydrateGroupedEntriesInBackground({ force: true }),
|
this.hydrateGroupedEntriesInBackground({ force: true }),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
@@ -2269,7 +2269,7 @@ export function stockListPageData(store) {
|
|||||||
? `${item.name} was already out of stock and removed from the group.`
|
? `${item.name} was already out of stock and removed from the group.`
|
||||||
: `${item.name} was ${actionLabel} 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) {
|
} catch (error) {
|
||||||
this.editErrors[item.id] = error.message || 'Removal failed.';
|
this.editErrors[item.id] = error.message || 'Removal failed.';
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -61,12 +61,12 @@ describe('api/client', () => {
|
|||||||
|
|
||||||
const url = buildKitchenApiUrl(store, 'kitchen/items/grouped', {
|
const url = buildKitchenApiUrl(store, 'kitchen/items/grouped', {
|
||||||
search_name: 'Milk + eggs',
|
search_name: 'Milk + eggs',
|
||||||
expanded: 1,
|
expanded: true,
|
||||||
ignored: '',
|
ignored: '',
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(url).toBe(
|
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',
|
name: 'Rice',
|
||||||
},
|
},
|
||||||
query: {
|
query: {
|
||||||
label: 1,
|
label: true,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(payload).toEqual({ ok: true });
|
expect(payload).toEqual({ ok: true });
|
||||||
|
|
||||||
const [url, request] = fetchSpy.mock.calls[0];
|
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.method).toBe('POST');
|
||||||
expect(request.body).toBe('{"name":"Rice"}');
|
expect(request.body).toBe('{"name":"Rice"}');
|
||||||
expect(request.headers.get('Accept')).toBe('application/json');
|
expect(request.headers.get('Accept')).toBe('application/json');
|
||||||
|
|||||||
@@ -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', () => {
|
describe('api/labels formatPrintErrorMessage', () => {
|
||||||
it('maps printer_unavailable payload to user-friendly message', () => {
|
it('maps printer_unavailable payload to user-friendly message', () => {
|
||||||
|
|||||||
+34
-7
@@ -20,6 +20,7 @@ const {
|
|||||||
listGroupedStockEntries,
|
listGroupedStockEntries,
|
||||||
listKitchenChanges,
|
listKitchenChanges,
|
||||||
listStockEntries,
|
listStockEntries,
|
||||||
|
markStockGone,
|
||||||
lookupItemByIdentifier,
|
lookupItemByIdentifier,
|
||||||
lookupItemDetails,
|
lookupItemDetails,
|
||||||
patchStockItem,
|
patchStockItem,
|
||||||
@@ -84,7 +85,7 @@ describe('api/stock', () => {
|
|||||||
|
|
||||||
await listGroupedStockEntries(
|
await listGroupedStockEntries(
|
||||||
{ config: { database: 'db' } },
|
{ config: { database: 'db' } },
|
||||||
{ expanded: 0, searchName: 'Rice', limit: 10, offset: 0 },
|
{ expanded: false, searchName: 'Rice', limit: 10, offset: 0 },
|
||||||
);
|
);
|
||||||
|
|
||||||
expect(apiRequestMock).toHaveBeenCalledWith(
|
expect(apiRequestMock).toHaveBeenCalledWith(
|
||||||
@@ -92,7 +93,7 @@ describe('api/stock', () => {
|
|||||||
'kitchen/items/grouped',
|
'kitchen/items/grouped',
|
||||||
{
|
{
|
||||||
query: {
|
query: {
|
||||||
expanded: 0,
|
expanded: false,
|
||||||
search_name: 'Rice',
|
search_name: 'Rice',
|
||||||
limit: 10,
|
limit: 10,
|
||||||
offset: 0,
|
offset: 0,
|
||||||
@@ -108,7 +109,7 @@ describe('api/stock', () => {
|
|||||||
|
|
||||||
const response = await listGroupedStockEntries(
|
const response = await listGroupedStockEntries(
|
||||||
{ config: { database: 'db' } },
|
{ config: { database: 'db' } },
|
||||||
{ expanded: 1, searchName: 'Rice' },
|
{ expanded: true, searchName: 'Rice' },
|
||||||
);
|
);
|
||||||
|
|
||||||
expect(response).toHaveLength(101);
|
expect(response).toHaveLength(101);
|
||||||
@@ -116,13 +117,13 @@ describe('api/stock', () => {
|
|||||||
1,
|
1,
|
||||||
{ config: { database: 'db' } },
|
{ config: { database: 'db' } },
|
||||||
'kitchen/items/grouped',
|
'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(
|
expect(apiRequestMock).toHaveBeenNthCalledWith(
|
||||||
2,
|
2,
|
||||||
{ config: { database: 'db' } },
|
{ config: { database: 'db' } },
|
||||||
'kitchen/items/grouped',
|
'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(
|
expect(apiRequestMock).toHaveBeenCalledWith(
|
||||||
{ config: { database: 'db' } },
|
{ config: { database: 'db' } },
|
||||||
'kitchen/items/item-2',
|
'kitchen/items/item-2',
|
||||||
{ query: { allow_inactive: 1 } },
|
{ query: { allow_inactive: true } },
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -297,7 +298,7 @@ describe('api/stock', () => {
|
|||||||
expect(apiRequestMock).toHaveBeenCalledWith(
|
expect(apiRequestMock).toHaveBeenCalledWith(
|
||||||
{ config: { database: 'db' } },
|
{ config: { database: 'db' } },
|
||||||
'kitchen/items/item-1/lookup',
|
'kitchen/items/item-1/lookup',
|
||||||
{ method: 'POST', query: { update: 1 } },
|
{ method: 'POST', query: { update: true } },
|
||||||
);
|
);
|
||||||
expect(response).toEqual({
|
expect(response).toEqual({
|
||||||
status: 'ok',
|
status: 'ok',
|
||||||
@@ -426,4 +427,30 @@ describe('api/stock', () => {
|
|||||||
});
|
});
|
||||||
expect(apiRequestMock).toHaveBeenCalledTimes(1);
|
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' } },
|
||||||
|
);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -130,6 +130,6 @@ describe('stock mark-gone behavior', () => {
|
|||||||
message: 'Beans was marked used and removed from the group.',
|
message: 'Beans was marked used and removed from the group.',
|
||||||
});
|
});
|
||||||
expect(listGroupedStockEntriesMock).toHaveBeenCalledTimes(1);
|
expect(listGroupedStockEntriesMock).toHaveBeenCalledTimes(1);
|
||||||
expect(listGroupedStockEntriesMock).toHaveBeenCalledWith(store, { expanded: 0 });
|
expect(listGroupedStockEntriesMock).toHaveBeenCalledWith(store, { expanded: false });
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -141,12 +141,12 @@ describe('stock list grouped-first behavior', () => {
|
|||||||
await data.init();
|
await data.init();
|
||||||
|
|
||||||
expect(data.viewMode).toBe('grouped');
|
expect(data.viewMode).toBe('grouped');
|
||||||
expect(listGroupedStockEntriesMock).toHaveBeenNthCalledWith(1, store, { expanded: 0 });
|
expect(listGroupedStockEntriesMock).toHaveBeenNthCalledWith(1, store, { expanded: false });
|
||||||
expect(listStockEntriesMock).not.toHaveBeenCalled();
|
expect(listStockEntriesMock).not.toHaveBeenCalled();
|
||||||
|
|
||||||
await Promise.resolve();
|
await Promise.resolve();
|
||||||
|
|
||||||
expect(listGroupedStockEntriesMock).toHaveBeenNthCalledWith(2, store, { expanded: 1 });
|
expect(listGroupedStockEntriesMock).toHaveBeenNthCalledWith(2, store, { expanded: true });
|
||||||
|
|
||||||
await data.switchView('items');
|
await data.switchView('items');
|
||||||
expect(listStockEntriesMock).toHaveBeenCalledTimes(1);
|
expect(listStockEntriesMock).toHaveBeenCalledTimes(1);
|
||||||
@@ -162,7 +162,7 @@ describe('stock list grouped-first behavior', () => {
|
|||||||
.mockResolvedValueOnce(createGroupedExpanded());
|
.mockResolvedValueOnce(createGroupedExpanded());
|
||||||
|
|
||||||
const data = stockListPageData({ isConnected: true, addAlert: vi.fn() });
|
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.groupDisplayItems(data.groupedEntries[0])).toEqual([]);
|
||||||
expect(data.hasGroupedChildStubs(data.groupedEntries[0])).toBe(true);
|
expect(data.hasGroupedChildStubs(data.groupedEntries[0])).toBe(true);
|
||||||
@@ -226,7 +226,7 @@ describe('stock list grouped-first behavior', () => {
|
|||||||
data.groupedLoaded = true;
|
data.groupedLoaded = true;
|
||||||
data.groupedEntries = createGroupedSummary().map((group) => data.indexGroup(group));
|
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.state.isRefreshing).toBe(true);
|
||||||
expect(data.groupedEntries).toHaveLength(1);
|
expect(data.groupedEntries).toHaveLength(1);
|
||||||
|
|||||||
Reference in New Issue
Block a user