Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
44 changes: 43 additions & 1 deletion .github/workflows/code-quality.yml
Original file line number Diff line number Diff line change
@@ -1,6 +1,12 @@
name: Code Quality Checks

on: push
# Run on PRs to main and on pushes to main (post-merge sanity). Feature-branch
# pushes without a PR don't trigger CI, so nothing double-fires.
on:
pull_request:
branches: [main]
push:
branches: [main]

permissions:
contents: read
Expand Down Expand Up @@ -78,3 +84,39 @@ jobs:

- name: Type check
run: pnpm typecheck

integration-tests:
name: Integration tests
# Only on PRs, and only after the cheap static checks pass — so a lint/type
# error fails fast without spinning up Docker + Testcontainers.
if: github.event_name == 'pull_request'
needs: [run-linter, type-check]
runs-on: ubuntu-latest
timeout-minutes: 20
steps:
- name: Check out repository
uses: actions/checkout@v4

- name: Set up pnpm
uses: pnpm/action-setup@v4
with:
version: 10.15
run_install: false

- name: Set up Node.js
uses: actions/setup-node@v4
with:
node-version: '22'
cache: pnpm
cache-dependency-path: |
pnpm-lock.yaml
**/pnpm-lock.yaml

- name: Install dependencies
run: pnpm install --frozen-lockfile

# Testcontainers starts an ephemeral Postgres via Docker, preinstalled on
# ubuntu-latest. globalSetup loads .env.test (committed, non-secret stubs)
# and sets DATABASE_URL from the container, so no secrets are needed here.
- name: Run integration tests
run: pnpm test
37 changes: 37 additions & 0 deletions test/fixtures/invoiceCreated.webhook.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
import { type InvoiceCreatedWebhookSchema, ValidWebhookEvent } from '@invoice-sync/types'
import { TEST_CLIENT, TEST_COMPANY, TEST_INVOICE } from '@test/helpers/constants'
import type { z } from 'zod'

type InvoiceWebhookInput = z.input<typeof InvoiceCreatedWebhookSchema>
type InvoiceData = InvoiceWebhookInput['data']

// Builds an invoice.created webhook payload for the client-billed happy path.
// Pass `dataOverrides` to vary a single case (status, collectionMethod,
// lineItems, taxAmount, etc.) without repeating the whole object.
export function buildInvoiceCreatedWebhook(
dataOverrides: Partial<InvoiceData> = {},
): InvoiceWebhookInput {
return {
eventType: ValidWebhookEvent.InvoiceCreated,
data: {
clientId: TEST_CLIENT.id,
companyId: TEST_COMPANY.id,
collectionMethod: 'sendInvoice',
createdAt: '2026-07-02T00:00:00.000Z',
currency: 'USD',
dueDate: '2026-07-16T00:00:00.000Z',
fileUrl: 'https://example.test/invoice.pdf',
id: TEST_INVOICE.id,
lineItems: [{ amount: 10000, description: 'Consulting', quantity: 1 }],
memo: 'Thanks for your business',
number: TEST_INVOICE.number,
sentDate: '2026-07-02T00:00:00.000Z',
status: 'open',
taxAmount: 825,
taxPercentage: 8.25,
total: 10825,
updatedAt: '2026-07-02T00:00:00.000Z',
...dataOverrides,
},
}
}
18 changes: 18 additions & 0 deletions test/helpers/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,3 +23,21 @@ export const TEST_XERO_ITEM = {
id: '44444444-4444-4444-8444-444444444444',
other: '99999999-9999-4999-8999-999999999999',
}

// The Copilot client an invoice is billed to (client-billed happy path) and its
// company. companyId must be a valid v4 uuid (InvoiceCreatedEventSchema.companyId).
export const TEST_CLIENT = {
id: '55555555-5555-4555-8555-555555555555',
email: 'client@example.test',
givenName: 'Test',
familyName: 'Client',
}
export const TEST_COMPANY = { id: '66666666-6666-4666-8666-666666666666' }

