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>
This commit is contained in:
co-authored by
Claude Fable 5
parent
cd344b6dbb
commit
707d597b2e
@@ -0,0 +1,131 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { getPool, withUserContext } from './setup'
|
||||
import { randomUUID } from 'crypto'
|
||||
import { seedCompany } from './fixtures'
|
||||
|
||||
// Committed (pool) inserts persist across pg-real runs, and the store_url
|
||||
// partial unique index is global: fixed URLs would collide with rows left by
|
||||
// a previous run before the assertion under test is ever reached.
|
||||
const uniqueStore = (label: string) => 'https://' + label + '-' + randomUUID() + '.example.se'
|
||||
|
||||
/**
|
||||
* Covers migration 20260806170000_woocommerce_connections:
|
||||
* 1. RLS: members insert and read their own company's connection,
|
||||
* non-members see nothing and cannot insert for a foreign company.
|
||||
* 2. One ACTIVE connection per company (partial unique index).
|
||||
* 3. One store actively connected to at most one company.
|
||||
* 4. No DELETE policy: a member DELETE silently affects zero rows.
|
||||
*/
|
||||
|
||||
describe('woocommerce_connections RLS', () => {
|
||||
it('a member can insert and read their company connection', async () => {
|
||||
const { userId, companyId } = await seedCompany()
|
||||
await withUserContext(userId, async (client) => {
|
||||
const inserted = await client.query(
|
||||
`INSERT INTO public.woocommerce_connections
|
||||
(company_id, user_id, store_url, status, oauth_state)
|
||||
VALUES ($1, $2, 'https://shop.example.se', 'pending', gen_random_uuid())
|
||||
RETURNING id`,
|
||||
[companyId, userId],
|
||||
)
|
||||
expect(inserted.rows).toHaveLength(1)
|
||||
|
||||
const read = await client.query(
|
||||
`SELECT status, store_url FROM public.woocommerce_connections WHERE company_id = $1`,
|
||||
[companyId],
|
||||
)
|
||||
expect(read.rows).toEqual([
|
||||
{ status: 'pending', store_url: 'https://shop.example.se' },
|
||||
])
|
||||
})
|
||||
})
|
||||
|
||||
it('a non-member sees nothing and cannot insert for a foreign company', async () => {
|
||||
const { userId: ownerId, companyId } = await seedCompany()
|
||||
await getPool().query(
|
||||
`INSERT INTO public.woocommerce_connections (company_id, user_id, store_url, status)
|
||||
VALUES ($1, $2, $3, 'active')`,
|
||||
[companyId, ownerId, uniqueStore('foreign')],
|
||||
)
|
||||
const { userId: outsiderId } = await seedCompany() // member of a DIFFERENT company
|
||||
|
||||
await withUserContext(outsiderId, async (client) => {
|
||||
const read = await client.query(
|
||||
`SELECT id FROM public.woocommerce_connections WHERE company_id = $1`,
|
||||
[companyId],
|
||||
)
|
||||
expect(read.rows).toHaveLength(0)
|
||||
|
||||
await expect(
|
||||
client.query(
|
||||
`INSERT INTO public.woocommerce_connections (company_id, user_id, store_url, status)
|
||||
VALUES ($1, $2, 'https://intruder.example.se', 'pending')`,
|
||||
[companyId, outsiderId],
|
||||
),
|
||||
).rejects.toThrow(/row-level security/i)
|
||||
})
|
||||
})
|
||||
|
||||
it('only one ACTIVE connection per company is allowed', async () => {
|
||||
const { userId, companyId } = await seedCompany()
|
||||
await getPool().query(
|
||||
`INSERT INTO public.woocommerce_connections (company_id, user_id, store_url, status)
|
||||
VALUES ($1, $2, $3, 'active')`,
|
||||
[companyId, userId, uniqueStore('store-one')],
|
||||
)
|
||||
await expect(
|
||||
getPool().query(
|
||||
`INSERT INTO public.woocommerce_connections (company_id, user_id, store_url, status)
|
||||
VALUES ($1, $2, $3, 'active')`,
|
||||
[companyId, userId, uniqueStore('store-two')],
|
||||
),
|
||||
).rejects.toMatchObject({ code: '23505' }) // unique_violation
|
||||
})
|
||||
|
||||
it('a store may be actively connected to at most one company', async () => {
|
||||
const { userId: userA, companyId: companyA } = await seedCompany()
|
||||
const { userId: userB, companyId: companyB } = await seedCompany()
|
||||
const sharedUrl = uniqueStore('shared')
|
||||
await getPool().query(
|
||||
`INSERT INTO public.woocommerce_connections (company_id, user_id, store_url, status)
|
||||
VALUES ($1, $2, $3, 'active')`,
|
||||
[companyA, userA, sharedUrl],
|
||||
)
|
||||
await expect(
|
||||
getPool().query(
|
||||
`INSERT INTO public.woocommerce_connections (company_id, user_id, store_url, status)
|
||||
VALUES ($1, $2, $3, 'active')`,
|
||||
[companyB, userB, sharedUrl],
|
||||
),
|
||||
).rejects.toMatchObject({ code: '23505' })
|
||||
|
||||
// A revoked row for the same store is fine (history is kept).
|
||||
const revoked = await getPool().query(
|
||||
`INSERT INTO public.woocommerce_connections (company_id, user_id, store_url, status)
|
||||
VALUES ($1, $2, $3, 'revoked') RETURNING id`,
|
||||
[companyB, userB, sharedUrl],
|
||||
)
|
||||
expect(revoked.rows).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('members cannot DELETE (no DELETE policy; revoke is a status flip)', async () => {
|
||||
const { userId, companyId } = await seedCompany()
|
||||
const { rows } = await getPool().query(
|
||||
`INSERT INTO public.woocommerce_connections (company_id, user_id, store_url, status)
|
||||
VALUES ($1, $2, $3, 'active') RETURNING id`,
|
||||
[companyId, userId, uniqueStore('keep')],
|
||||
)
|
||||
await withUserContext(userId, async (client) => {
|
||||
const del = await client.query(
|
||||
`DELETE FROM public.woocommerce_connections WHERE id = $1`,
|
||||
[rows[0].id],
|
||||
)
|
||||
expect(del.rowCount).toBe(0)
|
||||
})
|
||||
const still = await getPool().query(
|
||||
`SELECT id FROM public.woocommerce_connections WHERE id = $1`,
|
||||
[rows[0].id],
|
||||
)
|
||||
expect(still.rows).toHaveLength(1)
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user