* feat(woocommerce): store order/refund feed extension Connect a WooCommerce store via the wc-auth key handshake (manual key fallback) with per-store consumer key/secret AES-256-GCM encrypted at rest, and import paid orders and refunds into the transactions inbox as a bank-style feed on the 1680 cash account. Feed-only: nothing auto-books, gateway fees/payouts are out of scope (core wc/v3 does not expose them). Sync is cursor-paginated on modified_after (offset pages only inside same-second date_modified ties), terminates on an empty page, holds the cursor below failed refund fetches / ingest errors / deadline-skipped work, checks the time budget between refund fetches, and drops rows dated on or before bookkeeping_locked_through on every run. Nightly cron gated on the extension registry + new paid capability woocommerce_sync (backfilled to existing bank_sync grant holders). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(migrations): move woocommerce migrations past main's 20260806090000 origin/main gained 20260806090000_recurring_schedule_interval_months while this branch was in flight; identical version timestamps abort the Supabase apply, so the two new migrations move to 20260806170000/20260806170100. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(woocommerce): resolve CodeRabbit review findings - callback 503s early when WOOCOMMERCE_CREDENTIALS_ENCRYPTION_KEY is unset: encryptCredential would otherwise throw after the probe and strand the pending row without error_message - disconnect and upstream-revoke clear the encrypted consumer key/secret: nothing reads them after revoke and keeping decryptable dead credentials is unnecessary retention - manual sync gets a 240s time budget and the panel reports a truncated run as 'partial, sync again' instead of a normal completion - listOrderRefunds terminates on an empty batch (hosts may cap per_page), dedupes by id against hosts that ignore page, and caps total pages - unparseable money strings count as errors and log instead of being silently identical to a zero total - pg test uses per-run unique store URLs so committed rows cannot hit the store_url partial unique index across pg-real runs Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(woocommerce): resolve CodeRabbit cycle-2 findings - listOrderRefunds throws when the page cap is exhausted with data still flowing, instead of returning a silently partial list the sync cursor would advance past; the error routes into the existing held-cursor refund-retry path - partial sync results keep the row-error count, and the partial toast string surfaces it (ICU plural, hidden at zero) in both locales Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * chore: retrigger CI after dropped push event Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
74 lines
2.4 KiB
TypeScript
74 lines
2.4 KiB
TypeScript
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
|
|
import { listOrderRefunds, type WooCredentials } from '../lib/api-client'
|
|
|
|
const CREDS: WooCredentials = {
|
|
storeUrl: 'https://shop.example.se',
|
|
consumerKey: 'ck_test',
|
|
consumerSecret: 'cs_test',
|
|
}
|
|
|
|
function jsonResponse(body: unknown): Response {
|
|
return new Response(JSON.stringify(body), {
|
|
status: 200,
|
|
headers: { 'Content-Type': 'application/json' },
|
|
})
|
|
}
|
|
|
|
function makeRefunds(startId: number, count: number) {
|
|
return Array.from({ length: count }, (_, i) => ({
|
|
id: startId + i,
|
|
amount: '10.00',
|
|
reason: '',
|
|
date_created_gmt: '2026-08-01T10:00:00',
|
|
}))
|
|
}
|
|
|
|
const fetchMock = vi.fn()
|
|
|
|
beforeEach(() => {
|
|
fetchMock.mockReset()
|
|
vi.stubGlobal('fetch', fetchMock)
|
|
})
|
|
|
|
afterEach(() => {
|
|
vi.unstubAllGlobals()
|
|
})
|
|
|
|
describe('listOrderRefunds', () => {
|
|
it('terminates on an empty page, not a short one (hosts may cap per_page)', async () => {
|
|
fetchMock
|
|
.mockResolvedValueOnce(jsonResponse(makeRefunds(1, 50))) // short but non-empty
|
|
.mockResolvedValueOnce(jsonResponse(makeRefunds(51, 50)))
|
|
.mockResolvedValueOnce(jsonResponse([]))
|
|
|
|
const refunds = await listOrderRefunds(CREDS, 42)
|
|
expect(refunds).toHaveLength(100)
|
|
expect(fetchMock).toHaveBeenCalledTimes(3)
|
|
})
|
|
|
|
it('stops when a host ignoring `page` repeats the same rows', async () => {
|
|
// Fresh Response per call: a Response body is single-use.
|
|
fetchMock.mockImplementation(async () => jsonResponse(makeRefunds(1, 100)))
|
|
|
|
const refunds = await listOrderRefunds(CREDS, 42)
|
|
expect(refunds).toHaveLength(100)
|
|
// Page 1 full of fresh rows, page 2 identical → zero fresh → stop.
|
|
expect(fetchMock).toHaveBeenCalledTimes(2)
|
|
})
|
|
|
|
it('throws instead of returning a silently partial list when the page cap is exhausted', async () => {
|
|
// Ten full pages of genuinely fresh rows: the cap trips with data still
|
|
// flowing, and a partial return would let the sync cursor pass unseen
|
|
// refunds. The thrown error routes into the caller's held-cursor retry.
|
|
fetchMock.mockImplementation(async (url: string | URL) => {
|
|
const page = Number(new URL(String(url)).searchParams.get('page'))
|
|
return jsonResponse(makeRefunds(page * 1000, 100))
|
|
})
|
|
|
|
await expect(listOrderRefunds(CREDS, 42)).rejects.toThrow(
|
|
/Refund pagination cap exceeded/,
|
|
)
|
|
expect(fetchMock).toHaveBeenCalledTimes(10)
|
|
})
|
|
})
|