// The Copilot invoice and the Xero entities it maps to. Xero ids are v4 uuids
// because synced_invoices.xeroInvoiceId / salesAccountId and synced_contacts.contactId
// are uuid columns.
export const TEST_INVOICE = { id: 'test-invoice-00000001', number: 'INV-0001' }
export const TEST_XERO_CONTACT = { id: '77777777-7777-4777-8777-777777777777' }
export const TEST_XERO_INVOICE = { id: '88888888-8888-4888-8888-888888888888' }
export const TEST_SALES_ACCOUNT = { id: 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa' }
71 changes: 69 additions & 2 deletions test/helpers/mocks.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,12 @@
import { TEST_PORTAL, TEST_XERO_ITEM } from '@test/helpers/constants'
import {
TEST_CLIENT,
TEST_COMPANY,
TEST_PORTAL,
TEST_SALES_ACCOUNT,
TEST_XERO_CONTACT,
TEST_XERO_INVOICE,
TEST_XERO_ITEM,
} from '@test/helpers/constants'
import { type Mock, vi } from 'vitest'
import { CopilotAPI } from '@/lib/copilot/CopilotAPI'
import XeroAPI from '@/lib/xero/XeroAPI'
Expand All @@ -21,11 +29,22 @@ export function createMockCopilotAPI(overrides: CopilotAPIOverrides = {}) {
workspaceId: TEST_PORTAL.id,
internalUserId: TEST_PORTAL.internalUserId,
}),
getClient: vi.fn().mockResolvedValue({
id: TEST_CLIENT.id,
givenName: TEST_CLIENT.givenName,
familyName: TEST_CLIENT.familyName,
email: TEST_CLIENT.email,
companyIds: [TEST_COMPANY.id],
status: 'active',
avatarImageUrl: null,
fallbackColor: null,
createdAt: '2026-01-01T00:00:00.000Z',
}),
...overrides,
}
}

// Mocked XeroAPI. Defaults cover the product.created happy path:
// Mocked XeroAPI. Defaults cover the product.created and invoice.created happy paths:
// - setTokenSet: no-op (called in the service constructor)
// - getOrganisationCountryCode: a supported region, so no live call
// - createItems: echoes the code and gives each item a unique uuid, like real
Expand All @@ -45,6 +64,54 @@ export function createMockXeroAPI(overrides: XeroAPIOverrides = {}) {
description: item.description,
})),
),
// Item lookup for line-item mapping; empty so lines fall back to the copilot description.
getItems: vi.fn().mockResolvedValue([]),
// No pre-existing accounts, so the sales account is created on the region-default path.
getAccounts: vi.fn().mockResolvedValue([]),
createSalesAccount: vi.fn(
async (_tenantId: string, account: { code: string; name: string }) => ({
accountID: TEST_SALES_ACCOUNT.id,
code: account.code,
name: account.name,
type: 'REVENUE',
status: 'ACTIVE',
enablePaymentsToAccount: true,
}),
),
enablePaymentsForAccount: vi.fn().mockResolvedValue(undefined),
// No matching tax rate, so a region-specific rate is created.
getTaxRates: vi.fn().mockResolvedValue([]),
createTaxRate: vi.fn(
async (
_tenantId: string,
taxRate: { name: string; reportTaxType?: string; taxComponents?: { rate: number }[] },
) => ({
name: taxRate.name,
reportTaxType: taxRate.reportTaxType,
taxType: 'ASSEMBLYTAX',
effectiveRate: taxRate.taxComponents?.[0]?.rate ?? 0,
status: 'ACTIVE',
}),
),
// New-contact path: no synced contact seeded, so createContact is what runs.
getContact: vi.fn().mockResolvedValue(undefined),
createContact: vi.fn(
async (
_tenantId: string,
contact: { name: string; emailAddress?: string; firstName?: string; lastName?: string },
) => ({
contactID: TEST_XERO_CONTACT.id,
name: contact.name,
emailAddress: contact.emailAddress,
firstName: contact.firstName,
lastName: contact.lastName,
}),
),
createInvoice: vi.fn(async (_tenantId: string, invoice: { invoiceNumber?: string }) => ({
invoiceID: TEST_XERO_INVOICE.id,
invoiceNumber: invoice.invoiceNumber,
status: 'AUTHORISED',
})),
...overrides,
}
}
Expand Down
55 changes: 54 additions & 1 deletion test/helpers/seed.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,20 @@
import { TEST_PORTAL, TEST_PRODUCT, TEST_TOKENS, TEST_XERO_ITEM } from '@test/helpers/constants'
import {
TEST_CLIENT,
TEST_INVOICE,
TEST_PORTAL,
TEST_PRODUCT,
TEST_SALES_ACCOUNT,
TEST_TOKENS,
TEST_XERO_CONTACT,
TEST_XERO_INVOICE,
TEST_XERO_ITEM,
} from '@test/helpers/constants'
import type { InferInsertModel } from 'drizzle-orm'
import type { TokenSet } from 'xero-node'
import db from '@/db'
import { settings } from '@/db/schema/settings.schema'
import { SyncedContactUserType, syncedContacts } from '@/db/schema/syncedContacts.schema'
import { syncedInvoices } from '@/db/schema/syncedInvoices.schema'
import { syncedItems } from '@/db/schema/syncedItems.schema'
import { xeroConnections } from '@/db/schema/xeroConnections.schema'

Expand Down Expand Up @@ -81,3 +93,44 @@ export async function seedSyncedItem(overrides: SyncedItemOverrides = {}) {
.returning()
return row
}

type SyncedInvoiceOverrides = Partial<InferInsertModel<typeof syncedInvoices>>

