Files
accounted/app/api/extensions/invoice-inbox/webhook/__tests__/route.test.ts
T
MattssonandClaude Opus 4.6 0dd1f5ebc1 feat: multi-tenant company refactor (GNU-19) (#153)
* feat: multi-tenant company refactor (GNU-19)

Introduce companies table, company_members, and user_preferences to
support multiple companies per user. All data scoping changes from
user_id to company_id across the entire codebase.

Key changes:
- Database migration: new tables, company_id on 40+ tables, backfill,
  RLS rewrite from user_id to company-member-based, updated RPCs
- Types: Company, CompanyMember, CompanyRole, UserPreferences types;
  company_id added to all entity interfaces; companyId on all events
- Engine: all 7 core functions take companyId; storno, period, year-end
  services updated; 16 report generators updated
- Middleware: company context resolution (cookie → prefs → first company)
- API routes: ~120 routes updated with requireCompanyId()
- Frontend: CompanyProvider context, layout/dashboard/onboarding updated
- Extensions: context factory, 9 extensions, all lib files updated
- Tests: 1880 tests passing, all helpers updated with company_id defaults

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* feat: add database migrations for multi-tenant company and team system (GNU-19)

Adds company_invitations, company creation RPC, team_members, account
deletion RPC, and teams table refactor migrations. Updates base
multi-tenant migration with cascading FKs and onboarding_step column.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* feat: add team types and update core infrastructure for multi-tenancy (GNU-19)

Adds TeamRole, MemberSource, and Team types. Refactors Supabase service
client to be stateless, updates middleware for team-aware routing, extends
CompanyContext with team/role fields, and updates extension service types
to accept companyId.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* refactor: thread company_id through business logic functions (GNU-19)

Replaces user_id scoping with company_id across all lib modules:
bookkeeping, documents, transactions, invoices, reconciliation, tax,
deadlines, and import. Updates corresponding tests.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* refactor: thread company_id through API routes and extensions (GNU-19)

Updates all existing API routes to extract and pass companyId. Updates
enable-banking and arcim-migration extensions for company-scoped
transaction ingestion and sync.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* feat: add company and team management API routes (GNU-19)

Adds CRUD endpoints for company members, company invitations, team
members, and team invitations. Includes invite token utilities, email
templates, and company switch server action.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* feat: add team/company UI components, pages, and dashboard updates (GNU-19)

Adds CompanySwitcher, ConsultantEmptyState, Step0RoleChoice, company
members and team management panels. Updates dashboard layout for
team-aware routing, onboarding for multi-step role choice, and auth
callback for team invite acceptance. Ignores supabase/.branches/.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: add null guards for company in import page (GNU-19)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: move appUrl declaration to outer scope in invite route (GNU-19)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: add optional chaining for company.name in members section (GNU-19)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: add optional chaining for second company.name in members section (GNU-19)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: add null guards for company in extension components (GNU-19)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: pass companyId to executeSIEImport in arcim-migration extension (GNU-19)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: update tests to use companyId instead of userId and improve type handling

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-31 16:41:52 +02:00

159 lines
5.0 KiB
TypeScript

import { describe, it, expect, vi, beforeEach } from 'vitest'
import { createQueuedMockSupabase, parseJsonResponse } from '@/tests/helpers'
// Mock server-only
vi.mock('server-only', () => ({}))
// Hoisted mocks to avoid reference-before-initialization
const { mockVerify, mockServiceClientFn } = vi.hoisted(() => {
return {
mockVerify: vi.fn(),
mockServiceClientFn: vi.fn(),
}
})
// Mock svix
vi.mock('svix', () => ({
Webhook: class MockWebhook {
verify = mockVerify
},
}))
// Mock Supabase SSR
vi.mock('@supabase/ssr', () => ({
createServerClient: (...args: unknown[]) => mockServiceClientFn(...args),
}))
// Mock email handler
vi.mock('@/extensions/general/invoice-inbox/lib/email-handler', () => ({
parseInboundPayload: vi.fn(),
extractAttachments: vi.fn(),
resolveUserFromEmail: vi.fn(),
}))
// Mock unified document analyzer
vi.mock('@/lib/ai/document-analyzer', () => ({
analyzeDocument: vi.fn(),
}))
// Mock supplier matcher
vi.mock('@/extensions/general/invoice-inbox/lib/supplier-matcher', () => ({
matchSupplier: vi.fn(),
}))
// Mock receipt pipeline
vi.mock('@/extensions/general/receipt-ocr/lib/receipt-pipeline', () => ({
processReceiptFromDocument: vi.fn(),
}))
import { POST } from '../route'
import { parseInboundPayload, extractAttachments, resolveUserFromEmail } from '@/extensions/general/invoice-inbox/lib/email-handler'
const mockParseInboundPayload = vi.mocked(parseInboundPayload)
const mockExtractAttachments = vi.mocked(extractAttachments)
const mockResolveUserFromEmail = vi.mocked(resolveUserFromEmail)
describe('Invoice Inbox Webhook Route', () => {
beforeEach(() => {
vi.clearAllMocks()
process.env.RESEND_WEBHOOK_SECRET = 'test-secret'
process.env.NEXT_PUBLIC_SUPABASE_URL = 'http://localhost:54321'
process.env.SUPABASE_SERVICE_ROLE_KEY = 'test-key'
})
function makeWebhookRequest(body: unknown = {}) {
const bodyStr = JSON.stringify(body)
return new Request('http://localhost:3000/api/extensions/invoice-inbox/webhook', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'svix-id': 'msg_test123',
'svix-timestamp': String(Math.floor(Date.now() / 1000)),
'svix-signature': 'v1,test-signature',
},
body: bodyStr,
})
}
it('returns 400 when webhook headers are missing', async () => {
const request = new Request('http://localhost:3000/api/extensions/invoice-inbox/webhook', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: '{}',
})
const response = await POST(request)
const { status } = await parseJsonResponse(response)
expect(status).toBe(400)
})
it('returns 401 when signature verification fails', async () => {
mockVerify.mockImplementation(() => {
throw new Error('Invalid signature')
})
const response = await POST(makeWebhookRequest({ from: 'test@test.com', to: 'inbox@co.com' }))
const { status } = await parseJsonResponse(response)
expect(status).toBe(401)
})
it('returns 400 when payload is invalid', async () => {
mockVerify.mockReturnValue(undefined)
mockParseInboundPayload.mockReturnValue(null)
const response = await POST(makeWebhookRequest({}))
const { status } = await parseJsonResponse(response)
expect(status).toBe(400)
})
it('returns 404 when user not found for email', async () => {
const { supabase } = createQueuedMockSupabase()
mockServiceClientFn.mockReturnValue(supabase)
mockVerify.mockReturnValue(undefined)
mockParseInboundPayload.mockReturnValue({
from: 'supplier@test.com',
to: 'unknown@inbox.com',
subject: 'Invoice',
html: null,
text: null,
attachments: [],
created_at: '2024-06-15T10:00:00Z',
})
mockResolveUserFromEmail.mockResolvedValue(null)
const response = await POST(makeWebhookRequest({ from: 'supplier@test.com', to: 'unknown@inbox.com' }))
const { status } = await parseJsonResponse(response)
expect(status).toBe(404)
})
it('returns success with 0 processed when no attachments', async () => {
const { supabase, enqueueMany } = createQueuedMockSupabase()
mockServiceClientFn.mockReturnValue(supabase)
mockVerify.mockReturnValue(undefined)
mockParseInboundPayload.mockReturnValue({
from: 'supplier@test.com',
to: 'inbox@myco.com',
subject: 'No attachments',
html: null,
text: null,
attachments: [],
created_at: '2024-06-15T10:00:00Z',
})
mockExtractAttachments.mockReturnValue([])
mockResolveUserFromEmail.mockResolvedValue({ userId: 'user-1', companyId: 'company-1' })
// Insert inbox item with error status
enqueueMany([
{ data: { id: 'item-1' }, error: null },
])
const response = await POST(makeWebhookRequest({ from: 'supplier@test.com', to: 'inbox@myco.com' }))
const { status, body } = await parseJsonResponse<{ data: { processed: number } }>(response)
expect(status).toBe(200)
expect(body.data.processed).toBe(0)
})
})