Files
accounted/app/api/extensions/woocommerce/__tests__/callback.test.ts
T
Mattsson 707d597b2e feat(woocommerce): store order/refund feed extension (#1442)
* 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>
2026-08-06 23:30:00 +02:00

165 lines
5.9 KiB
TypeScript

import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
vi.mock('@/lib/supabase/server', () => ({
createServiceClient: vi.fn(),
}))
vi.mock('@/lib/init', () => ({ ensureInitialized: vi.fn() }))
vi.mock('@/lib/events/bus', () => ({ eventBus: { emit: vi.fn() } }))
vi.mock('@/lib/extensions/loader', () => ({ loadExtensions: vi.fn() }))
vi.mock('@/lib/extensions/registry', () => ({ extensionRegistry: { get: vi.fn() } }))
vi.mock('@/extensions/general/woocommerce/lib/api-client', async (importOriginal) => {
const actual =
await importOriginal<typeof import('@/extensions/general/woocommerce/lib/api-client')>()
return { ...actual, testConnectionAndFetchStoreInfo: vi.fn() }
})
import { POST } from '../callback/route'
import { createServiceClient } from '@/lib/supabase/server'
import { eventBus } from '@/lib/events/bus'
import { extensionRegistry } from '@/lib/extensions/registry'
import { testConnectionAndFetchStoreInfo } from '@/extensions/general/woocommerce/lib/api-client'
import { decryptCredential } from '@/extensions/general/woocommerce/lib/credentials'
import { createQueuedMockSupabase } from '@/tests/helpers'
const STATE = '123e4567-e89b-12d3-a456-426614174000'
function makeCallbackRequest(body: unknown): Request {
return new Request('https://test.local/api/extensions/woocommerce/callback', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: typeof body === 'string' ? body : JSON.stringify(body),
})
}
const VALID_BODY = {
key_id: 1,
user_id: STATE,
consumer_key: 'ck_new',
consumer_secret: 'cs_new',
key_permissions: 'read',
}
describe('POST /api/extensions/woocommerce/callback', () => {
beforeEach(() => {
vi.clearAllMocks()
vi.stubEnv('WOOCOMMERCE_CREDENTIALS_ENCRYPTION_KEY', 'test-key')
vi.mocked(extensionRegistry.get).mockReturnValue(
{ id: 'woocommerce' } as ReturnType<typeof extensionRegistry.get>,
)
})
afterEach(() => {
vi.unstubAllEnvs()
})
it('refuses with 503 when the extension is disabled', async () => {
vi.mocked(extensionRegistry.get).mockReturnValue(undefined)
const res = await POST(makeCallbackRequest(VALID_BODY))
expect(res.status).toBe(503)
const body = await res.json()
expect(body.code).toBe('EXTENSION_DISABLED')
})
it('rejects a non-JSON body with 400', async () => {
const res = await POST(makeCallbackRequest('not json'))
expect(res.status).toBe(400)
})
it('rejects a missing or non-UUID state with 400', async () => {
const res = await POST(
makeCallbackRequest({ ...VALID_BODY, user_id: 'not-a-uuid' }),
)
expect(res.status).toBe(400)
const res2 = await POST(makeCallbackRequest({ user_id: STATE }))
expect(res2.status).toBe(400)
})
it('returns 404 for an unknown or already-consumed state', async () => {
const { supabase, enqueue } = createQueuedMockSupabase()
vi.mocked(createServiceClient).mockResolvedValue(
supabase as unknown as Awaited<ReturnType<typeof createServiceClient>>,
)
enqueue({ data: null, error: { message: 'no rows', code: 'PGRST116' } })
const res = await POST(makeCallbackRequest(VALID_BODY))
expect(res.status).toBe(404)
})
it('marks the row error and returns 502 when the credential probe fails', async () => {
const { supabase, enqueue, findCall } = createQueuedMockSupabase()
vi.mocked(createServiceClient).mockResolvedValue(
supabase as unknown as Awaited<ReturnType<typeof createServiceClient>>,
)
enqueue({
data: {
id: 'conn-1',
company_id: 'company-1',
user_id: 'user-1',
store_url: 'https://shop.example.se',
},
})
enqueue({ data: null }) // markError update
vi.mocked(testConnectionAndFetchStoreInfo).mockRejectedValue(new Error('403'))
const res = await POST(makeCallbackRequest(VALID_BODY))
expect(res.status).toBe(502)
const errorUpdate = findCall('woocommerce_connections', 'update')?.[0] as Record<
string,
unknown
>
expect(errorUpdate.status).toBe('error')
// The probe ran against the STORED store_url, not anything the caller sent.
expect(vi.mocked(testConnectionAndFetchStoreInfo).mock.calls[0][0]).toMatchObject({
storeUrl: 'https://shop.example.se',
consumerKey: 'ck_new',
})
})
it('encrypts the keys, activates the row and emits the audit event', async () => {
const { supabase, enqueue, findCalls } = createQueuedMockSupabase()
vi.mocked(createServiceClient).mockResolvedValue(
supabase as unknown as Awaited<ReturnType<typeof createServiceClient>>,
)
enqueue({
data: {
id: 'conn-1',
company_id: 'company-1',
user_id: 'user-1',
store_url: 'https://shop.example.se',
},
})
enqueue({
data: {
id: 'conn-1',
company_id: 'company-1',
user_id: 'user-1',
store_url: 'https://shop.example.se',
},
}) // activation update
vi.mocked(testConnectionAndFetchStoreInfo).mockResolvedValue({
name: 'Testbutiken',
currency: 'SEK',
prices_include_tax: true,
wc_version: '9.9.5',
})
const res = await POST(makeCallbackRequest(VALID_BODY))
expect(res.status).toBe(200)
const updates = findCalls('woocommerce_connections', 'update')
const activation = updates[0][0] as Record<string, string | boolean | null>
expect(activation.status).toBe('active')
expect(activation.transaction_sync_enabled).toBe(true)
expect(activation.oauth_state).toBeNull()
expect(activation.store_name).toBe('Testbutiken')
// Secrets never stored in plaintext, and they decrypt back.
expect(String(activation.consumer_key_encrypted)).not.toContain('ck_new')
expect(decryptCredential(String(activation.consumer_key_encrypted))).toBe('ck_new')
expect(decryptCredential(String(activation.consumer_secret_encrypted))).toBe('cs_new')
expect(eventBus.emit).toHaveBeenCalledWith(
expect.objectContaining({ type: 'woocommerce.connected' }),
)
})
})