const baseSyncedInvoice: InferInsertModel<typeof syncedInvoices> = {
portalId: TEST_PORTAL.id,
tenantId: TEST_PORTAL.tenantId,
copilotInvoiceId: TEST_INVOICE.id,
xeroInvoiceId: TEST_XERO_INVOICE.id,
salesAccountId: TEST_SALES_ACCOUNT.id,
status: 'success',
}

// Seeds a synced_invoices row. Defaults to a fully-synced ('success') invoice
// for idempotency tests; override `status`/ids as needed.
export async function seedSyncedInvoice(overrides: SyncedInvoiceOverrides = {}) {
const [row] = await db
.insert(syncedInvoices)
.values({ ...baseSyncedInvoice, ...overrides })
.returning()
return row
}

type SyncedContactOverrides = Partial<InferInsertModel<typeof syncedContacts>>

const baseSyncedContact: InferInsertModel<typeof syncedContacts> = {
portalId: TEST_PORTAL.id,
tenantId: TEST_PORTAL.tenantId,
clientOrCompanyId: TEST_CLIENT.id,
userType: SyncedContactUserType.CLIENT,
contactId: TEST_XERO_CONTACT.id,
}

// Seeds a synced_contacts row (client-billed by default). Available for the
// contact-reuse path; not used by the baseline new-contact tests.
export async function seedSyncedContact(overrides: SyncedContactOverrides = {}) {
const [row] = await db
.insert(syncedContacts)
.values({ ...baseSyncedContact, ...overrides })
.returning()
return row
}
Original file line number Diff line number Diff line change
Expand Up @@ -4,16 +4,16 @@ import { beforeEach } from 'vitest'

type InstallOpts = Parameters<typeof installMockApis>[0]

export interface ProductCreatedTestHandle {
export interface WebhookTestHandle {
copilot: MockCopilotAPI
xero: MockXeroAPI
}

// beforeEach for product.created tests: truncates the DB and installs fresh
// beforeEach for webhook integration tests: truncates the DB and installs fresh
// mocks. Returns a handle with the current test's copilot/xero mocks.
// `optsFactory` runs per test so overrides get fresh vi.fn()s.
export function setupProductCreatedTest(optsFactory?: () => InstallOpts): ProductCreatedTestHandle {
const handle = {} as ProductCreatedTestHandle
export function setupWebhookTest(optsFactory?: () => InstallOpts): WebhookTestHandle {
const handle = {} as WebhookTestHandle

beforeEach(async () => {
await truncateAllTestTables()
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
import { buildInvoiceCreatedWebhook } from '@test/fixtures/invoiceCreated.webhook'
import { seedConnectedPortal } from '@test/helpers/seed'
import { postWebhook } from '@test/helpers/webhook'
import { setupWebhookTest } from '@test/helpers/webhookTestSetup'
import { describe, expect, it } from 'vitest'
import db from '@/db'
import { failedSyncs } from '@/db/schema/failedSyncs.schema'
import { syncedInvoices } from '@/db/schema/syncedInvoices.schema'

describe('POST /api/webhook — invoice.created chargeAutomatically', () => {
const apis = setupWebhookTest()

it('skips a chargeAutomatically invoice without syncing', async () => {
await seedConnectedPortal()

const res = await postWebhook(
buildInvoiceCreatedWebhook({ collectionMethod: 'chargeAutomatically' }),
)
expect(res.status).toBe(200)

expect(apis.xero.createInvoice).not.toHaveBeenCalled()
expect(await db.select().from(syncedInvoices)).toHaveLength(0)
expect(await db.select().from(failedSyncs)).toHaveLength(0)
})
})
24 changes: 24 additions & 0 deletions test/integration/webhook/invoiceCreated/draftInvoice.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
import { buildInvoiceCreatedWebhook } from '@test/fixtures/invoiceCreated.webhook'
import { seedConnectedPortal } from '@test/helpers/seed'
import { postWebhook } from '@test/helpers/webhook'
import { setupWebhookTest } from '@test/helpers/webhookTestSetup'
import { describe, expect, it } from 'vitest'
import db from '@/db'
import { failedSyncs } from '@/db/schema/failedSyncs.schema'
import { syncedInvoices } from '@/db/schema/syncedInvoices.schema'

describe('POST /api/webhook — invoice.created draft', () => {
const apis = setupWebhookTest()

it('acks a draft invoice without syncing or recording a failure', async () => {
await seedConnectedPortal()

const res = await postWebhook(buildInvoiceCreatedWebhook({ status: 'draft' }))
expect(res.status).toBe(200)

expect(apis.xero.createInvoice).not.toHaveBeenCalled()
expect(await db.select().from(syncedInvoices)).toHaveLength(0)
// Draft is ignored via APIError(OK) — not a failure.
expect(await db.select().from(failedSyncs)).toHaveLength(0)
})
})
Loading
Loading