Files
accounted/extensions/general/zettle/__tests__/api-routes.test.ts
T
MattssonandClaude Fable 5.1 6ea92f3152 feat(zettle): sync paid purchases into webshop_orders (#2445)
Community PR #2416 by @olofpinzke, adopted and finished by maintainers (rebased so every commit is signed).

Why the problem occurred: no Zettle integration; POS sales only reached the books as bank descriptors while Woo/Shopify already had order underlag via webshop_orders. The contributor's version also failed at the database (platform CHECKs listed only woocommerce/shopify), which the mocked unit tests never saw.
What was simplified: reused the Orders/book/invoice path instead of a new inbox; Finance API payouts/fees deferred. Sales the one-account, revenue-per-rate model cannot book (split tender, gift cards, tips) import unbookable with a "bokför manuellt" title instead of guessing accounts. Reset parity uses the rename-and-wrap pattern instead of re-issuing the reset body.
Why this solution: per-purchase rows give the radunderlag BFL verifikat need and the bulk-book path exists; daily kassarapport aggregation and Finance API fees/payouts are the follow-up (DECISIONS.md). Skeptic-refuted paths fixed before merge: concurrent refresh-token rotation (sync claim), cron offset paging (candidate snapshot), platform CHECKs, writer-role gate, migration-reset parity, white-label return origin re-validated at callback, VAT net from product rows.

Not live until ZETTLE_CLIENT_ID / ZETTLE_CLIENT_SECRET / ZETTLE_CREDENTIALS_ENCRYPTION_KEY are set on Vercel and a Zettle developer app is registered with the callback redirect URI.

Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WtYqzKPoTSRHskYYdf7MwB
2026-09-09 11:19:39 +02:00

189 lines
7.2 KiB
TypeScript

import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
vi.mock('@/lib/entitlements/has-capability', async (importOriginal) => {
const actual = await importOriginal<typeof import('@/lib/entitlements/has-capability')>()
return { ...actual, requireCapability: vi.fn().mockResolvedValue(null) }
})
vi.mock('../lib/oauth', async (importOriginal) => {
const actual = await importOriginal<typeof import('../lib/oauth')>()
return {
...actual,
refreshAccessToken: vi.fn(),
disconnectApplication: vi.fn().mockResolvedValue(undefined),
}
})
vi.mock('../lib/order-sync', async (importOriginal) => {
const actual = await importOriginal<typeof import('../lib/order-sync')>()
return { ...actual, syncZettlePurchases: vi.fn() }
})
vi.mock('@/lib/auth/api-keys', () => ({
createServiceClientNoCookies: vi.fn(() => ({ service: true })),
}))
vi.mock('@/lib/auth/oauth-flows', () => ({
resolveOAuthOrigin: vi.fn().mockResolvedValue('https://brand.testbrand.example'),
}))
import { zettleExtension } from '../index'
import { requireCapability, capabilityBlockedResponse } from '@/lib/entitlements/has-capability'
import { CAPABILITY } from '@/lib/entitlements/keys'
import { syncZettlePurchases } from '../lib/order-sync'
import { resolveOAuthOrigin } from '@/lib/auth/oauth-flows'
import { createQueuedMockSupabase } from '@/tests/helpers'
import type { ExtensionContext } from '@/lib/extensions/types'
function findRoute(method: string, path: string) {
const route = zettleExtension.apiRoutes?.find((r) => r.method === method && r.path === path)
expect(route, `${method} ${path} must be registered`).toBeDefined()
return route!
}
function makeRequest(method: string, body?: unknown): Request {
return new Request('https://test.local/api/extensions/ext/zettle/x', {
method,
headers: { 'Content-Type': 'application/json' },
...(body !== undefined ? { body: JSON.stringify(body) } : {}),
})
}
function makeContext(supabase: unknown): ExtensionContext {
return {
userId: 'user-1',
companyId: 'company-1',
extensionId: 'zettle',
requestId: 'req_test',
supabase,
emit: vi.fn().mockResolvedValue(undefined),
log: { info: vi.fn(), warn: vi.fn(), error: vi.fn() },
settings: {
get: vi.fn().mockResolvedValue(null),
set: vi.fn().mockResolvedValue(undefined),
clear: vi.fn().mockResolvedValue(undefined),
},
// eslint-disable-next-line @typescript-eslint/no-explicit-any
} as any
}
const USER = { id: 'user-1', is_anonymous: false }
describe('zettle extension routes', () => {
beforeEach(() => {
vi.clearAllMocks()
vi.stubEnv('ZETTLE_CLIENT_ID', 'cid')
vi.stubEnv('ZETTLE_CLIENT_SECRET', 'csecret')
vi.stubEnv('ZETTLE_CREDENTIALS_ENCRYPTION_KEY', 'test-key')
vi.stubEnv('NEXT_PUBLIC_APP_URL', 'https://app.example.com')
vi.mocked(requireCapability).mockResolvedValue(null)
})
afterEach(() => {
vi.unstubAllEnvs()
})
it('GET /status returns configured + connection', async () => {
const { supabase, enqueue } = createQueuedMockSupabase()
supabase.auth.getUser.mockResolvedValue({ data: { user: USER }, error: null })
enqueue({
data: [
{
id: 'c1',
status: 'active',
organization_uuid: 'org-1',
organization_name: null,
currency: 'SEK',
error_message: null,
connected_at: '2026-09-01T00:00:00.000Z',
transaction_sync_enabled: true,
last_order_synced_at: null,
},
],
})
const res = await findRoute('GET', '/status').handler(makeRequest('GET'), makeContext(supabase))
expect(res.status).toBe(200)
const body = await res.json()
expect(body.configured).toBe(true)
expect(body.connection.id).toBe('c1')
})
it('POST /connect stages pending and returns authorize url', async () => {
const { supabase, enqueue } = createQueuedMockSupabase()
supabase.auth.getUser.mockResolvedValue({ data: { user: USER }, error: null })
enqueue({ data: { is_sandbox: false } }) // guardSandbox
enqueue({ data: [] }) // no active connection
enqueue({ data: [] }) // clear stale pending
enqueue({ data: { id: 'pending-1' } }) // insert pending
const res = await findRoute('POST', '/connect').handler(makeRequest('POST'), makeContext(supabase))
expect(res.status).toBe(200)
const body = await res.json()
const url = new URL(body.url)
expect(url.origin + url.pathname).toBe('https://oauth.zettle.com/authorize')
expect(url.searchParams.get('client_id')).toBe('cid')
expect(url.searchParams.get('scope')).toBe('READ:PURCHASE READ:USERINFO')
// The validated brand/app origin is frozen on the pending row so the
// callback can send the browser back to the domain it started on.
expect(resolveOAuthOrigin).toHaveBeenCalledTimes(1)
expect(supabase.from).toHaveBeenCalledWith('zettle_connections')
expect(requireCapability).toHaveBeenCalledWith(
expect.anything(),
'company-1',
CAPABILITY.zettle_sync,
)
})
it('POST /connect invalidates a prior pending row before staging a new one', async () => {
const { supabase, enqueue } = createQueuedMockSupabase()
supabase.auth.getUser.mockResolvedValue({ data: { user: USER }, error: null })
enqueue({ data: { is_sandbox: false } }) // guardSandbox
enqueue({ data: [] }) // no active connection
enqueue({ data: [] }) // clear stale pending (status -> error)
enqueue({ data: { id: 'pending-2' } }) // insert replacement pending
const res = await findRoute('POST', '/connect').handler(makeRequest('POST'), makeContext(supabase))
expect(res.status).toBe(200)
// Second connect must leave the abandoned oauth_state unusable so a late
// callback for the first flow cannot activate that row (see callback test).
expect(supabase.from).toHaveBeenCalledWith('zettle_connections')
})
it('POST /connect refuses when capability is blocked', async () => {
vi.mocked(requireCapability).mockResolvedValue(capabilityBlockedResponse(CAPABILITY.zettle_sync))
const { supabase, enqueue } = createQueuedMockSupabase()
supabase.auth.getUser.mockResolvedValue({ data: { user: USER }, error: null })
enqueue({ data: { is_sandbox: false } })
const res = await findRoute('POST', '/connect').handler(makeRequest('POST'), makeContext(supabase))
expect([402, 403]).toContain(res.status)
})
it('POST /sync calls syncZettlePurchases', async () => {
vi.mocked(syncZettlePurchases).mockResolvedValue({
fetched: 1,
refundsFetched: 0,
inserted: 1,
updated: 0,
unchanged: 0,
frozenFlagged: 0,
crossMarked: 0,
errors: 0,
needsReview: 0,
skippedUnsupported: 0,
})
const { supabase, enqueue } = createQueuedMockSupabase()
supabase.auth.getUser.mockResolvedValue({ data: { user: USER }, error: null })
enqueue({
data: {
id: 'c1',
status: 'active',
company_id: 'company-1',
user_id: 'user-1',
organization_uuid: 'org-1',
refresh_token_encrypted: 'enc',
},
})
const res = await findRoute('POST', '/sync').handler(makeRequest('POST'), makeContext(supabase))
expect(res.status).toBe(200)
expect(syncZettlePurchases).toHaveBeenCalled()
})
})