import { describe, it, expect, vi, beforeEach } from 'vitest' import { createMockRequest, createMockRouteParams, parseJsonResponse } from '@/tests/helpers' import { eventBus } from '@/lib/events' vi.mock('@/lib/init', () => ({ ensureInitialized: vi.fn(), })) vi.mock('@/lib/supabase/server', () => ({ createClient: vi.fn(), createServiceClient: vi.fn(), })) vi.mock('@/lib/auth/require-auth', () => ({ requireAuth: vi.fn(), })) import { createServiceClient } from '@/lib/supabase/server' import { requireAuth } from '@/lib/auth/require-auth' import { POST } from '../route' const mockRequireAuth = vi.mocked(requireAuth) const mockCreateServiceClient = vi.mocked(createServiceClient) type Row = { data: unknown; error: unknown } function mockService( rowsByTable: Record, updateResults: Record = {}, ) { const insertSpy = vi.fn().mockResolvedValue({ data: null, error: null }) const updateSpies: Record> = {} const from = vi.fn().mockImplementation((table: string) => { const queue = rowsByTable[table] ?? [] const next = queue.shift() ?? { data: null, error: null } // Awaitable at any depth: update().eq(...), update().eq(...).eq(...), // update().eq(...).neq(...).select(...) all resolve to the table's // configured update result (default: one revoked row, no error). const updateResult = updateResults[table] ?? { data: [{ id: `${table}-1` }], error: null } const updateChain: { eq: ReturnType neq: ReturnType select: ReturnType then: (resolve: (v: unknown) => void) => void } = { eq: vi.fn(() => updateChain), neq: vi.fn(() => updateChain), select: vi.fn(() => updateChain), then: (resolve: (v: unknown) => void) => resolve(updateResult), } const updateFn = vi.fn().mockReturnValue(updateChain) updateSpies[table] = updateFn return { select: vi.fn().mockReturnValue({ eq: vi.fn().mockReturnValue({ eq: vi.fn().mockReturnValue({ maybeSingle: vi.fn().mockResolvedValue(next), }), maybeSingle: vi.fn().mockResolvedValue(next), }), }), update: updateFn, insert: insertSpy, } }) // eslint-disable-next-line @typescript-eslint/no-explicit-any mockCreateServiceClient.mockReturnValue({ from } as any) return { from, insertSpy, updateSpies } } function mockAuth(userId: string | null) { if (userId) { mockRequireAuth.mockResolvedValue({ // eslint-disable-next-line @typescript-eslint/no-explicit-any user: { id: userId, email: 'u@example.com' } as any, // eslint-disable-next-line @typescript-eslint/no-explicit-any supabase: {} as any, error: null, }) } else { mockRequireAuth.mockResolvedValue({ user: null, // eslint-disable-next-line @typescript-eslint/no-explicit-any supabase: {} as any, // eslint-disable-next-line @typescript-eslint/no-explicit-any error: { status: 401 } as any, }) } } beforeEach(() => { vi.clearAllMocks() eventBus.clear() }) describe('POST /api/company/[id]/delete', () => { it('returns 401 when unauthenticated', async () => { mockAuth(null) const req = createMockRequest('/api/company/c1/delete', { method: 'POST', body: { confirm_name: 'Acme' }, }) const res = await POST(req, createMockRouteParams({ id: 'c1' })) // requireAuth already returned a NextResponse error expect(res).toBeDefined() }) it('returns 400 when confirm_name does not match', async () => { mockAuth('user-1') mockService({ companies: [{ data: { id: 'c1', name: 'Acme AB', archived_at: null }, error: null }], company_members: [{ data: { role: 'owner' }, error: null }], }) const req = createMockRequest('/api/company/c1/delete', { method: 'POST', body: { confirm_name: 'Wrong Name' }, }) const { status, body } = await parseJsonResponse( await POST(req, createMockRouteParams({ id: 'c1' })) ) expect(status).toBe(400) expect(body).toHaveProperty('error') }) it('accepts the settings display name when companies.name is stale', async () => { // Regression: the UI shows company_settings.company_name (the dialog, // label and the enable-gate all use it), but companies.name can be stale. // The server must accept the displayed name, not just the raw column. mockAuth('user-1') mockService({ companies: [{ data: { id: 'c1', name: 'Gammalt namn', archived_at: null }, error: null }], company_members: [{ data: { role: 'owner' }, error: null }], company_settings: [{ data: { company_name: 'Nytt namn' }, error: null }], }) const req = createMockRequest('/api/company/c1/delete', { method: 'POST', body: { confirm_name: 'Nytt namn' }, }) const { status } = await parseJsonResponse( await POST(req, createMockRouteParams({ id: 'c1' })) ) expect(status).toBe(200) }) it('rejects the stale companies.name when a settings name exists (only the displayed name is accepted)', async () => { // Security: the UI only ever shows company_settings.company_name when set, so // the server must not accept the stale companies.name as an alternative // confirmation: that would be a delete path the user was never shown. mockAuth('user-1') mockService({ companies: [{ data: { id: 'c1', name: 'Gammalt namn', archived_at: null }, error: null }], company_members: [{ data: { role: 'owner' }, error: null }], company_settings: [{ data: { company_name: 'Nytt namn' }, error: null }], }) const req = createMockRequest('/api/company/c1/delete', { method: 'POST', body: { confirm_name: 'Gammalt namn' }, }) const { status } = await parseJsonResponse( await POST(req, createMockRouteParams({ id: 'c1' })) ) expect(status).toBe(400) }) it('returns 403 when caller is member but not owner', async () => { mockAuth('user-1') mockService({ companies: [{ data: { id: 'c1', name: 'Acme AB', archived_at: null }, error: null }], company_members: [{ data: { role: 'member' }, error: null }], }) const req = createMockRequest('/api/company/c1/delete', { method: 'POST', body: { confirm_name: 'Acme AB' }, }) const { status } = await parseJsonResponse( await POST(req, createMockRouteParams({ id: 'c1' })) ) expect(status).toBe(403) }) it('returns 404 when company is already archived', async () => { mockAuth('user-1') mockService({ companies: [ { data: { id: 'c1', name: 'Acme AB', archived_at: '2026-01-01T00:00:00Z', }, error: null, }, ], }) const req = createMockRequest('/api/company/c1/delete', { method: 'POST', body: { confirm_name: 'Acme AB' }, }) const { status } = await parseJsonResponse( await POST(req, createMockRouteParams({ id: 'c1' })) ) expect(status).toBe(404) }) it('archives company, writes audit log, emits event on happy path', async () => { mockAuth('user-1') const { insertSpy, updateSpies } = mockService({ companies: [{ data: { id: 'c1', name: 'Acme AB', archived_at: null }, error: null }], company_members: [{ data: { role: 'owner' }, error: null }], }) const emitted: unknown[] = [] eventBus.on('company.deleted', (payload) => { emitted.push(payload) }) const req = createMockRequest('/api/company/c1/delete', { method: 'POST', body: { confirm_name: 'Acme AB' }, }) const { status, body } = await parseJsonResponse<{ data: { companyId: string; archivedAt: string } }>(await POST(req, createMockRouteParams({ id: 'c1' }))) expect(status).toBe(200) expect(body.data.companyId).toBe('c1') expect(body.data.archivedAt).toBeTruthy() // Verified the companies.update was called (archived_at + archived_by) expect(updateSpies.companies).toHaveBeenCalledWith( expect.objectContaining({ archived_at: expect.any(String), archived_by: 'user-1', }) ) // Audit log row written expect(insertSpy).toHaveBeenCalledWith( expect.objectContaining({ action: 'DELETE', table_name: 'companies', record_id: 'c1', user_id: 'user-1', }) ) // Event emitted expect(emitted).toHaveLength(1) expect(emitted[0]).toMatchObject({ companyId: 'c1', userId: 'user-1' }) }) it('revokes store connections on archive so the store-uniqueness indexes free up', async () => { // Regression: archived companies are hidden by user_company_ids(), so an // 'active' connection left behind blocks reconnecting the same store from // a new company ("Butiken är redan ansluten till ett företag") with no // user-reachable disconnect. mockAuth('user-1') const { updateSpies } = mockService({ companies: [{ data: { id: 'c1', name: 'Acme AB', archived_at: null }, error: null }], company_members: [{ data: { role: 'owner' }, error: null }], }) const req = createMockRequest('/api/company/c1/delete', { method: 'POST', body: { confirm_name: 'Acme AB' }, }) const { status } = await parseJsonResponse( await POST(req, createMockRouteParams({ id: 'c1' })) ) expect(status).toBe(200) expect(updateSpies.woocommerce_connections).toHaveBeenCalledWith( expect.objectContaining({ status: 'revoked', disconnected_at: expect.any(String), consumer_key_encrypted: null, consumer_secret_encrypted: null, oauth_state: null, }) ) expect(updateSpies.shopify_connections).toHaveBeenCalledWith( expect.objectContaining({ status: 'revoked', disconnected_at: expect.any(String), client_id_encrypted: null, client_secret_encrypted: null, }) ) expect(updateSpies.stripe_connections).toHaveBeenCalledWith( expect.objectContaining({ status: 'revoked', disconnected_at: expect.any(String), oauth_state: null, }) ) }) it('writes audit_log rows for each revoked connection', async () => { mockAuth('user-1') const { insertSpy } = mockService({ companies: [{ data: { id: 'c1', name: 'Acme AB', archived_at: null }, error: null }], company_members: [{ data: { role: 'owner' }, error: null }], }) const req = createMockRequest('/api/company/c1/delete', { method: 'POST', body: { confirm_name: 'Acme AB' }, }) const { status } = await parseJsonResponse( await POST(req, createMockRouteParams({ id: 'c1' })) ) expect(status).toBe(200) // One audit insert per revoked connection row. for (const table of [ 'woocommerce_connections', 'shopify_connections', 'stripe_connections', ]) { expect(insertSpy).toHaveBeenCalledWith( expect.objectContaining({ action: 'UPDATE', table_name: table, record_id: `${table}-1`, company_id: 'c1', user_id: 'user-1', new_state: expect.objectContaining({ status: 'revoked' }), }), ) } }) it('still archives the company when a connection revoke update fails', async () => { mockAuth('user-1') const { insertSpy, updateSpies } = mockService( { companies: [{ data: { id: 'c1', name: 'Acme AB', archived_at: null }, error: null }], company_members: [{ data: { role: 'owner' }, error: null }], }, { woocommerce_connections: { data: null, error: { message: 'boom' } }, }, ) const req = createMockRequest('/api/company/c1/delete', { method: 'POST', body: { confirm_name: 'Acme AB' }, }) const { status } = await parseJsonResponse( await POST(req, createMockRouteParams({ id: 'c1' })) ) // Non-fatal by design: the archive already happened. expect(status).toBe(200) expect(updateSpies.companies).toHaveBeenCalled() // No audit rows for the failed table, but the other tables still processed. expect(insertSpy).not.toHaveBeenCalledWith( expect.objectContaining({ table_name: 'woocommerce_connections' }), ) expect(insertSpy).toHaveBeenCalledWith( expect.objectContaining({ table_name: 'shopify_connections' }), ) }) })