Files
accounted/extensions/general/woocommerce/__tests__/order-sync.test.ts
T
MattssonandClaude Fable 5 c35b2547fb feat(webshop-orders): Orders page with per-store, per-payment-method booking (#1525)
* feat(webshop-orders): schema, types and error codes for the orders surface

webshop_orders (order/refund rows, financial-freeze trigger, member
select/update RLS, no DELETE) + webshop_store_settings (per-store payment
method -> account map), source_type 'webshop_order', multi-store index drop,
customer_country, and a one-time woo cursor reset so the switch-over
backfills and cross-marks existing feed rows. Tables classified in the
full-archive export; pg-real coverage for RLS, freeze and CHECK.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(webshop-orders): core service (ingest, booking lines)

upsertWebshopOrders: two-phase order/refund upsert with FX enrichment,
legacy-feed cross-marking, frozen-row protection and field-wise jsonb
comparisons (Postgres does not preserve object key order). Booking-line
builder: per-rate VAT split with SIGNED buckets (discounts book as revenue
reductions), refund mirroring, 3740 residual, per-store account prefill,
and advisory export/EU + OSS warnings.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(webshop-orders): API routes for list, booking, invoicing and mapping

Booking is draft -> atomic claim -> commit (conditional link-back closes the
concurrent double-book race; a lost claim cancels the voucher-free draft).
Legacy-feed guard honors transactions.is_ignored on both the book and
create-invoice paths. Invoice conversion reuses buildInvoiceWriteData for an
unnumbered draft with dominant-rate fallback and drift-safe unit prices.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(webshop-orders): Orders page, booking/invoice dialogs and gated nav

/orders lists per-store orders with status tabs (server-side filters),
exception chips and one action per row. Booking dialog prefills from the
per-store payment-method mapping with an opt-in remember; invoice dialog
converts to a draft kundfaktura. The Order nav item renders only for
companies with an active WooCommerce connection or existing order rows
(Shopify deliberately excluded until its sync writes webshop_orders).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(woocommerce): switch the order sync to webshop_orders, multi-store

The sync maps rich wc/v3 payloads (billing, line/shipping/fee taxes, refund
allocations with parent-prorated VAT fallback) and upserts order rows
instead of transactions-inbox rows; already-imported feed rows stay
bookable and get cross-marked. Multi-store: several active connections per
company, per-store panel cards with the account-mapping editor.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* docs(webshop-orders): decision log entries and ratchet baseline

Baseline moves DOWN only: naive-ore-round 638 -> 637 via roundOre adoption;
hand-rolled invariants stay at 115 (ACCOUNT_NUMBER_RE imported, not inlined).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(webshop-orders): resolve PR #1525 review findings and CI failures

Review batch (Superagent, CodeRabbit, Swedish compliance review):
- Mutual-exclusion claims: booking guards invoice_id, invoice link-back
  guards journal_entry_id AND treats zero matched rows as the conflict it
  is (409 + rollback), closing both TOCTOU races.
- Freeze v2 migration (20260812124858): the link columns themselves are
  protected: invoice links immutable, journal links clearable only while
  the entry is still a draft (the booking rollback path).
- Scraped orgnr no longer auto-written to customers.org_number; rate
  fallback applies only on single-VAT-bucket orders; refunds get their own
  WEBSHOP_ORDER_REFUND_NOT_CONVERTIBLE code; VAT advisories outrank the
  invoice-mode hint in the booking dialog.
- Ingest compares every synced field (billing corrections no longer drop
  as unchanged); sync guards absent refunds arrays; /sync aggregates
  per-store results; panel disables all cards while a request runs; orders
  page separates load failure from empty; account field explains itself.

CI: regenerated skills/accounted-api; pg tests restructured for
transaction-abort/rollback semantics + freeze-link coverage; unresolvable-
expression ceiling 375 -> 378 with documented reason (partial-update
payloads in ingest, shapes covered by unit tests).

Declined: CodeRabbit docstring-coverage advisory (house style: comments
only where the code cannot say it).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-12 15:08:13 +02:00

690 lines
24 KiB
TypeScript

import { describe, it, expect, vi, beforeEach } from 'vitest'
import type { SupabaseClient } from '@supabase/supabase-js'
const listOrdersPage = vi.fn()
const listOrderRefunds = vi.fn()
vi.mock('../lib/api-client', () => ({
listOrdersPage: (...args: unknown[]) => listOrdersPage(...args),
listOrderRefunds: (...args: unknown[]) => listOrderRefunds(...args),
isRevokedCredentialsError: (error: unknown) =>
error instanceof Error && error.message === 'REVOKED',
WC_PAGE_SIZE: 100,
}))
vi.mock('@/lib/webshop-orders/ingest', () => ({
upsertWebshopOrders: vi.fn(),
}))
import { upsertWebshopOrders } from '@/lib/webshop-orders/ingest'
import type { WebshopOrderUpsert } from '@/lib/webshop-orders/types'
import { encryptCredential } from '../lib/credentials'
import {
WOOCOMMERCE_IMPORT_SOURCE,
buildRefundVatBreakdown,
buildVatBreakdown,
extractOrgnr,
mapOrderToWebshopRow,
mapRefundToWebshopRow,
orderImports,
orderIsPaid,
syncWooCommerceOrders,
wooOrderExternalId,
wooRefundExternalId,
wooStoreScope,
} from '../lib/order-sync'
import type { WooCommerceConnection, WooOrder, WooRefund } from '../types'
process.env.WOOCOMMERCE_CREDENTIALS_ENCRYPTION_KEY = 'test-key'
const emptyUpsertResult = {
inserted: 0,
updated: 0,
unchanged: 0,
frozenFlagged: 0,
crossMarked: 0,
errors: 0,
}
function makeConnection(overrides: Partial<WooCommerceConnection> = {}): WooCommerceConnection {
return {
id: 'conn-1',
company_id: 'company-1',
user_id: 'user-1',
store_url: 'https://shop.example.se',
store_name: 'Testbutiken',
consumer_key_encrypted: encryptCredential('ck_test'),
consumer_secret_encrypted: encryptCredential('cs_test'),
key_permissions: 'read',
status: 'active',
oauth_state: null,
currency: 'SEK',
prices_include_tax: true,
wc_version: '9.9.5',
transaction_sync_enabled: true,
last_order_synced_at: null,
error_message: null,
connected_at: '2026-07-01T00:00:00.000Z',
disconnected_at: null,
created_at: '2026-07-01T00:00:00.000Z',
updated_at: '2026-07-01T00:00:00.000Z',
...overrides,
}
}
function makeOrder(overrides: Partial<WooOrder> = {}): WooOrder {
return {
id: 1042,
number: '1042',
status: 'processing',
currency: 'sek',
total: '1250.00',
total_tax: '250.00',
prices_include_tax: true,
date_created_gmt: '2026-08-01T09:00:00',
date_modified_gmt: '2026-08-01T09:05:00',
date_paid_gmt: '2026-08-01T09:04:30',
payment_method: 'stripe',
payment_method_title: 'Kortbetalning',
transaction_id: 'pi_abc123',
refunds: [],
billing: {
first_name: 'Test',
last_name: 'Person',
company: 'Testbolaget AB',
email: 'kund@example.se',
},
line_items: [
{
id: 1,
name: 'Produkt A',
quantity: 2,
total: '1000.00',
total_tax: '250.00',
taxes: [{ id: 3, total: '250.00' }],
},
],
tax_lines: [
{ rate_id: 3, rate_percent: 25, label: 'Moms 25%', tax_total: '250.00', shipping_tax_total: '0.00' },
],
shipping_lines: [],
meta_data: [],
...overrides,
}
}
/** Minimal chainable supabase mock covering the sync's query patterns. */
function makeSupabaseMock() {
const updates: Array<{ table: string; values: Record<string, unknown> }> = []
const client = {
from(table: string) {
const builder = {
select: () => builder,
eq: () => builder,
maybeSingle: async () => ({ data: null, error: null }),
update: (values: Record<string, unknown>) => {
updates.push({ table, values })
return builder
},
}
return builder
},
}
return { client: client as unknown as SupabaseClient, updates }
}
function cursorUpdates(updates: Array<{ table: string; values: Record<string, unknown> }>) {
return updates.filter(
(u) => u.table === 'woocommerce_connections' && 'last_order_synced_at' in u.values,
)
}
beforeEach(() => {
vi.clearAllMocks()
// Termination is an empty page; every test starts from a quiet store and
// enqueues its pages with mockResolvedValueOnce.
listOrdersPage.mockResolvedValue([])
listOrderRefunds.mockResolvedValue([])
vi.mocked(upsertWebshopOrders).mockResolvedValue({ ...emptyUpsertResult })
})
describe('frozen external_id formats', () => {
// ⚠️ These assert the exact persisted formats, now shared between
// webshop_orders.external_id and the legacy transactions rows the
// cross-mark joins against. If this test fails, you are about to orphan
// every previously imported WooCommerce row AND break the legacy overlap
// join: do not update the expectation without a coordinated backfill.
it('order id format is frozen', () => {
expect(wooOrderExternalId('shop.example.se', 1042)).toBe(
'woo_shop.example.se_order_1042',
)
})
it('refund id format is frozen', () => {
expect(wooRefundExternalId('shop.example.se', 77)).toBe(
'woo_shop.example.se_refund_77',
)
})
it('store scope strips exactly the https prefix and keeps host + path', () => {
expect(wooStoreScope('https://shop.example.se')).toBe('shop.example.se')
expect(wooStoreScope('https://example.se/butik')).toBe('example.se/butik')
})
it('legacy import source constant is frozen', () => {
expect(WOOCOMMERCE_IMPORT_SOURCE).toBe('woocommerce')
})
})
describe('orderImports / orderIsPaid', () => {
it('every non-trash status imports, paid or not', () => {
expect(orderImports(makeOrder())).toBe(true)
expect(orderImports(makeOrder({ status: 'pending', date_paid_gmt: null }))).toBe(true)
expect(orderImports(makeOrder({ status: 'refunded' }))).toBe(true)
expect(orderImports(makeOrder({ status: 'trash' }))).toBe(false)
})
it('is_paid follows date_paid', () => {
expect(orderIsPaid(makeOrder())).toBe(true)
expect(orderIsPaid(makeOrder({ date_paid_gmt: null }))).toBe(false)
})
})
describe('mapOrderToWebshopRow', () => {
const connection = { id: 'conn-1', store_name: 'Testbutiken' }
it('maps the full booking underlag', () => {
const rows = mapOrderToWebshopRow(connection, 'shop.example.se', makeOrder())
expect(rows).toHaveLength(1)
expect(rows[0]).toMatchObject({
platform: 'woocommerce',
store_scope: 'shop.example.se',
store_label: 'Testbutiken',
connection_id: 'conn-1',
row_type: 'order',
external_id: 'woo_shop.example.se_order_1042',
platform_order_id: '1042',
order_number: '1042',
status: 'processing',
is_paid: true,
order_date: '2026-08-01',
paid_date: '2026-08-01',
currency: 'SEK',
total: 1250,
total_tax: 250,
customer_name: 'Test Person',
customer_company: 'Testbolaget AB',
customer_email: 'kund@example.se',
payment_method: 'stripe',
payment_method_title: 'Kortbetalning',
gateway_reference: 'pi_abc123',
})
expect(rows[0].vat_breakdown).toEqual([{ rate: 25, net: 1000, tax: 250 }])
expect(rows[0].line_items).toEqual([
{ name: 'Produkt A', quantity: 2, total: 1000, total_tax: 250, vat_rate: 25 },
])
})
it('imports unpaid orders with is_paid false and no paid_date', () => {
const rows = mapOrderToWebshopRow(
connection,
's',
makeOrder({ status: 'pending', date_paid_gmt: null }),
)
expect(rows).toHaveLength(1)
expect(rows[0]).toMatchObject({ is_paid: false, paid_date: null, order_date: '2026-08-01' })
})
it('skips trashed, unparseable-total and zero-total orders', () => {
expect(mapOrderToWebshopRow(connection, 's', makeOrder({ status: 'trash' }))).toEqual([])
expect(
mapOrderToWebshopRow(connection, 's', makeOrder({ total: 'not-a-number' })),
).toEqual([])
// 100% coupon order: paid but zero money; importing it would strand an
// unbookable row (the engine refuses zero-sum entries).
expect(mapOrderToWebshopRow(connection, 's', makeOrder({ total: '0.00' }))).toEqual([])
})
it('captures the billing country uppercased', () => {
const rows = mapOrderToWebshopRow(
connection,
's',
makeOrder({ billing: { first_name: 'A', last_name: 'B', country: 'dk' } }),
)
expect(rows[0].customer_country).toBe('DK')
})
it('snapshots shipping and fee lines so the invoice conversion covers order.total', () => {
const rows = mapOrderToWebshopRow(
connection,
's',
makeOrder({
shipping_lines: [
{ method_title: 'Postnord', total: '80.00', total_tax: '20.00', taxes: [{ id: 3, total: '20.00' }] },
],
fee_lines: [
{ name: 'Fakturaavgift', total: '25.00', total_tax: '6.25', taxes: [{ id: 3, total: '6.25' }] },
],
}),
)
expect(rows[0].line_items.map((i) => i.name)).toEqual([
'Produkt A',
'Postnord',
'Fakturaavgift',
])
expect(rows[0].line_items[1]).toMatchObject({ total: 80, total_tax: 20, vat_rate: 25 })
expect(rows[0].line_items[2]).toMatchObject({ total: 25, total_tax: 6.25, vat_rate: 25 })
})
it('sums inline refund totals into refunded_total', () => {
const rows = mapOrderToWebshopRow(
connection,
's',
makeOrder({ refunds: [{ id: 77, reason: '', total: '-250.00' }] }),
)
expect(rows[0].refunded_total).toBe(250)
})
})
describe('mapRefundToWebshopRow', () => {
const connection = { id: 'conn-1', store_name: 'Testbutiken' }
const refund: WooRefund = {
id: 77,
amount: '250.00',
reason: 'Retur',
date_created_gmt: '2026-08-03T10:00:00',
}
it('maps a refund to a negative row parented by external id, with PRORATED VAT', () => {
const rows = mapRefundToWebshopRow(connection, 'shop.example.se', makeOrder(), refund)
expect(rows).toHaveLength(1)
expect(rows[0]).toMatchObject({
row_type: 'refund',
parent_external_id: 'woo_shop.example.se_order_1042',
external_id: 'woo_shop.example.se_refund_77',
order_number: '1042',
order_date: '2026-08-03',
total: -250,
// 250 of a 1250 order (net 1000 + moms 250): the reversal carries the
// sale's mix. A zero here would leave ruta 10 over-declared (the
// skeptic's counterexample ledger).
total_tax: -50,
is_paid: true,
})
expect(rows[0].vat_breakdown).toEqual([{ rate: 25, net: 200, tax: 50 }])
})
it('skips zero-amount refunds', () => {
expect(
mapRefundToWebshopRow(connection, 's', makeOrder(), { ...refund, amount: '0' }),
).toEqual([])
})
})
describe('buildRefundVatBreakdown', () => {
it('prefers the refund\'s own line allocation over proration', () => {
const refundWithLines: WooRefund = {
id: 78,
amount: '125.00',
reason: '',
date_created_gmt: '2026-08-03T10:00:00',
line_items: [
{ id: 9, total: '-100.00', total_tax: '-25.00', taxes: [{ id: 3, total: '-25.00' }] },
],
}
expect(buildRefundVatBreakdown(makeOrder(), refundWithLines)).toEqual({
breakdown: [{ rate: 25, net: 100, tax: 25 }],
totalTax: 25,
})
})
it('prorates a mixed-rate order for amount-only refunds', () => {
const order = makeOrder({
total: '1000.00',
total_tax: '160.00',
line_items: [
{ id: 1, name: 'A', quantity: 1, total: '500.00', total_tax: '125.00', taxes: [{ id: 3, total: '125.00' }] },
{ id: 2, name: 'B', quantity: 1, total: '340.00', total_tax: '35.00', taxes: [{ id: 4, total: '35.00' }] },
],
tax_lines: [
{ rate_id: 3, rate_percent: 25, tax_total: '125.00', shipping_tax_total: '0.00' },
{ rate_id: 4, rate_percent: 12, tax_total: '35.00', shipping_tax_total: '0.00' },
],
})
const halfRefund: WooRefund = {
id: 79,
amount: '500.00',
reason: '',
date_created_gmt: '2026-08-03T10:00:00',
}
const { breakdown, totalTax } = buildRefundVatBreakdown(order, halfRefund)
expect(breakdown).toEqual([
{ rate: 25, net: 250, tax: 62.5 },
{ rate: 12, net: 170, tax: 17.5 },
])
expect(totalTax).toBe(80)
})
it('returns empty when the order itself has no breakdown', () => {
const order = makeOrder({ line_items: [], shipping_lines: [], tax_lines: [] })
const amountOnly: WooRefund = {
id: 80,
amount: '100.00',
reason: '',
date_created_gmt: '2026-08-03T10:00:00',
}
expect(buildRefundVatBreakdown(order, amountOnly)).toEqual({
breakdown: [],
totalTax: 0,
})
})
})
describe('buildVatBreakdown', () => {
it('groups line and shipping taxes per rate', () => {
const order = makeOrder({
line_items: [
{
id: 1,
name: 'A',
quantity: 1,
total: '400.00',
total_tax: '100.00',
taxes: [{ id: 3, total: '100.00' }],
},
{
id: 2,
name: 'B',
quantity: 1,
total: '100.00',
total_tax: '12.00',
taxes: [{ id: 4, total: '12.00' }],
},
],
shipping_lines: [
{ total: '49.00', total_tax: '12.25', taxes: [{ id: 3, total: '12.25' }] },
],
tax_lines: [
{ rate_id: 3, rate_percent: 25, tax_total: '112.25', shipping_tax_total: '12.25' },
{ rate_id: 4, rate_percent: 12, tax_total: '12.00', shipping_tax_total: '0.00' },
],
})
expect(buildVatBreakdown(order)).toEqual([
{ rate: 25, net: 449, tax: 112.25 },
{ rate: 12, net: 100, tax: 12 },
])
})
it('lands untaxed lines in the 0% bucket', () => {
const order = makeOrder({
line_items: [
{ id: 1, name: 'A', quantity: 1, total: '300.00', total_tax: '0.00', taxes: [] },
],
tax_lines: [],
total_tax: '0.00',
})
expect(buildVatBreakdown(order)).toEqual([{ rate: 0, net: 300, tax: 0 }])
})
it('infers the rate from the ratio when tax_lines are missing', () => {
const order = makeOrder({
line_items: [
{ id: 1, name: 'A', quantity: 1, total: '400.00', total_tax: '100.00' },
],
tax_lines: [],
})
expect(buildVatBreakdown(order)).toEqual([{ rate: 25, net: 400, tax: 100 }])
})
it('returns [] when the payload has no line data (dialog falls back)', () => {
const order = makeOrder({ line_items: [], shipping_lines: [], tax_lines: [] })
expect(buildVatBreakdown(order)).toEqual([])
})
it('returns [] for tax-stripped stores: empty taxes arrays are NOT tax data', () => {
// Hardened hosts serialize `taxes: []` on every line while hiding the
// amounts; treating that as tax data booked the whole VAT into 3740.
const order = makeOrder({
total: '500.00',
total_tax: '100.00',
line_items: [
{ id: 1, name: 'A', quantity: 1, total: '400.00', total_tax: '', taxes: [] },
],
tax_lines: [],
shipping_lines: [],
})
expect(buildVatBreakdown(order)).toEqual([])
})
it('includes fee lines so their VAT reaches 2611 instead of the 3740 residual', () => {
const order = makeOrder({
total: '562.50',
total_tax: '112.50',
fee_lines: [
{ name: 'Fakturaavgift', total: '50.00', total_tax: '12.50', taxes: [{ id: 3, total: '12.50' }] },
],
})
expect(buildVatBreakdown(order)).toEqual([{ rate: 25, net: 1050, tax: 262.5 }])
})
it('keeps discount lines SIGNED (negative net stays negative)', () => {
const order = makeOrder({
total: '525.00',
total_tax: '125.00',
line_items: [
{ id: 1, name: 'Produkt', quantity: 1, total: '500.00', total_tax: '125.00', taxes: [{ id: 3, total: '125.00' }] },
{ id: 2, name: 'Presentkort', quantity: 1, total: '-100.00', total_tax: '0.00', taxes: [] },
],
})
expect(buildVatBreakdown(order)).toEqual([
{ rate: 25, net: 500, tax: 125 },
{ rate: 0, net: -100, tax: 0 },
])
})
})
describe('extractOrgnr', () => {
it('reads an orgnr embedded in the billing company field', () => {
expect(
extractOrgnr(makeOrder({ billing: { company: 'Testbolaget AB 556677-8899' } })),
).toBe('556677-8899')
})
it('scans meta_data for orgnr-ish keys', () => {
expect(
extractOrgnr(
makeOrder({
billing: { company: 'Testbolaget AB' },
meta_data: [{ key: '_billing_org_nr', value: '5566778899' }],
}),
),
).toBe('556677-8899')
})
it('returns null when nothing matches', () => {
expect(extractOrgnr(makeOrder())).toBeNull()
expect(extractOrgnr(makeOrder({ billing: undefined, meta_data: undefined }))).toBeNull()
})
})
describe('syncWooCommerceOrders', () => {
it('upserts order and refund rows and advances the cursor', async () => {
const { client, updates } = makeSupabaseMock()
const order = makeOrder({
refunds: [{ id: 77, reason: 'Retur', total: '-250.00' }],
})
listOrdersPage.mockResolvedValueOnce([order])
listOrderRefunds.mockResolvedValueOnce([
{ id: 77, amount: '250.00', reason: 'Retur', date_created_gmt: '2026-08-03T10:00:00' },
])
vi.mocked(upsertWebshopOrders).mockResolvedValueOnce({
...emptyUpsertResult,
inserted: 2,
})
const summary = await syncWooCommerceOrders(client, makeConnection())
expect(summary).toMatchObject({ fetched: 1, refundsFetched: 1, inserted: 2, errors: 0 })
expect(upsertWebshopOrders).toHaveBeenCalledTimes(1)
const [, companyId, userId, rows] = vi.mocked(upsertWebshopOrders).mock.calls[0]
expect(companyId).toBe('company-1')
expect(userId).toBe('user-1')
expect((rows as WebshopOrderUpsert[]).map((r) => r.external_id)).toEqual([
'woo_shop.example.se_order_1042',
'woo_shop.example.se_refund_77',
])
// Cursor persisted from the page's max date_modified_gmt, branded UTC,
// and any stale error_message is cleared on progress.
const cursors = cursorUpdates(updates)
expect(cursors).toHaveLength(1)
expect(cursors[0].values.last_order_synced_at).toBe('2026-08-01T09:05:00.000Z')
expect(cursors[0].values.error_message).toBeNull()
// Second list call proves cursor pagination: modified_after advanced to
// the last row's timestamp, page reset to 1, terminated by the empty page.
expect(listOrdersPage).toHaveBeenCalledTimes(2)
expect(listOrdersPage.mock.calls[1][1]).toEqual({
modifiedAfter: '2026-08-01T09:05:00.000Z',
page: 1,
})
})
it('imports unpaid orders without fetching refunds for them', async () => {
const { client } = makeSupabaseMock()
listOrdersPage.mockResolvedValueOnce([
makeOrder({ status: 'pending', date_paid_gmt: null, refunds: [] }),
])
await syncWooCommerceOrders(client, makeConnection())
expect(listOrderRefunds).not.toHaveBeenCalled()
const [, , , rows] = vi.mocked(upsertWebshopOrders).mock.calls[0]
expect((rows as WebshopOrderUpsert[])[0]).toMatchObject({ is_paid: false })
})
it('holds the cursor below an order whose refund fetch failed', async () => {
const { client, updates } = makeSupabaseMock()
const order = makeOrder({ refunds: [{ id: 77, reason: '', total: '-250.00' }] })
listOrdersPage.mockResolvedValueOnce([order])
listOrderRefunds.mockRejectedValueOnce(new Error('502 from host'))
const summary = await syncWooCommerceOrders(client, makeConnection())
expect(summary.errors).toBe(1)
// date_modified 09:05:00 minus 1s: the next run re-lists this order.
const cursors = cursorUpdates(updates)
expect(cursors).toHaveLength(1)
expect(cursors[0].values.last_order_synced_at).toBe('2026-08-01T09:04:59.000Z')
})
it('holds the cursor below a page whose upsert reported errors', async () => {
const { client, updates } = makeSupabaseMock()
listOrdersPage.mockResolvedValueOnce([makeOrder()])
vi.mocked(upsertWebshopOrders).mockResolvedValueOnce({
...emptyUpsertResult,
errors: 1,
})
const summary = await syncWooCommerceOrders(client, makeConnection())
expect(summary.errors).toBe(1)
const cursors = cursorUpdates(updates)
expect(cursors).toHaveLength(1)
expect(cursors[0].values.last_order_synced_at).toBe('2026-08-01T09:04:59.000Z')
})
it('pages through a full same-timestamp tie by offset, then resumes cursor pagination', async () => {
const { client } = makeSupabaseMock()
const tie = Array.from({ length: 100 }, (_, i) =>
makeOrder({ id: i + 1, number: String(i + 1) }),
)
const later = makeOrder({
id: 500,
number: '500',
date_modified_gmt: '2026-08-01T10:00:00',
})
listOrdersPage.mockResolvedValueOnce(tie).mockResolvedValueOnce([later])
const summary = await syncWooCommerceOrders(client, makeConnection())
expect(summary.fetched).toBe(101)
expect(listOrdersPage).toHaveBeenCalledTimes(3)
const [firstArgs, secondArgs, thirdArgs] = listOrdersPage.mock.calls.map((c) => c[1])
// Full page, all one timestamp: same cursor, next offset page.
expect(secondArgs).toEqual({ modifiedAfter: firstArgs.modifiedAfter, page: 2 })
// Progress within the tie page: cursor moves, offset resets.
expect(thirdArgs).toEqual({ modifiedAfter: '2026-08-01T10:00:00.000Z', page: 1 })
})
it('counts an unparseable order total as an error without stalling the cursor', async () => {
const { client, updates } = makeSupabaseMock()
listOrdersPage.mockResolvedValueOnce([makeOrder({ total: 'not-a-number' })])
const summary = await syncWooCommerceOrders(client, makeConnection())
expect(summary.errors).toBe(1)
expect(upsertWebshopOrders).not.toHaveBeenCalled()
// Deliberate: a permanently corrupt total must not stall the feed.
expect(cursorUpdates(updates)).toHaveLength(1)
})
it('does nothing for a connection without credentials or not active', async () => {
const { client } = makeSupabaseMock()
const summary = await syncWooCommerceOrders(
client,
makeConnection({ consumer_key_encrypted: null }),
)
expect(summary.fetched).toBe(0)
expect(listOrdersPage).not.toHaveBeenCalled()
const revokedSummary = await syncWooCommerceOrders(
client,
makeConnection({ status: 'revoked' }),
)
expect(revokedSummary.fetched).toBe(0)
})
it('flips the connection to revoked when the store rejects the credentials', async () => {
const { client, updates } = makeSupabaseMock()
listOrdersPage.mockRejectedValueOnce(new Error('REVOKED'))
const summary = await syncWooCommerceOrders(client, makeConnection())
expect(summary.revoked).toBe(true)
const revokeUpdate = updates.find((u) => u.table === 'woocommerce_connections')
expect(revokeUpdate?.values.status).toBe('revoked')
})
it('stops before fetching when the deadline is already reached', async () => {
const { client } = makeSupabaseMock()
const summary = await syncWooCommerceOrders(
client,
makeConnection(),
undefined,
Date.now() - 1,
)
expect(summary.deadlineReached).toBe(true)
expect(listOrdersPage).not.toHaveBeenCalled()
})
it('skips remaining refund fetches on deadline and holds the cursor for them', async () => {
const { client, updates } = makeSupabaseMock()
const refunded = makeOrder({ refunds: [{ id: 77, reason: '', total: '-1.00' }] })
// The list call itself consumes the whole budget, so the deadline is
// comfortably alive at the loop check and expired by the refund loop.
const deadlineMs = Date.now() + 200
listOrdersPage.mockImplementationOnce(async () => {
await new Promise((resolve) => setTimeout(resolve, 250))
return [refunded]
})
const summary = await syncWooCommerceOrders(client, makeConnection(), undefined, deadlineMs)
expect(summary.deadlineReached).toBe(true)
expect(listOrderRefunds).not.toHaveBeenCalled()
const cursors = cursorUpdates(updates)
expect(cursors).toHaveLength(1)
expect(cursors[0].values.last_order_synced_at).toBe('2026-08-01T09:04:59.000Z')
})
})