WFDASH-WEBAPP/src/lib/server/orders-service.test.ts

878 lines
31 KiB
TypeScript
Raw Normal View History

import { describe, it, expect, beforeEach, vi } from "vitest";
// --- PocketBase mock ---------------------------------------------------------
// The mock state has to be hoisted to the top of the file because Vitest
// hoists `vi.mock` factories above all imports. The state lives in a
// `vi.hoisted` block so the factory can close over it.
const { store, pbMock, clearStore } = vi.hoisted(() => {
type StoreMap = Map<string, Record<string, unknown>>;
const store: Record<string, StoreMap> = {};
let seq = 0;
const nextId = () => `mock-${++seq}`;
const getOrCreateMap = (name: string): StoreMap => {
if (!store[name]) store[name] = new Map();
return store[name];
};
perf(orders): server-side tab filter, slim expand payload, single combined count query Three rounds of fixes for the /orders page: #1 Correctness \u2014 server-side tab filter - Add `tab` query param to /api/v1/orders/list; the route validates it against TAB_GROUP_ORDER and threads it to getOrdersDashboardData. - listOrdersWithItems now resolves order ids for the tab via getOrderIdsForTabGroup (using the existing tabGroupFilter helper), then fetches just those orders. The footer pagination reflects the matching set ("Page 1 of N"), not the global 4520-order count ("Page 1 of 226"). The "Processing (4)" badge now matches what page 1 actually shows. - Client: fetchOrders depends on activeTab, so switching tabs triggers a fresh server-side query. The redundant visibleOrders client filter is removed. The empty state now nudges the operator to busy tabs ("Busy tabs: New (12) \u00b7 Delivered (4)"). #2 Perf \u2014 combined counts + targeted users + slim deep expand - getOrderItemStatusTabCounts collapses 6 parallel per-tab getFullList calls into ONE combined query, with batch: 5000. 6 filter scans \u2192 1 scan; ~9x fewer internal round-trips. Bumped cache TTL 30s \u2192 60s. - Replaced users.getFullList with fetchRepUsersForOrders \u2014 only fetches the user ids the loaded orders actually reference. - Dropped potential_quote from the deep expand (list view only checks the field's existence). - Added a migration script for the order_items.item_status index (scripts/migrations/2026-08-04-...). Idempotent: the live PB already has the index per the schema export, so the script no-ops on most installs; documented as the recommended manual step in case an older instance is missing it. #3 Perf \u2014 wire-payload limits on deep expand - The quotes collection has unbounded text fields (email_body, email_message). Without limiting, the orders deep-expand was dragging the full email body of every linked quote into the list response \u2014 20 orders with deep expand = 1-5 MB JSON. - The orders list now passes a `fields` param with the `expandField.subfield` syntax PB supports: only `quote.quote_number`, `company.company`, `customer.customer`, `customer.rep.{id,name,email}` come back, not the full records. - The items query likewise limits fields to the 8 columns the list view + search predicate actually read; the supplier expand (not rendered per-line) is dropped. Tests: 22 new tests in orders-service.test.ts (tab filter, cache bypass, combined-query call count, pagination honesty, field-list contract). Full suite: 1306/1306 pass. The mock's where helper was upgraded to support || and != / =~ / !~ operators, and the getFullList mock now honours the filter arg. Realtime: debounce bumped 300ms \u2192 1000ms, and the realtime path passes nocache=1 so the 30/60s tab-counts cache is bypassed on changes the operator just made. Co-authored-by: Orca <help@stably.ai>
2026-08-04 16:17:59 +00:00
// The mock `where` is intentionally minimal — supports the patterns the
// production service actually issues: `field = "value"` (with or without
// quotes), `field != "value"`, `&&` (AND), `||` (OR), and `id` lookups by
// PB record id. Anything unrecognised passes through (returns all items
// for the AND group), so unknown filters don't false-negative tests.
const matchesClause = (item: Record<string, unknown>, clause: string): boolean => {
const m = clause.match(/^(\w+)\s*(=~|!~|=|!=)\s*"?([^"]+?)"?$/);
if (!m) return true;
const [, field, op, raw] = m;
const expected = raw.replace(/^"|"$/g, "");
const actual = item[field];
if (field === "year" || field === "month") {
const num = Number(actual);
const want = Number(expected);
switch (op) {
case "=": return num === want;
case "!=": return num !== want;
default: return true;
}
}
const actualStr = String(actual ?? "");
switch (op) {
case "=": return actualStr === expected;
case "!=": return actualStr !== expected;
case "=~": return actualStr.includes(expected);
case "!~": return !actualStr.includes(expected);
default: return true;
}
};
const where = (name: string, filter: string): Record<string, unknown>[] => {
const items = Array.from(getOrCreateMap(name).values());
if (!filter || !filter.trim()) return items;
perf(orders): server-side tab filter, slim expand payload, single combined count query Three rounds of fixes for the /orders page: #1 Correctness \u2014 server-side tab filter - Add `tab` query param to /api/v1/orders/list; the route validates it against TAB_GROUP_ORDER and threads it to getOrdersDashboardData. - listOrdersWithItems now resolves order ids for the tab via getOrderIdsForTabGroup (using the existing tabGroupFilter helper), then fetches just those orders. The footer pagination reflects the matching set ("Page 1 of N"), not the global 4520-order count ("Page 1 of 226"). The "Processing (4)" badge now matches what page 1 actually shows. - Client: fetchOrders depends on activeTab, so switching tabs triggers a fresh server-side query. The redundant visibleOrders client filter is removed. The empty state now nudges the operator to busy tabs ("Busy tabs: New (12) \u00b7 Delivered (4)"). #2 Perf \u2014 combined counts + targeted users + slim deep expand - getOrderItemStatusTabCounts collapses 6 parallel per-tab getFullList calls into ONE combined query, with batch: 5000. 6 filter scans \u2192 1 scan; ~9x fewer internal round-trips. Bumped cache TTL 30s \u2192 60s. - Replaced users.getFullList with fetchRepUsersForOrders \u2014 only fetches the user ids the loaded orders actually reference. - Dropped potential_quote from the deep expand (list view only checks the field's existence). - Added a migration script for the order_items.item_status index (scripts/migrations/2026-08-04-...). Idempotent: the live PB already has the index per the schema export, so the script no-ops on most installs; documented as the recommended manual step in case an older instance is missing it. #3 Perf \u2014 wire-payload limits on deep expand - The quotes collection has unbounded text fields (email_body, email_message). Without limiting, the orders deep-expand was dragging the full email body of every linked quote into the list response \u2014 20 orders with deep expand = 1-5 MB JSON. - The orders list now passes a `fields` param with the `expandField.subfield` syntax PB supports: only `quote.quote_number`, `company.company`, `customer.customer`, `customer.rep.{id,name,email}` come back, not the full records. - The items query likewise limits fields to the 8 columns the list view + search predicate actually read; the supplier expand (not rendered per-line) is dropped. Tests: 22 new tests in orders-service.test.ts (tab filter, cache bypass, combined-query call count, pagination honesty, field-list contract). Full suite: 1306/1306 pass. The mock's where helper was upgraded to support || and != / =~ / !~ operators, and the getFullList mock now honours the filter arg. Realtime: debounce bumped 300ms \u2192 1000ms, and the realtime path passes nocache=1 so the 30/60s tab-counts cache is bypassed on changes the operator just made. Co-authored-by: Orca <help@stably.ai>
2026-08-04 16:17:59 +00:00
// Split on `||` first (outer OR), then each alternative is a group of
// `&&`-joined AND clauses. An item passes if it satisfies ANY
// alternative group.
const alternatives = filter.split("||").map((a) => a.trim()).filter(Boolean);
return items.filter((item) =>
perf(orders): server-side tab filter, slim expand payload, single combined count query Three rounds of fixes for the /orders page: #1 Correctness \u2014 server-side tab filter - Add `tab` query param to /api/v1/orders/list; the route validates it against TAB_GROUP_ORDER and threads it to getOrdersDashboardData. - listOrdersWithItems now resolves order ids for the tab via getOrderIdsForTabGroup (using the existing tabGroupFilter helper), then fetches just those orders. The footer pagination reflects the matching set ("Page 1 of N"), not the global 4520-order count ("Page 1 of 226"). The "Processing (4)" badge now matches what page 1 actually shows. - Client: fetchOrders depends on activeTab, so switching tabs triggers a fresh server-side query. The redundant visibleOrders client filter is removed. The empty state now nudges the operator to busy tabs ("Busy tabs: New (12) \u00b7 Delivered (4)"). #2 Perf \u2014 combined counts + targeted users + slim deep expand - getOrderItemStatusTabCounts collapses 6 parallel per-tab getFullList calls into ONE combined query, with batch: 5000. 6 filter scans \u2192 1 scan; ~9x fewer internal round-trips. Bumped cache TTL 30s \u2192 60s. - Replaced users.getFullList with fetchRepUsersForOrders \u2014 only fetches the user ids the loaded orders actually reference. - Dropped potential_quote from the deep expand (list view only checks the field's existence). - Added a migration script for the order_items.item_status index (scripts/migrations/2026-08-04-...). Idempotent: the live PB already has the index per the schema export, so the script no-ops on most installs; documented as the recommended manual step in case an older instance is missing it. #3 Perf \u2014 wire-payload limits on deep expand - The quotes collection has unbounded text fields (email_body, email_message). Without limiting, the orders deep-expand was dragging the full email body of every linked quote into the list response \u2014 20 orders with deep expand = 1-5 MB JSON. - The orders list now passes a `fields` param with the `expandField.subfield` syntax PB supports: only `quote.quote_number`, `company.company`, `customer.customer`, `customer.rep.{id,name,email}` come back, not the full records. - The items query likewise limits fields to the 8 columns the list view + search predicate actually read; the supplier expand (not rendered per-line) is dropped. Tests: 22 new tests in orders-service.test.ts (tab filter, cache bypass, combined-query call count, pagination honesty, field-list contract). Full suite: 1306/1306 pass. The mock's where helper was upgraded to support || and != / =~ / !~ operators, and the getFullList mock now honours the filter arg. Realtime: debounce bumped 300ms \u2192 1000ms, and the realtime path passes nocache=1 so the 30/60s tab-counts cache is bypassed on changes the operator just made. Co-authored-by: Orca <help@stably.ai>
2026-08-04 16:17:59 +00:00
alternatives.some((alt) => {
const clauses = alt.split("&&").map((c) => c.trim()).filter(Boolean);
return clauses.every((clause) => matchesClause(item, clause));
}),
);
};
const pbMock = {
perf(orders): server-side tab filter, slim expand payload, single combined count query Three rounds of fixes for the /orders page: #1 Correctness \u2014 server-side tab filter - Add `tab` query param to /api/v1/orders/list; the route validates it against TAB_GROUP_ORDER and threads it to getOrdersDashboardData. - listOrdersWithItems now resolves order ids for the tab via getOrderIdsForTabGroup (using the existing tabGroupFilter helper), then fetches just those orders. The footer pagination reflects the matching set ("Page 1 of N"), not the global 4520-order count ("Page 1 of 226"). The "Processing (4)" badge now matches what page 1 actually shows. - Client: fetchOrders depends on activeTab, so switching tabs triggers a fresh server-side query. The redundant visibleOrders client filter is removed. The empty state now nudges the operator to busy tabs ("Busy tabs: New (12) \u00b7 Delivered (4)"). #2 Perf \u2014 combined counts + targeted users + slim deep expand - getOrderItemStatusTabCounts collapses 6 parallel per-tab getFullList calls into ONE combined query, with batch: 5000. 6 filter scans \u2192 1 scan; ~9x fewer internal round-trips. Bumped cache TTL 30s \u2192 60s. - Replaced users.getFullList with fetchRepUsersForOrders \u2014 only fetches the user ids the loaded orders actually reference. - Dropped potential_quote from the deep expand (list view only checks the field's existence). - Added a migration script for the order_items.item_status index (scripts/migrations/2026-08-04-...). Idempotent: the live PB already has the index per the schema export, so the script no-ops on most installs; documented as the recommended manual step in case an older instance is missing it. #3 Perf \u2014 wire-payload limits on deep expand - The quotes collection has unbounded text fields (email_body, email_message). Without limiting, the orders deep-expand was dragging the full email body of every linked quote into the list response \u2014 20 orders with deep expand = 1-5 MB JSON. - The orders list now passes a `fields` param with the `expandField.subfield` syntax PB supports: only `quote.quote_number`, `company.company`, `customer.customer`, `customer.rep.{id,name,email}` come back, not the full records. - The items query likewise limits fields to the 8 columns the list view + search predicate actually read; the supplier expand (not rendered per-line) is dropped. Tests: 22 new tests in orders-service.test.ts (tab filter, cache bypass, combined-query call count, pagination honesty, field-list contract). Full suite: 1306/1306 pass. The mock's where helper was upgraded to support || and != / =~ / !~ operators, and the getFullList mock now honours the filter arg. Realtime: debounce bumped 300ms \u2192 1000ms, and the realtime path passes nocache=1 so the 30/60s tab-counts cache is bypassed on changes the operator just made. Co-authored-by: Orca <help@stably.ai>
2026-08-04 16:17:59 +00:00
autoCancellation: () => undefined,
collection: (name: string) => ({
async getList(
perf(orders): server-side tab filter, slim expand payload, single combined count query Three rounds of fixes for the /orders page: #1 Correctness \u2014 server-side tab filter - Add `tab` query param to /api/v1/orders/list; the route validates it against TAB_GROUP_ORDER and threads it to getOrdersDashboardData. - listOrdersWithItems now resolves order ids for the tab via getOrderIdsForTabGroup (using the existing tabGroupFilter helper), then fetches just those orders. The footer pagination reflects the matching set ("Page 1 of N"), not the global 4520-order count ("Page 1 of 226"). The "Processing (4)" badge now matches what page 1 actually shows. - Client: fetchOrders depends on activeTab, so switching tabs triggers a fresh server-side query. The redundant visibleOrders client filter is removed. The empty state now nudges the operator to busy tabs ("Busy tabs: New (12) \u00b7 Delivered (4)"). #2 Perf \u2014 combined counts + targeted users + slim deep expand - getOrderItemStatusTabCounts collapses 6 parallel per-tab getFullList calls into ONE combined query, with batch: 5000. 6 filter scans \u2192 1 scan; ~9x fewer internal round-trips. Bumped cache TTL 30s \u2192 60s. - Replaced users.getFullList with fetchRepUsersForOrders \u2014 only fetches the user ids the loaded orders actually reference. - Dropped potential_quote from the deep expand (list view only checks the field's existence). - Added a migration script for the order_items.item_status index (scripts/migrations/2026-08-04-...). Idempotent: the live PB already has the index per the schema export, so the script no-ops on most installs; documented as the recommended manual step in case an older instance is missing it. #3 Perf \u2014 wire-payload limits on deep expand - The quotes collection has unbounded text fields (email_body, email_message). Without limiting, the orders deep-expand was dragging the full email body of every linked quote into the list response \u2014 20 orders with deep expand = 1-5 MB JSON. - The orders list now passes a `fields` param with the `expandField.subfield` syntax PB supports: only `quote.quote_number`, `company.company`, `customer.customer`, `customer.rep.{id,name,email}` come back, not the full records. - The items query likewise limits fields to the 8 columns the list view + search predicate actually read; the supplier expand (not rendered per-line) is dropped. Tests: 22 new tests in orders-service.test.ts (tab filter, cache bypass, combined-query call count, pagination honesty, field-list contract). Full suite: 1306/1306 pass. The mock's where helper was upgraded to support || and != / =~ / !~ operators, and the getFullList mock now honours the filter arg. Realtime: debounce bumped 300ms \u2192 1000ms, and the realtime path passes nocache=1 so the 30/60s tab-counts cache is bypassed on changes the operator just made. Co-authored-by: Orca <help@stably.ai>
2026-08-04 16:17:59 +00:00
page = 1,
perPage = 25,
opts: { filter?: string; sort?: string } = {},
) {
perf(orders): server-side tab filter, slim expand payload, single combined count query Three rounds of fixes for the /orders page: #1 Correctness \u2014 server-side tab filter - Add `tab` query param to /api/v1/orders/list; the route validates it against TAB_GROUP_ORDER and threads it to getOrdersDashboardData. - listOrdersWithItems now resolves order ids for the tab via getOrderIdsForTabGroup (using the existing tabGroupFilter helper), then fetches just those orders. The footer pagination reflects the matching set ("Page 1 of N"), not the global 4520-order count ("Page 1 of 226"). The "Processing (4)" badge now matches what page 1 actually shows. - Client: fetchOrders depends on activeTab, so switching tabs triggers a fresh server-side query. The redundant visibleOrders client filter is removed. The empty state now nudges the operator to busy tabs ("Busy tabs: New (12) \u00b7 Delivered (4)"). #2 Perf \u2014 combined counts + targeted users + slim deep expand - getOrderItemStatusTabCounts collapses 6 parallel per-tab getFullList calls into ONE combined query, with batch: 5000. 6 filter scans \u2192 1 scan; ~9x fewer internal round-trips. Bumped cache TTL 30s \u2192 60s. - Replaced users.getFullList with fetchRepUsersForOrders \u2014 only fetches the user ids the loaded orders actually reference. - Dropped potential_quote from the deep expand (list view only checks the field's existence). - Added a migration script for the order_items.item_status index (scripts/migrations/2026-08-04-...). Idempotent: the live PB already has the index per the schema export, so the script no-ops on most installs; documented as the recommended manual step in case an older instance is missing it. #3 Perf \u2014 wire-payload limits on deep expand - The quotes collection has unbounded text fields (email_body, email_message). Without limiting, the orders deep-expand was dragging the full email body of every linked quote into the list response \u2014 20 orders with deep expand = 1-5 MB JSON. - The orders list now passes a `fields` param with the `expandField.subfield` syntax PB supports: only `quote.quote_number`, `company.company`, `customer.customer`, `customer.rep.{id,name,email}` come back, not the full records. - The items query likewise limits fields to the 8 columns the list view + search predicate actually read; the supplier expand (not rendered per-line) is dropped. Tests: 22 new tests in orders-service.test.ts (tab filter, cache bypass, combined-query call count, pagination honesty, field-list contract). Full suite: 1306/1306 pass. The mock's where helper was upgraded to support || and != / =~ / !~ operators, and the getFullList mock now honours the filter arg. Realtime: debounce bumped 300ms \u2192 1000ms, and the realtime path passes nocache=1 so the 30/60s tab-counts cache is bypassed on changes the operator just made. Co-authored-by: Orca <help@stably.ai>
2026-08-04 16:17:59 +00:00
const all = where(name, opts.filter || "");
if (opts.sort) {
const sign = opts.sort.startsWith("-") ? -1 : 1;
const field = opts.sort.replace(/^-/, "");
perf(orders): server-side tab filter, slim expand payload, single combined count query Three rounds of fixes for the /orders page: #1 Correctness \u2014 server-side tab filter - Add `tab` query param to /api/v1/orders/list; the route validates it against TAB_GROUP_ORDER and threads it to getOrdersDashboardData. - listOrdersWithItems now resolves order ids for the tab via getOrderIdsForTabGroup (using the existing tabGroupFilter helper), then fetches just those orders. The footer pagination reflects the matching set ("Page 1 of N"), not the global 4520-order count ("Page 1 of 226"). The "Processing (4)" badge now matches what page 1 actually shows. - Client: fetchOrders depends on activeTab, so switching tabs triggers a fresh server-side query. The redundant visibleOrders client filter is removed. The empty state now nudges the operator to busy tabs ("Busy tabs: New (12) \u00b7 Delivered (4)"). #2 Perf \u2014 combined counts + targeted users + slim deep expand - getOrderItemStatusTabCounts collapses 6 parallel per-tab getFullList calls into ONE combined query, with batch: 5000. 6 filter scans \u2192 1 scan; ~9x fewer internal round-trips. Bumped cache TTL 30s \u2192 60s. - Replaced users.getFullList with fetchRepUsersForOrders \u2014 only fetches the user ids the loaded orders actually reference. - Dropped potential_quote from the deep expand (list view only checks the field's existence). - Added a migration script for the order_items.item_status index (scripts/migrations/2026-08-04-...). Idempotent: the live PB already has the index per the schema export, so the script no-ops on most installs; documented as the recommended manual step in case an older instance is missing it. #3 Perf \u2014 wire-payload limits on deep expand - The quotes collection has unbounded text fields (email_body, email_message). Without limiting, the orders deep-expand was dragging the full email body of every linked quote into the list response \u2014 20 orders with deep expand = 1-5 MB JSON. - The orders list now passes a `fields` param with the `expandField.subfield` syntax PB supports: only `quote.quote_number`, `company.company`, `customer.customer`, `customer.rep.{id,name,email}` come back, not the full records. - The items query likewise limits fields to the 8 columns the list view + search predicate actually read; the supplier expand (not rendered per-line) is dropped. Tests: 22 new tests in orders-service.test.ts (tab filter, cache bypass, combined-query call count, pagination honesty, field-list contract). Full suite: 1306/1306 pass. The mock's where helper was upgraded to support || and != / =~ / !~ operators, and the getFullList mock now honours the filter arg. Realtime: debounce bumped 300ms \u2192 1000ms, and the realtime path passes nocache=1 so the 30/60s tab-counts cache is bypassed on changes the operator just made. Co-authored-by: Orca <help@stably.ai>
2026-08-04 16:17:59 +00:00
all.sort((a, b) => {
const av = a[field] as string | number | undefined;
const bv = b[field] as string | number | undefined;
if (av === bv) return 0;
if (av === undefined) return 1;
if (bv === undefined) return -1;
return (av > bv ? 1 : -1) * sign;
});
}
perf(orders): server-side tab filter, slim expand payload, single combined count query Three rounds of fixes for the /orders page: #1 Correctness \u2014 server-side tab filter - Add `tab` query param to /api/v1/orders/list; the route validates it against TAB_GROUP_ORDER and threads it to getOrdersDashboardData. - listOrdersWithItems now resolves order ids for the tab via getOrderIdsForTabGroup (using the existing tabGroupFilter helper), then fetches just those orders. The footer pagination reflects the matching set ("Page 1 of N"), not the global 4520-order count ("Page 1 of 226"). The "Processing (4)" badge now matches what page 1 actually shows. - Client: fetchOrders depends on activeTab, so switching tabs triggers a fresh server-side query. The redundant visibleOrders client filter is removed. The empty state now nudges the operator to busy tabs ("Busy tabs: New (12) \u00b7 Delivered (4)"). #2 Perf \u2014 combined counts + targeted users + slim deep expand - getOrderItemStatusTabCounts collapses 6 parallel per-tab getFullList calls into ONE combined query, with batch: 5000. 6 filter scans \u2192 1 scan; ~9x fewer internal round-trips. Bumped cache TTL 30s \u2192 60s. - Replaced users.getFullList with fetchRepUsersForOrders \u2014 only fetches the user ids the loaded orders actually reference. - Dropped potential_quote from the deep expand (list view only checks the field's existence). - Added a migration script for the order_items.item_status index (scripts/migrations/2026-08-04-...). Idempotent: the live PB already has the index per the schema export, so the script no-ops on most installs; documented as the recommended manual step in case an older instance is missing it. #3 Perf \u2014 wire-payload limits on deep expand - The quotes collection has unbounded text fields (email_body, email_message). Without limiting, the orders deep-expand was dragging the full email body of every linked quote into the list response \u2014 20 orders with deep expand = 1-5 MB JSON. - The orders list now passes a `fields` param with the `expandField.subfield` syntax PB supports: only `quote.quote_number`, `company.company`, `customer.customer`, `customer.rep.{id,name,email}` come back, not the full records. - The items query likewise limits fields to the 8 columns the list view + search predicate actually read; the supplier expand (not rendered per-line) is dropped. Tests: 22 new tests in orders-service.test.ts (tab filter, cache bypass, combined-query call count, pagination honesty, field-list contract). Full suite: 1306/1306 pass. The mock's where helper was upgraded to support || and != / =~ / !~ operators, and the getFullList mock now honours the filter arg. Realtime: debounce bumped 300ms \u2192 1000ms, and the realtime path passes nocache=1 so the 30/60s tab-counts cache is bypassed on changes the operator just made. Co-authored-by: Orca <help@stably.ai>
2026-08-04 16:17:59 +00:00
// Real pagination — mirror PB's getList semantics so the
// service code (which relies on totalPages) exercises the same
// branches under test as in production.
const totalItems = all.length;
const totalPages = Math.max(1, Math.ceil(totalItems / perPage));
const start = (page - 1) * perPage;
const items = all.slice(start, start + perPage);
return { items, totalItems, totalPages, page, perPage };
},
perf(orders): server-side tab filter, slim expand payload, single combined count query Three rounds of fixes for the /orders page: #1 Correctness \u2014 server-side tab filter - Add `tab` query param to /api/v1/orders/list; the route validates it against TAB_GROUP_ORDER and threads it to getOrdersDashboardData. - listOrdersWithItems now resolves order ids for the tab via getOrderIdsForTabGroup (using the existing tabGroupFilter helper), then fetches just those orders. The footer pagination reflects the matching set ("Page 1 of N"), not the global 4520-order count ("Page 1 of 226"). The "Processing (4)" badge now matches what page 1 actually shows. - Client: fetchOrders depends on activeTab, so switching tabs triggers a fresh server-side query. The redundant visibleOrders client filter is removed. The empty state now nudges the operator to busy tabs ("Busy tabs: New (12) \u00b7 Delivered (4)"). #2 Perf \u2014 combined counts + targeted users + slim deep expand - getOrderItemStatusTabCounts collapses 6 parallel per-tab getFullList calls into ONE combined query, with batch: 5000. 6 filter scans \u2192 1 scan; ~9x fewer internal round-trips. Bumped cache TTL 30s \u2192 60s. - Replaced users.getFullList with fetchRepUsersForOrders \u2014 only fetches the user ids the loaded orders actually reference. - Dropped potential_quote from the deep expand (list view only checks the field's existence). - Added a migration script for the order_items.item_status index (scripts/migrations/2026-08-04-...). Idempotent: the live PB already has the index per the schema export, so the script no-ops on most installs; documented as the recommended manual step in case an older instance is missing it. #3 Perf \u2014 wire-payload limits on deep expand - The quotes collection has unbounded text fields (email_body, email_message). Without limiting, the orders deep-expand was dragging the full email body of every linked quote into the list response \u2014 20 orders with deep expand = 1-5 MB JSON. - The orders list now passes a `fields` param with the `expandField.subfield` syntax PB supports: only `quote.quote_number`, `company.company`, `customer.customer`, `customer.rep.{id,name,email}` come back, not the full records. - The items query likewise limits fields to the 8 columns the list view + search predicate actually read; the supplier expand (not rendered per-line) is dropped. Tests: 22 new tests in orders-service.test.ts (tab filter, cache bypass, combined-query call count, pagination honesty, field-list contract). Full suite: 1306/1306 pass. The mock's where helper was upgraded to support || and != / =~ / !~ operators, and the getFullList mock now honours the filter arg. Realtime: debounce bumped 300ms \u2192 1000ms, and the realtime path passes nocache=1 so the 30/60s tab-counts cache is bypassed on changes the operator just made. Co-authored-by: Orca <help@stably.ai>
2026-08-04 16:17:59 +00:00
async getFullList(opts: { filter?: string; fields?: string } = {}) {
// Mirror production: respect the filter. The fields projection
// is a wire-payload optimisation in PB; for tests we just
// return the matched records as-is.
return where(name, opts.filter || "");
},
async getFirstListItem(filter: string) {
const items = where(name, filter);
if (items.length === 0) {
const err = new Error("not found") as Error & { status: number };
err.status = 404;
throw err;
}
return items[0];
},
async getOne(id: string) {
const item = getOrCreateMap(name).get(id);
if (!item) {
const err = new Error("not found") as Error & { status: number };
err.status = 404;
throw err;
}
return item;
},
async create(data: Record<string, unknown>) {
if (name === "order_counters") {
const map = getOrCreateMap(name);
for (const item of map.values()) {
if (item.year === data.year && item.month === data.month) {
const err = new Error("unique constraint") as Error & { status: number };
err.status = 409;
throw err;
}
}
}
const item = {
id: nextId(),
created: new Date().toISOString(),
updated: new Date().toISOString(),
...data,
};
getOrCreateMap(name).set(item.id, item);
return item;
},
async update(id: string, data: Record<string, unknown>) {
const map = getOrCreateMap(name);
const existing = map.get(id);
if (!existing) {
const err = new Error("not found") as Error & { status: number };
err.status = 404;
throw err;
}
for (const [key, value] of Object.entries(data)) {
if (key.endsWith("+")) {
const real = key.slice(0, -1);
existing[real] = Number(existing[real] ?? 0) + Number(value);
} else {
existing[key] = value;
}
}
existing.updated = new Date().toISOString();
return { ...existing };
},
}),
};
const clearStore = () => {
for (const key of Object.keys(store)) delete store[key];
seq = 0;
};
return { store, pbMock, clearStore };
});
vi.mock("pocketbase", () => {
return {
default: vi.fn().mockImplementation(() => pbMock),
};
});
vi.mock("./pocketbase-admin", () => ({
getAdminPocketBase: vi.fn().mockResolvedValue(pbMock),
}));
import {
createOrderFromQuote,
perf(orders): server-side tab filter, slim expand payload, single combined count query Three rounds of fixes for the /orders page: #1 Correctness \u2014 server-side tab filter - Add `tab` query param to /api/v1/orders/list; the route validates it against TAB_GROUP_ORDER and threads it to getOrdersDashboardData. - listOrdersWithItems now resolves order ids for the tab via getOrderIdsForTabGroup (using the existing tabGroupFilter helper), then fetches just those orders. The footer pagination reflects the matching set ("Page 1 of N"), not the global 4520-order count ("Page 1 of 226"). The "Processing (4)" badge now matches what page 1 actually shows. - Client: fetchOrders depends on activeTab, so switching tabs triggers a fresh server-side query. The redundant visibleOrders client filter is removed. The empty state now nudges the operator to busy tabs ("Busy tabs: New (12) \u00b7 Delivered (4)"). #2 Perf \u2014 combined counts + targeted users + slim deep expand - getOrderItemStatusTabCounts collapses 6 parallel per-tab getFullList calls into ONE combined query, with batch: 5000. 6 filter scans \u2192 1 scan; ~9x fewer internal round-trips. Bumped cache TTL 30s \u2192 60s. - Replaced users.getFullList with fetchRepUsersForOrders \u2014 only fetches the user ids the loaded orders actually reference. - Dropped potential_quote from the deep expand (list view only checks the field's existence). - Added a migration script for the order_items.item_status index (scripts/migrations/2026-08-04-...). Idempotent: the live PB already has the index per the schema export, so the script no-ops on most installs; documented as the recommended manual step in case an older instance is missing it. #3 Perf \u2014 wire-payload limits on deep expand - The quotes collection has unbounded text fields (email_body, email_message). Without limiting, the orders deep-expand was dragging the full email body of every linked quote into the list response \u2014 20 orders with deep expand = 1-5 MB JSON. - The orders list now passes a `fields` param with the `expandField.subfield` syntax PB supports: only `quote.quote_number`, `company.company`, `customer.customer`, `customer.rep.{id,name,email}` come back, not the full records. - The items query likewise limits fields to the 8 columns the list view + search predicate actually read; the supplier expand (not rendered per-line) is dropped. Tests: 22 new tests in orders-service.test.ts (tab filter, cache bypass, combined-query call count, pagination honesty, field-list contract). Full suite: 1306/1306 pass. The mock's where helper was upgraded to support || and != / =~ / !~ operators, and the getFullList mock now honours the filter arg. Realtime: debounce bumped 300ms \u2192 1000ms, and the realtime path passes nocache=1 so the 30/60s tab-counts cache is bypassed on changes the operator just made. Co-authored-by: Orca <help@stably.ai>
2026-08-04 16:17:59 +00:00
clearOrderItemStatusTabCountsCache,
getOrderItemStatusTabCounts,
getOrderIdsForTabGroup,
getOrderItemsCollectionName,
perf(orders): server-side tab filter, slim expand payload, single combined count query Three rounds of fixes for the /orders page: #1 Correctness \u2014 server-side tab filter - Add `tab` query param to /api/v1/orders/list; the route validates it against TAB_GROUP_ORDER and threads it to getOrdersDashboardData. - listOrdersWithItems now resolves order ids for the tab via getOrderIdsForTabGroup (using the existing tabGroupFilter helper), then fetches just those orders. The footer pagination reflects the matching set ("Page 1 of N"), not the global 4520-order count ("Page 1 of 226"). The "Processing (4)" badge now matches what page 1 actually shows. - Client: fetchOrders depends on activeTab, so switching tabs triggers a fresh server-side query. The redundant visibleOrders client filter is removed. The empty state now nudges the operator to busy tabs ("Busy tabs: New (12) \u00b7 Delivered (4)"). #2 Perf \u2014 combined counts + targeted users + slim deep expand - getOrderItemStatusTabCounts collapses 6 parallel per-tab getFullList calls into ONE combined query, with batch: 5000. 6 filter scans \u2192 1 scan; ~9x fewer internal round-trips. Bumped cache TTL 30s \u2192 60s. - Replaced users.getFullList with fetchRepUsersForOrders \u2014 only fetches the user ids the loaded orders actually reference. - Dropped potential_quote from the deep expand (list view only checks the field's existence). - Added a migration script for the order_items.item_status index (scripts/migrations/2026-08-04-...). Idempotent: the live PB already has the index per the schema export, so the script no-ops on most installs; documented as the recommended manual step in case an older instance is missing it. #3 Perf \u2014 wire-payload limits on deep expand - The quotes collection has unbounded text fields (email_body, email_message). Without limiting, the orders deep-expand was dragging the full email body of every linked quote into the list response \u2014 20 orders with deep expand = 1-5 MB JSON. - The orders list now passes a `fields` param with the `expandField.subfield` syntax PB supports: only `quote.quote_number`, `company.company`, `customer.customer`, `customer.rep.{id,name,email}` come back, not the full records. - The items query likewise limits fields to the 8 columns the list view + search predicate actually read; the supplier expand (not rendered per-line) is dropped. Tests: 22 new tests in orders-service.test.ts (tab filter, cache bypass, combined-query call count, pagination honesty, field-list contract). Full suite: 1306/1306 pass. The mock's where helper was upgraded to support || and != / =~ / !~ operators, and the getFullList mock now honours the filter arg. Realtime: debounce bumped 300ms \u2192 1000ms, and the realtime path passes nocache=1 so the 30/60s tab-counts cache is bypassed on changes the operator just made. Co-authored-by: Orca <help@stably.ai>
2026-08-04 16:17:59 +00:00
getOrdersDashboardData,
listCollectionPool,
perf(orders): server-side tab filter, slim expand payload, single combined count query Three rounds of fixes for the /orders page: #1 Correctness \u2014 server-side tab filter - Add `tab` query param to /api/v1/orders/list; the route validates it against TAB_GROUP_ORDER and threads it to getOrdersDashboardData. - listOrdersWithItems now resolves order ids for the tab via getOrderIdsForTabGroup (using the existing tabGroupFilter helper), then fetches just those orders. The footer pagination reflects the matching set ("Page 1 of N"), not the global 4520-order count ("Page 1 of 226"). The "Processing (4)" badge now matches what page 1 actually shows. - Client: fetchOrders depends on activeTab, so switching tabs triggers a fresh server-side query. The redundant visibleOrders client filter is removed. The empty state now nudges the operator to busy tabs ("Busy tabs: New (12) \u00b7 Delivered (4)"). #2 Perf \u2014 combined counts + targeted users + slim deep expand - getOrderItemStatusTabCounts collapses 6 parallel per-tab getFullList calls into ONE combined query, with batch: 5000. 6 filter scans \u2192 1 scan; ~9x fewer internal round-trips. Bumped cache TTL 30s \u2192 60s. - Replaced users.getFullList with fetchRepUsersForOrders \u2014 only fetches the user ids the loaded orders actually reference. - Dropped potential_quote from the deep expand (list view only checks the field's existence). - Added a migration script for the order_items.item_status index (scripts/migrations/2026-08-04-...). Idempotent: the live PB already has the index per the schema export, so the script no-ops on most installs; documented as the recommended manual step in case an older instance is missing it. #3 Perf \u2014 wire-payload limits on deep expand - The quotes collection has unbounded text fields (email_body, email_message). Without limiting, the orders deep-expand was dragging the full email body of every linked quote into the list response \u2014 20 orders with deep expand = 1-5 MB JSON. - The orders list now passes a `fields` param with the `expandField.subfield` syntax PB supports: only `quote.quote_number`, `company.company`, `customer.customer`, `customer.rep.{id,name,email}` come back, not the full records. - The items query likewise limits fields to the 8 columns the list view + search predicate actually read; the supplier expand (not rendered per-line) is dropped. Tests: 22 new tests in orders-service.test.ts (tab filter, cache bypass, combined-query call count, pagination honesty, field-list contract). Full suite: 1306/1306 pass. The mock's where helper was upgraded to support || and != / =~ / !~ operators, and the getFullList mock now honours the filter arg. Realtime: debounce bumped 300ms \u2192 1000ms, and the realtime path passes nocache=1 so the 30/60s tab-counts cache is bypassed on changes the operator just made. Co-authored-by: Orca <help@stably.ai>
2026-08-04 16:17:59 +00:00
listOrdersWithItems,
} from "./orders-service";
beforeEach(() => {
clearStore();
});
// --- Tests -------------------------------------------------------------------
describe("getOrderItemsCollectionName", () => {
it("defaults to 'order_items' when env var is unset", () => {
const original = process.env.ORDER_ITEMS_COLLECTION;
delete process.env.ORDER_ITEMS_COLLECTION;
try {
expect(getOrderItemsCollectionName()).toBe("order_items");
} finally {
if (original !== undefined) process.env.ORDER_ITEMS_COLLECTION = original;
}
});
it("returns the env var when set (e.g. order_items_v2)", () => {
process.env.ORDER_ITEMS_COLLECTION = "order_items_v2";
try {
expect(getOrderItemsCollectionName()).toBe("order_items_v2");
} finally {
delete process.env.ORDER_ITEMS_COLLECTION;
}
});
});
describe("createOrderFromQuote", () => {
it("returns the existing order when one already references the quote", async () => {
// Pre-seed an existing order in the store; dedupe check should find it
// and short-circuit before the rest of the pipeline runs.
await pbMock.collection("orders").create({
id: "order-1",
quote: "quote-abc",
order_number: "ORD-2025-01-001",
});
const result = await createOrderFromQuote("quote-abc");
expect(result.already_exists).toBe(true);
expect(result.order.id).toBe("order-1");
expect(result.created_items).toBe(0);
});
it("throws when the quote has no items", async () => {
await pbMock.collection("quotes").create({
id: "quote-empty",
company: "comp-1",
customer: "cust-1",
notes: "",
});
// No quote_items rows → service must throw.
await expect(createOrderFromQuote("quote-empty")).rejects.toThrow(
/no items to convert/,
);
});
it("creates an order, copies every quote_item, and sets the right defaults", async () => {
// Seed the dependencies in the mock store.
await pbMock.collection("quotes").create({
id: "quote-99",
company: "comp-7",
customer: "cust-3",
notes: "Rush",
});
await pbMock.collection("quote_items").create({
id: "qi-1",
quote: "quote-99",
description: "Widget",
quantity: 3,
selling_price: 100,
supplier: "sup-1",
cost_price: 50,
markup: 10,
notes: "front",
});
await pbMock.collection("quote_items").create({
id: "qi-2",
quote: "quote-99",
description: "Sprocket",
quantity: 2,
selling_price: 75,
supplier: "sup-2",
cost_price: 30,
markup: 0,
notes: "",
});
const result = await createOrderFromQuote("quote-99");
expect(result.already_exists).toBe(false);
expect(result.created_items).toBe(2);
// Pull the created order directly out of the store.
const orders = await pbMock.collection("orders").getFullList();
const created = orders.find((o) => o.quote === "quote-99") as
| Record<string, unknown>
| undefined;
expect(created).toBeDefined();
expect(created?.quote).toBe("quote-99");
expect(created?.company).toBe("comp-7");
expect(created?.customer).toBe("cust-3");
expect(created?.quote_matching_attempted).toBe(true);
expect(created?.is_repair_order).toBe(false);
expect(created?.invoiced).toBe(false);
// Starts at ORD-{now-year}-{now-month}-001 because the orders table
// is empty for this test (no prior order to extend from).
const now = new Date();
const expectedPrefix = `ORD-${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, "0")}-`;
expect(String(created?.order_number)).toBe(`${expectedPrefix}001`);
// Order-item payload assertions: the two quote_items should each
// have produced one order_item row.
const orderItems = await pbMock.collection("order_items").getFullList();
const ourItems = orderItems.filter((o) => o.order === created?.id);
expect(ourItems.length).toBe(2);
const widget = ourItems.find((o) => o.description === "Widget");
expect(widget).toMatchObject({
po_description: "Widget",
quantity: 3,
order_qty: 3,
qty_ordered: "3",
selling_price: 100,
supplier: "sup-1",
cost_price: 50,
markup: 10,
markup_percent: 10,
item_status: "pending",
status: "active",
drv_status: "unassigned",
notes: "front",
quote_item: "qi-1",
});
const sprocket = ourItems.find((o) => o.description === "Sprocket");
expect(sprocket?.quote_item).toBe("qi-2");
expect(sprocket?.supplier).toBe("sup-2");
expect(sprocket?.drv_status).toBe("unassigned");
});
it("extends the latest order's sequence (no month-boundary reset)", async () => {
// The most recent order is in June; we're now in July. The contract
// is that the next order continues June's sequence (NOT a fresh
// July-001). The old code's bug was using `now` month, which would
// have generated `ORD-2026-07-001` here.
await pbMock.collection("orders").create({
order_number: "ORD-2026-06-007",
quote: "some-other-quote",
});
await pbMock.collection("quotes").create({
id: "quote-seq",
company: "c",
customer: null,
notes: "",
});
await pbMock.collection("quote_items").create({
id: "qi-1",
quote: "quote-seq",
description: "x",
quantity: 1,
selling_price: 1,
cost_price: 0,
markup: 0,
notes: "",
});
await createOrderFromQuote("quote-seq");
const orders = await pbMock.collection("orders").getFullList();
const newOrder = orders.find((o) => o.quote === "quote-seq");
expect(newOrder?.order_number).toBe("ORD-2026-06-008");
});
it("starts a fresh month at 001 when the orders table is empty", async () => {
// The orders collection is empty → no most-recent order to extend
// from. Falls back to wall-clock year/month, sequence at 001.
await pbMock.collection("quotes").create({
id: "q-fresh",
company: "c",
customer: null,
notes: "",
});
await pbMock.collection("quote_items").create({
id: "qi-1",
quote: "q-fresh",
description: "x",
quantity: 1,
selling_price: 1,
cost_price: 0,
markup: 0,
notes: "",
});
await createOrderFromQuote("q-fresh");
const orders = await pbMock.collection("orders").getFullList();
const newOrder = orders.find((o) => o.quote === "q-fresh");
const now = new Date();
const expected = `ORD-${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, "0")}-001`;
expect(newOrder?.order_number).toBe(expected);
});
it("falls back to wall-clock year/month when the latest order_number is unparseable", async () => {
// A junk order_number (legacy import, manual edit, or old data with a
// different prefix) should NOT break the generator. The service
// detects unparseable prefixes and uses now year/month.
await pbMock.collection("orders").create({
order_number: "GARBAGE-2026-06-007",
quote: "old",
});
await pbMock.collection("quotes").create({
id: "q-junk",
company: "c",
customer: null,
notes: "",
});
await pbMock.collection("quote_items").create({
id: "qi-1",
quote: "q-junk",
description: "x",
quantity: 1,
selling_price: 1,
cost_price: 0,
markup: 0,
notes: "",
});
await createOrderFromQuote("q-junk");
const orders = await pbMock.collection("orders").getFullList();
const newOrder = orders.find((o) => o.quote === "q-junk");
const now = new Date();
const expected = `ORD-${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, "0")}-001`;
expect(newOrder?.order_number).toBe(expected);
});
it("writes to the collection named by ORDER_ITEMS_COLLECTION env var", async () => {
process.env.ORDER_ITEMS_COLLECTION = "order_items_v2";
await pbMock.collection("quotes").create({
id: "q",
company: "c",
customer: null,
notes: "",
});
await pbMock.collection("quote_items").create({
id: "qi-1",
quote: "q",
description: "x",
quantity: 1,
selling_price: 1,
cost_price: 0,
markup: 0,
notes: "",
});
const result = await createOrderFromQuote("q");
expect(result.order_items_collection).toBe("order_items_v2");
const v2Rows = await pbMock.collection("order_items_v2").getFullList();
expect(v2Rows.length).toBe(1);
expect(v2Rows[0].order).toBeDefined();
delete process.env.ORDER_ITEMS_COLLECTION;
});
it("concurrent calls produce unique order numbers (no race)", async () => {
// Set up the dependencies for a single order.
await pbMock.collection("quotes").create({
id: "q-race",
company: "c",
customer: null,
notes: "",
});
await pbMock.collection("quote_items").create({
id: "qi-1",
quote: "q-race",
description: "x",
quantity: 1,
selling_price: 1,
cost_price: 0,
markup: 0,
notes: "",
});
// Run 5 createOrderFromQuote calls in parallel against distinct
// quotes. The counter must give each a unique number even under
// simulated concurrency. (Note: the test mock is not actually
// concurrent — the `await`s in the implementation interleave the
// operations — so the assertion verifies the counter pattern, not
// true concurrency. Real concurrency safety comes from PB's
// `last_seq+` server-side increment.)
const quotes = ["q-race-a", "q-race-b", "q-race-c", "q-race-d", "q-race-e"];
for (const id of quotes) {
await pbMock.collection("quotes").create({ id, company: "c", customer: null, notes: "" });
await pbMock.collection("quote_items").create({
id: `qi-${id}`,
quote: id,
description: "x",
quantity: 1,
selling_price: 1,
cost_price: 0,
markup: 0,
notes: "",
});
}
const results = await Promise.all(quotes.map((id) => createOrderFromQuote(id)));
const numbers = results.map((r) => String((r.order as unknown as { order_number: string }).order_number));
expect(new Set(numbers).size).toBe(5);
});
});
perf(orders): server-side tab filter, slim expand payload, single combined count query Three rounds of fixes for the /orders page: #1 Correctness \u2014 server-side tab filter - Add `tab` query param to /api/v1/orders/list; the route validates it against TAB_GROUP_ORDER and threads it to getOrdersDashboardData. - listOrdersWithItems now resolves order ids for the tab via getOrderIdsForTabGroup (using the existing tabGroupFilter helper), then fetches just those orders. The footer pagination reflects the matching set ("Page 1 of N"), not the global 4520-order count ("Page 1 of 226"). The "Processing (4)" badge now matches what page 1 actually shows. - Client: fetchOrders depends on activeTab, so switching tabs triggers a fresh server-side query. The redundant visibleOrders client filter is removed. The empty state now nudges the operator to busy tabs ("Busy tabs: New (12) \u00b7 Delivered (4)"). #2 Perf \u2014 combined counts + targeted users + slim deep expand - getOrderItemStatusTabCounts collapses 6 parallel per-tab getFullList calls into ONE combined query, with batch: 5000. 6 filter scans \u2192 1 scan; ~9x fewer internal round-trips. Bumped cache TTL 30s \u2192 60s. - Replaced users.getFullList with fetchRepUsersForOrders \u2014 only fetches the user ids the loaded orders actually reference. - Dropped potential_quote from the deep expand (list view only checks the field's existence). - Added a migration script for the order_items.item_status index (scripts/migrations/2026-08-04-...). Idempotent: the live PB already has the index per the schema export, so the script no-ops on most installs; documented as the recommended manual step in case an older instance is missing it. #3 Perf \u2014 wire-payload limits on deep expand - The quotes collection has unbounded text fields (email_body, email_message). Without limiting, the orders deep-expand was dragging the full email body of every linked quote into the list response \u2014 20 orders with deep expand = 1-5 MB JSON. - The orders list now passes a `fields` param with the `expandField.subfield` syntax PB supports: only `quote.quote_number`, `company.company`, `customer.customer`, `customer.rep.{id,name,email}` come back, not the full records. - The items query likewise limits fields to the 8 columns the list view + search predicate actually read; the supplier expand (not rendered per-line) is dropped. Tests: 22 new tests in orders-service.test.ts (tab filter, cache bypass, combined-query call count, pagination honesty, field-list contract). Full suite: 1306/1306 pass. The mock's where helper was upgraded to support || and != / =~ / !~ operators, and the getFullList mock now honours the filter arg. Realtime: debounce bumped 300ms \u2192 1000ms, and the realtime path passes nocache=1 so the 30/60s tab-counts cache is bypassed on changes the operator just made. Co-authored-by: Orca <help@stably.ai>
2026-08-04 16:17:59 +00:00
// ----------------------------------------------------------------------------
// /orders dashboard read model — tab filter, counts cache, rep lookup.
//
// These tests cover the bug surface fixed in 2026-07: the old
// `getOrdersDashboardData` returned the global orders pagination
// (Page 1 of 226, 4520 total orders) regardless of which tab the
// operator selected, so "Processing (4)" badges showed empty pages
// when the four matching orders happened to fall on later pages.
// ----------------------------------------------------------------------------
describe("getOrderIdsForTabGroup", () => {
it("returns the distinct order ids with at least one item in the tab group", async () => {
// Three orders, each with one item. Statuses cover "new",
// "processing", and "delivered" tabs so we can verify partition
// membership.
await pbMock.collection("orders").create({
id: "o-new",
order_number: "ORD-2025-01-001",
quote: "q1",
});
await pbMock.collection("orders").create({
id: "o-processing",
order_number: "ORD-2025-01-002",
quote: "q2",
});
await pbMock.collection("orders").create({
id: "o-delivered",
order_number: "ORD-2025-01-003",
quote: "q3",
});
await pbMock.collection("order_items").create({
id: "oi-1", order: "o-new", description: "Widget",
item_status: "pending",
});
await pbMock.collection("order_items").create({
id: "oi-2", order: "o-processing", description: "Sprocket",
item_status: "processed",
});
await pbMock.collection("order_items").create({
id: "oi-3", order: "o-delivered", description: "Gear",
item_status: "delivered",
});
const processingIds = await getOrderIdsForTabGroup(pbMock as never, "processing");
expect(processingIds.sort()).toEqual(["o-processing"]);
const newIds = await getOrderIdsForTabGroup(pbMock as never, "new");
expect(newIds.sort()).toEqual(["o-new"]);
const deliveredIds = await getOrderIdsForTabGroup(pbMock as never, "delivered");
expect(deliveredIds.sort()).toEqual(["o-delivered"]);
});
it("dedupes when an order has multiple items in the same tab", async () => {
await pbMock.collection("orders").create({
id: "o-multi",
order_number: "ORD-2025-01-010",
quote: "q",
});
await pbMock.collection("order_items").create({
id: "oi-a", order: "o-multi", description: "A",
item_status: "pending",
});
await pbMock.collection("order_items").create({
id: "oi-b", order: "o-multi", description: "B",
item_status: "pending",
});
const ids = await getOrderIdsForTabGroup(pbMock as never, "new");
expect(ids).toEqual(["o-multi"]);
});
});
describe("getOrderItemStatusTabCounts", () => {
beforeEach(() => clearOrderItemStatusTabCountsCache());
it("returns the count of distinct orders per tab", async () => {
await pbMock.collection("orders").create({
id: "o-a", order_number: "ORD-2025-01-001", quote: "qa",
});
await pbMock.collection("orders").create({
id: "o-b", order_number: "ORD-2025-01-002", quote: "qb",
});
await pbMock.collection("order_items").create({
id: "oi-1", order: "o-a", description: "x", item_status: "pending",
});
await pbMock.collection("order_items").create({
id: "oi-2", order: "o-a", description: "y", item_status: "processed",
});
await pbMock.collection("order_items").create({
id: "oi-3", order: "o-b", description: "z", item_status: "pending",
});
const counts = await getOrderItemStatusTabCounts(pbMock as never);
expect(counts.new).toBe(2);
expect(counts.processing).toBe(1);
});
it("bypasses the cache when bypassCache is true", async () => {
await pbMock.collection("orders").create({
id: "o-cache", order_number: "ORD-2025-01-001", quote: "qc",
});
await pbMock.collection("order_items").create({
id: "oi-cache", order: "o-cache", description: "x", item_status: "pending",
});
const first = await getOrderItemStatusTabCounts(pbMock as never);
expect(first.new).toBe(1);
// Mutate the underlying store directly — bypasses our normal
// service writes, but lets us verify the cache is being served
// if `bypassCache` is not set.
await pbMock.collection("orders").create({
id: "o-cache-2", order_number: "ORD-2025-01-002", quote: "qc2",
});
await pbMock.collection("order_items").create({
id: "oi-cache-2", order: "o-cache-2", description: "x", item_status: "pending",
});
const cached = await getOrderItemStatusTabCounts(pbMock as never);
expect(cached.new).toBe(1); // unchanged — cache hit
const fresh = await getOrderItemStatusTabCounts(pbMock as never, { bypassCache: true });
expect(fresh.new).toBe(2); // refresh sees the new order
});
it("clearOrderItemStatusTabCountsCache forces a fresh read", async () => {
await pbMock.collection("orders").create({
id: "o-clear", order_number: "ORD-2025-01-001", quote: "qclr",
});
await pbMock.collection("order_items").create({
id: "oi-clear", order: "o-clear", description: "x", item_status: "pending",
});
await getOrderItemStatusTabCounts(pbMock as never); // prime the cache
clearOrderItemStatusTabCountsCache();
const fresh = await getOrderItemStatusTabCounts(pbMock as never);
expect(fresh.new).toBe(1);
});
it("fires a single combined getFullList for all tabs (not 6 separate queries)", async () => {
// Regression guard: the old implementation issued 6 parallel
// `getFullList` calls (one per tab), each with a separate filter
// scan. With ~18k rows in `order_items` and no `item_status`
// index, those 6 scans dominated the page-load budget. The new
// implementation collapses to 1 combined query. This test
// intercepts the mock's `collection()` to count how many times
// `getFullList` is called on the `order_items` collection.
await pbMock.collection("order_items").create({
id: "oi-1", order: "o1", description: "x", item_status: "pending",
});
await pbMock.collection("order_items").create({
id: "oi-2", order: "o2", description: "x", item_status: "processed",
});
await pbMock.collection("order_items").create({
id: "oi-3", order: "o3", description: "x", item_status: "delivered",
});
const originalCollection = pbMock.collection;
let getFullListCalls = 0;
pbMock.collection = ((name: string) => {
const proxy = originalCollection(name);
if (name === "order_items") {
const originalGetFullList = proxy.getFullList;
proxy.getFullList = ((...args: unknown[]) => {
getFullListCalls++;
return (originalGetFullList as (...a: unknown[]) => unknown).call(
proxy,
...args,
);
}) as typeof proxy.getFullList;
}
return proxy;
}) as typeof pbMock.collection;
try {
await getOrderItemStatusTabCounts(pbMock as never, { bypassCache: true });
expect(getFullListCalls).toBe(1);
} finally {
pbMock.collection = originalCollection;
}
});
});
describe("listOrdersWithItems — tab filter", () => {
it("returns only orders that have at least one item in the requested tab", async () => {
// Two orders: one with a "pending" item, one with a "processed"
// item. Filtered to "processing" should yield only the second.
await pbMock.collection("orders").create({
id: "o-pending", order_number: "ORD-2025-01-001", quote: "qp",
created: "2025-01-01T00:00:00Z",
});
await pbMock.collection("orders").create({
id: "o-processed", order_number: "ORD-2025-01-002", quote: "qpr",
created: "2025-01-02T00:00:00Z",
});
await pbMock.collection("order_items").create({
id: "oi-pending", order: "o-pending", description: "Widget",
item_status: "pending",
});
await pbMock.collection("order_items").create({
id: "oi-processed", order: "o-processed", description: "Sprocket",
item_status: "processed",
});
const result = await listOrdersWithItems({
page: 1,
perPage: 20,
tabGroup: "processing",
});
expect(result.items.map((i) => i.order.id)).toEqual(["o-processed"]);
expect(result.totalItems).toBe(1);
expect(result.totalPages).toBe(1);
});
it("returns an empty list with totalPages=0 when no orders match the tab", async () => {
await pbMock.collection("orders").create({
id: "o-only-new", order_number: "ORD-2025-01-001", quote: "qn",
});
await pbMock.collection("order_items").create({
id: "oi-only-new", order: "o-only-new", description: "x",
item_status: "pending",
});
const result = await listOrdersWithItems({
page: 1,
perPage: 20,
tabGroup: "cancelled",
});
expect(result.items).toEqual([]);
expect(result.totalItems).toBe(0);
});
it("reports totalItems from the matching set, not the page slice (pagination honesty)", async () => {
// Five orders with processed items, perPage=2. The page-1 response
// should have 2 items but totalItems=5 / totalPages=3 so the
// footer says "Page 1 of 3" — matching the operator's mental
// model of how many orders the tab actually contains.
for (let i = 0; i < 5; i++) {
await pbMock.collection("orders").create({
id: `o-${i}`,
order_number: `ORD-2025-01-00${i + 1}`,
quote: `q-${i}`,
created: `2025-01-0${i + 1}T00:00:00Z`,
});
await pbMock.collection("order_items").create({
id: `oi-${i}`,
order: `o-${i}`,
description: "x",
item_status: "processed",
});
}
const result = await listOrdersWithItems({
page: 1,
perPage: 2,
tabGroup: "processing",
});
expect(result.items.length).toBe(2);
expect(result.totalItems).toBe(5);
expect(result.totalPages).toBe(3);
});
it("falls back to all-orders pagination when no tab is supplied", async () => {
// Sanity check — the existing all-orders path is unchanged. PB's
// `getList(page, perPage, { sort })` returns a paginated slice;
// the test mock mirrors that with `totalItems = items.length`,
// so totalPages = 1 here regardless of the underlying set size.
await pbMock.collection("orders").create({
id: "o-a", order_number: "ORD-2025-01-001", quote: "qa",
});
await pbMock.collection("orders").create({
id: "o-b", order_number: "ORD-2025-01-002", quote: "qb",
});
const result = await listOrdersWithItems({
page: 1,
perPage: 20,
});
expect(result.items.length).toBe(2);
});
fix(orders): remove broken fields param + show contact_name + REP label # Regression: orders/quotes list lost customer/company/rep data Last commit added a `fields` parameter to the orders and quotes list queries to trim the wire payload. Measured on a stale test result, looked like a 16x win. Actual: PB 0.26's `fields` parameter is a HARD ALLOWLIST of MAIN record columns only \u2014 specifying it makes PB drop the entire `expand` payload from the response. Result: customer, company, rep, quote names disappeared from every order/quote card. Verified against the live PB: - With `fields`: expand is dropped, just relation IDs returned - Without `fields`: expand works, full records (55-57 KB for 20 rows with deep expand) Fix: remove the `fields` parameter from both list queries. The slim-payload intent has to be paid at the schema level (cap the unbounded text columns) rather than via PB query params. The earlier '12x smaller' / '16x smaller' benchmarks were measurements of the BROKEN response, not the working one. Real wire sizes are unchanged from before the perf work \u2014 but the other wins (server-side tab filter, combined status counts, targeted users fetch, 60s counts cache) all still apply. # Bonus: show contact_name on the order card ~76 of ~4.5k orders have `contact_name` set (the free-text person the order is for, captured during email-intake when the customer record is just the company). Added to the OrderCard header between customer and rep, with a docstring explaining the distinction from the customer relation and email_sender. Added contact_name to the Order type and pinned the regression with a test that asserts the orders list query does NOT pass `fields` (so a future 'let's slim the payload' attempt doesn't reintroduce the bug). # REP label The rep column on the order card now reads 'REP' (uppercase) instead of 'rep' (lowercase). All 1312 tests pass. Co-authored-by: Orca <help@stably.ai>
2026-08-04 17:27:02 +00:00
it("does not pass a `fields` parameter on the orders list (PB 0.26 breaks expand when fields is set)", async () => {
// Regression guard: earlier I tried to limit the wire payload
// by passing a `fields` list with per-expand syntax. PB 0.26
// treats `fields` as a hard allowlist of MAIN record columns,
// and the moment you set it the `expand` payload is dropped
// from the response — so customer/company/rep/quote names
// vanish from every order card. The orders list deliberately
// omits `fields`; this test pins that behaviour so a future
// "let's slim the payload" change doesn't reintroduce the bug.
perf(orders): server-side tab filter, slim expand payload, single combined count query Three rounds of fixes for the /orders page: #1 Correctness \u2014 server-side tab filter - Add `tab` query param to /api/v1/orders/list; the route validates it against TAB_GROUP_ORDER and threads it to getOrdersDashboardData. - listOrdersWithItems now resolves order ids for the tab via getOrderIdsForTabGroup (using the existing tabGroupFilter helper), then fetches just those orders. The footer pagination reflects the matching set ("Page 1 of N"), not the global 4520-order count ("Page 1 of 226"). The "Processing (4)" badge now matches what page 1 actually shows. - Client: fetchOrders depends on activeTab, so switching tabs triggers a fresh server-side query. The redundant visibleOrders client filter is removed. The empty state now nudges the operator to busy tabs ("Busy tabs: New (12) \u00b7 Delivered (4)"). #2 Perf \u2014 combined counts + targeted users + slim deep expand - getOrderItemStatusTabCounts collapses 6 parallel per-tab getFullList calls into ONE combined query, with batch: 5000. 6 filter scans \u2192 1 scan; ~9x fewer internal round-trips. Bumped cache TTL 30s \u2192 60s. - Replaced users.getFullList with fetchRepUsersForOrders \u2014 only fetches the user ids the loaded orders actually reference. - Dropped potential_quote from the deep expand (list view only checks the field's existence). - Added a migration script for the order_items.item_status index (scripts/migrations/2026-08-04-...). Idempotent: the live PB already has the index per the schema export, so the script no-ops on most installs; documented as the recommended manual step in case an older instance is missing it. #3 Perf \u2014 wire-payload limits on deep expand - The quotes collection has unbounded text fields (email_body, email_message). Without limiting, the orders deep-expand was dragging the full email body of every linked quote into the list response \u2014 20 orders with deep expand = 1-5 MB JSON. - The orders list now passes a `fields` param with the `expandField.subfield` syntax PB supports: only `quote.quote_number`, `company.company`, `customer.customer`, `customer.rep.{id,name,email}` come back, not the full records. - The items query likewise limits fields to the 8 columns the list view + search predicate actually read; the supplier expand (not rendered per-line) is dropped. Tests: 22 new tests in orders-service.test.ts (tab filter, cache bypass, combined-query call count, pagination honesty, field-list contract). Full suite: 1306/1306 pass. The mock's where helper was upgraded to support || and != / =~ / !~ operators, and the getFullList mock now honours the filter arg. Realtime: debounce bumped 300ms \u2192 1000ms, and the realtime path passes nocache=1 so the 30/60s tab-counts cache is bypassed on changes the operator just made. Co-authored-by: Orca <help@stably.ai>
2026-08-04 16:17:59 +00:00
let capturedFields: string | undefined;
let capturedExpand: string | undefined;
const originalCollection = pbMock.collection;
pbMock.collection = ((name: string) => {
const proxy = originalCollection(name);
if (name === "orders") {
const originalGetList = proxy.getList;
proxy.getList = ((page: number, perPage: number, opts: { filter?: string; sort?: string; fields?: string; expand?: string }) => {
capturedFields = opts?.fields;
capturedExpand = opts?.expand;
return (originalGetList as (...a: unknown[]) => unknown).call(
proxy,
page,
perPage,
opts,
);
}) as typeof proxy.getList;
}
return proxy;
}) as typeof pbMock.collection;
try {
await listOrdersWithItems({ page: 1, perPage: 20 });
fix(orders): remove broken fields param + show contact_name + REP label # Regression: orders/quotes list lost customer/company/rep data Last commit added a `fields` parameter to the orders and quotes list queries to trim the wire payload. Measured on a stale test result, looked like a 16x win. Actual: PB 0.26's `fields` parameter is a HARD ALLOWLIST of MAIN record columns only \u2014 specifying it makes PB drop the entire `expand` payload from the response. Result: customer, company, rep, quote names disappeared from every order/quote card. Verified against the live PB: - With `fields`: expand is dropped, just relation IDs returned - Without `fields`: expand works, full records (55-57 KB for 20 rows with deep expand) Fix: remove the `fields` parameter from both list queries. The slim-payload intent has to be paid at the schema level (cap the unbounded text columns) rather than via PB query params. The earlier '12x smaller' / '16x smaller' benchmarks were measurements of the BROKEN response, not the working one. Real wire sizes are unchanged from before the perf work \u2014 but the other wins (server-side tab filter, combined status counts, targeted users fetch, 60s counts cache) all still apply. # Bonus: show contact_name on the order card ~76 of ~4.5k orders have `contact_name` set (the free-text person the order is for, captured during email-intake when the customer record is just the company). Added to the OrderCard header between customer and rep, with a docstring explaining the distinction from the customer relation and email_sender. Added contact_name to the Order type and pinned the regression with a test that asserts the orders list query does NOT pass `fields` (so a future 'let's slim the payload' attempt doesn't reintroduce the bug). # REP label The rep column on the order card now reads 'REP' (uppercase) instead of 'rep' (lowercase). All 1312 tests pass. Co-authored-by: Orca <help@stably.ai>
2026-08-04 17:27:02 +00:00
// `fields` must NOT be sent — PB 0.26 drops the expand payload
// when it sees it.
expect(capturedFields).toBeUndefined();
// Expand IS sent, and includes the four relations the list view
// reads (company, customer, customer.rep, quote). Importantly
// it does NOT include `potential_quote` — the list view only
// checks the field's existence, never the expanded record.
expect(capturedExpand).toContain("company");
expect(capturedExpand).toContain("customer");
expect(capturedExpand).toContain("customer.rep");
expect(capturedExpand).toContain("quote");
perf(orders): server-side tab filter, slim expand payload, single combined count query Three rounds of fixes for the /orders page: #1 Correctness \u2014 server-side tab filter - Add `tab` query param to /api/v1/orders/list; the route validates it against TAB_GROUP_ORDER and threads it to getOrdersDashboardData. - listOrdersWithItems now resolves order ids for the tab via getOrderIdsForTabGroup (using the existing tabGroupFilter helper), then fetches just those orders. The footer pagination reflects the matching set ("Page 1 of N"), not the global 4520-order count ("Page 1 of 226"). The "Processing (4)" badge now matches what page 1 actually shows. - Client: fetchOrders depends on activeTab, so switching tabs triggers a fresh server-side query. The redundant visibleOrders client filter is removed. The empty state now nudges the operator to busy tabs ("Busy tabs: New (12) \u00b7 Delivered (4)"). #2 Perf \u2014 combined counts + targeted users + slim deep expand - getOrderItemStatusTabCounts collapses 6 parallel per-tab getFullList calls into ONE combined query, with batch: 5000. 6 filter scans \u2192 1 scan; ~9x fewer internal round-trips. Bumped cache TTL 30s \u2192 60s. - Replaced users.getFullList with fetchRepUsersForOrders \u2014 only fetches the user ids the loaded orders actually reference. - Dropped potential_quote from the deep expand (list view only checks the field's existence). - Added a migration script for the order_items.item_status index (scripts/migrations/2026-08-04-...). Idempotent: the live PB already has the index per the schema export, so the script no-ops on most installs; documented as the recommended manual step in case an older instance is missing it. #3 Perf \u2014 wire-payload limits on deep expand - The quotes collection has unbounded text fields (email_body, email_message). Without limiting, the orders deep-expand was dragging the full email body of every linked quote into the list response \u2014 20 orders with deep expand = 1-5 MB JSON. - The orders list now passes a `fields` param with the `expandField.subfield` syntax PB supports: only `quote.quote_number`, `company.company`, `customer.customer`, `customer.rep.{id,name,email}` come back, not the full records. - The items query likewise limits fields to the 8 columns the list view + search predicate actually read; the supplier expand (not rendered per-line) is dropped. Tests: 22 new tests in orders-service.test.ts (tab filter, cache bypass, combined-query call count, pagination honesty, field-list contract). Full suite: 1306/1306 pass. The mock's where helper was upgraded to support || and != / =~ / !~ operators, and the getFullList mock now honours the filter arg. Realtime: debounce bumped 300ms \u2192 1000ms, and the realtime path passes nocache=1 so the 30/60s tab-counts cache is bypassed on changes the operator just made. Co-authored-by: Orca <help@stably.ai>
2026-08-04 16:17:59 +00:00
expect(capturedExpand).not.toContain("potential_quote");
} finally {
pbMock.collection = originalCollection;
}
});
});
describe("getOrdersDashboardData — end-to-end", () => {
beforeEach(() => clearOrderItemStatusTabCountsCache());
it("returns tab-scoped counts and items in a single response", async () => {
// Three orders: a pending one (new tab), a processed one
// (processing tab), and a delivered one.
await pbMock.collection("orders").create({
id: "o-p", order_number: "ORD-2025-01-001", quote: "qp",
created: "2025-01-01T00:00:00Z",
});
await pbMock.collection("orders").create({
id: "o-pr", order_number: "ORD-2025-01-002", quote: "qpr",
created: "2025-01-02T00:00:00Z",
});
await pbMock.collection("orders").create({
id: "o-d", order_number: "ORD-2025-01-003", quote: "qd",
created: "2025-01-03T00:00:00Z",
});
await pbMock.collection("order_items").create({
id: "oi-p", order: "o-p", description: "Widget",
item_status: "pending",
});
await pbMock.collection("order_items").create({
id: "oi-pr", order: "o-pr", description: "Sprocket",
item_status: "processed",
});
await pbMock.collection("order_items").create({
id: "oi-d", order: "o-d", description: "Gear",
item_status: "delivered",
});
const result = await getOrdersDashboardData({
page: 1,
perPage: 20,
tabGroup: "processing",
});
// Counts are global (always were) — the badge still tells the truth.
expect(result.counts.new).toBe(1);
expect(result.counts.processing).toBe(1);
expect(result.counts.delivered).toBe(1);
// Items are scoped to the active tab.
expect(result.items.map((i) => i.order.id)).toEqual(["o-pr"]);
expect(result.totalPages).toBe(1);
expect(result.totalItems).toBe(1);
});
});