Fixes #701. setActiveCompany upserted active_company_id without checking the result, then set the gnubok-company-id cookie unconditionally. A failed write — including an RLS-filtered UPDATE, which affects zero rows without raising an error — looked like a successful switch: switchCompany returned {}, the UI hard-reloaded, and middleware (which reads user_preferences, not the cookie) resolved the old company. - setActiveCompany now verifies the upsert with .select().single() and throws a typed CompanyContextError ('not_member' | 'persist_failed'); the cookie is only set after the write is confirmed, so it can no longer diverge from the database. - switchCompany logs the failure and returns distinct error codes instead of reporting every failure as a permissions problem. - CompanySwitcher now shows a destructive toast on failure (it previously failed with no feedback); BankIdCompanyPicker translates the codes. Messages added to sv/en under company_switcher and select_company. - The remaining fire-and-forget user_preferences writers (middleware fallback write-back, team invite accept, auth callback invite accept) now check and log errors; non-fatal by design since each has a working fallback path. - New tests cover every failure mode, including cookie-not-set on a failed write and the silent zero-row write caught by the read-back. Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
332 lines
11 KiB
TypeScript
332 lines
11 KiB
TypeScript
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
|
|
|
vi.mock('next/cache', () => ({
|
|
revalidatePath: vi.fn(),
|
|
}))
|
|
|
|
vi.mock('@/lib/supabase/server', () => ({
|
|
createClient: vi.fn(),
|
|
}))
|
|
|
|
// Keep the real CompanyContextError so instanceof checks in switchCompany
|
|
// see the same class the tests throw.
|
|
vi.mock('@/lib/company/context', async (importOriginal) => ({
|
|
...(await importOriginal<typeof import('@/lib/company/context')>()),
|
|
setActiveCompany: vi.fn().mockResolvedValue(undefined),
|
|
}))
|
|
|
|
import { createClient } from '@/lib/supabase/server'
|
|
import { setActiveCompany, CompanyContextError } from '@/lib/company/context'
|
|
import { createCompanyFromOnboarding, switchCompany } from '../actions'
|
|
|
|
const mockCreateClient = vi.mocked(createClient)
|
|
const mockSetActiveCompany = vi.mocked(setActiveCompany)
|
|
|
|
type CapturedCall = { table: string; method: string; args: unknown[] }
|
|
|
|
/**
|
|
* Builds a chainable Supabase mock that records every method call, allows
|
|
* per-table result seeding, and returns a capture log the test can assert on.
|
|
*
|
|
* - `results[table][method]` (optional) is returned when the chain ends on
|
|
* that method. Chains otherwise resolve to `{ data: null, error: null }`.
|
|
* - Unknown methods on the chain no-op and return the chain so callers can
|
|
* keep chaining freely.
|
|
*/
|
|
function buildSupabase(opts: {
|
|
user: { id: string } | null
|
|
results?: Record<string, Record<string, { data?: unknown; error?: unknown }>>
|
|
rpcResults?: Record<string, { data?: unknown; error?: unknown }>
|
|
}) {
|
|
const calls: CapturedCall[] = []
|
|
const { user, results = {}, rpcResults = {} } = opts
|
|
|
|
function makeChain(table: string) {
|
|
const record = (method: string, args: unknown[]) => {
|
|
calls.push({ table, method, args })
|
|
}
|
|
const chain: Record<string, unknown> = {}
|
|
const methods = ['select', 'eq', 'is', 'in', 'order', 'limit', 'maybeSingle', 'single', 'insert', 'upsert', 'delete', 'update']
|
|
for (const m of methods) {
|
|
chain[m] = (...args: unknown[]) => {
|
|
record(m, args)
|
|
const canTerminate = results[table]?.[m]
|
|
if (canTerminate) {
|
|
return Promise.resolve({
|
|
data: canTerminate.data ?? null,
|
|
error: canTerminate.error ?? null,
|
|
})
|
|
}
|
|
return chain
|
|
}
|
|
}
|
|
chain.then = (resolve: (v: unknown) => void) => resolve({ data: null, error: null })
|
|
return chain
|
|
}
|
|
|
|
const supabase = {
|
|
auth: {
|
|
getUser: vi.fn().mockResolvedValue({ data: { user } }),
|
|
},
|
|
from: vi.fn().mockImplementation((table: string) => makeChain(table)),
|
|
rpc: vi.fn().mockImplementation((name: string) => {
|
|
const result = rpcResults[name]
|
|
if (result) {
|
|
return Promise.resolve({ data: result.data ?? null, error: result.error ?? null })
|
|
}
|
|
return Promise.resolve({ data: null, error: null })
|
|
}),
|
|
}
|
|
|
|
return { supabase, calls }
|
|
}
|
|
|
|
beforeEach(() => {
|
|
vi.clearAllMocks()
|
|
})
|
|
|
|
describe('switchCompany', () => {
|
|
it('returns {} when the switch persists', async () => {
|
|
const { supabase } = buildSupabase({ user: { id: 'user-1' } })
|
|
mockCreateClient.mockResolvedValue(supabase as never)
|
|
|
|
const result = await switchCompany('company-2')
|
|
|
|
expect(result).toEqual({})
|
|
expect(mockSetActiveCompany).toHaveBeenCalledWith(supabase, 'user-1', 'company-2')
|
|
})
|
|
|
|
it('returns Unauthorized when there is no user', async () => {
|
|
const { supabase } = buildSupabase({ user: null })
|
|
mockCreateClient.mockResolvedValue(supabase as never)
|
|
|
|
const result = await switchCompany('company-2')
|
|
|
|
expect(result).toEqual({ error: 'Unauthorized' })
|
|
expect(mockSetActiveCompany).not.toHaveBeenCalled()
|
|
})
|
|
|
|
it('maps a membership failure to the not_member code', async () => {
|
|
const { supabase } = buildSupabase({ user: { id: 'user-1' } })
|
|
mockCreateClient.mockResolvedValue(supabase as never)
|
|
mockSetActiveCompany.mockRejectedValueOnce(
|
|
new CompanyContextError('User is not a member of this company', 'not_member'),
|
|
)
|
|
|
|
const result = await switchCompany('company-2')
|
|
|
|
expect(result).toEqual({ error: 'not_member' })
|
|
})
|
|
|
|
it('maps a failed user_preferences write to persist_failed, not a permissions error (#701)', async () => {
|
|
const { supabase } = buildSupabase({ user: { id: 'user-1' } })
|
|
mockCreateClient.mockResolvedValue(supabase as never)
|
|
mockSetActiveCompany.mockRejectedValueOnce(
|
|
new CompanyContextError('Failed to persist active company: timeout', 'persist_failed'),
|
|
)
|
|
|
|
const result = await switchCompany('company-2')
|
|
|
|
expect(result).toEqual({ error: 'persist_failed' })
|
|
})
|
|
|
|
it('maps unexpected errors to persist_failed rather than claiming missing access', async () => {
|
|
const { supabase } = buildSupabase({ user: { id: 'user-1' } })
|
|
mockCreateClient.mockResolvedValue(supabase as never)
|
|
mockSetActiveCompany.mockRejectedValueOnce(new Error('cookies unavailable'))
|
|
|
|
const result = await switchCompany('company-2')
|
|
|
|
expect(result).toEqual({ error: 'persist_failed' })
|
|
})
|
|
})
|
|
|
|
describe('createCompanyFromOnboarding — org_number validation', () => {
|
|
it('rejects malformed org_numbers at the guard boundary', async () => {
|
|
const { supabase } = buildSupabase({
|
|
user: { id: 'user-1' },
|
|
rpcResults: { create_company_with_owner: { data: 'x' } },
|
|
})
|
|
mockCreateClient.mockResolvedValue(supabase as never)
|
|
|
|
const result = await createCompanyFromOnboarding({
|
|
teamId: 'team-1',
|
|
settings: {
|
|
entity_type: 'aktiebolag',
|
|
company_name: 'Broken AB',
|
|
org_number: 'abc123', // not a 10- or 12-digit number
|
|
},
|
|
fiscalPeriod: {
|
|
startDate: '2026-01-01',
|
|
endDate: '2026-12-31',
|
|
name: 'Räkenskapsår 2026',
|
|
},
|
|
})
|
|
|
|
expect(result.error).toBe('org_number_invalid')
|
|
// Must NOT have reached the create RPC — otherwise we'd save a malformed
|
|
// org_number and poison SIE/SRU exports.
|
|
const rpcCreate = supabase.rpc.mock.calls.find(([name]) => name === 'create_company_with_owner')
|
|
expect(rpcCreate).toBeUndefined()
|
|
})
|
|
|
|
it('rejects right-length org_numbers with invalid Luhn check digit', async () => {
|
|
const { supabase } = buildSupabase({
|
|
user: { id: 'user-1' },
|
|
rpcResults: { create_company_with_owner: { data: 'x' } },
|
|
})
|
|
mockCreateClient.mockResolvedValue(supabase as never)
|
|
|
|
const result = await createCompanyFromOnboarding({
|
|
teamId: 'team-1',
|
|
settings: {
|
|
entity_type: 'aktiebolag',
|
|
company_name: 'Fake AB',
|
|
// 10 digits but Luhn check digit is wrong (real Volvo is 5560125790;
|
|
// the trailing 1 is an intentional off-by-one). Skatteverket SRU
|
|
// validators and receiving SIE4 consumers would reject this, so we
|
|
// refuse at the boundary.
|
|
org_number: '5560125791',
|
|
},
|
|
fiscalPeriod: {
|
|
startDate: '2026-01-01',
|
|
endDate: '2026-12-31',
|
|
name: 'Räkenskapsår 2026',
|
|
},
|
|
})
|
|
|
|
expect(result.error).toBe('org_number_invalid')
|
|
const rpcCreate = supabase.rpc.mock.calls.find(([name]) => name === 'create_company_with_owner')
|
|
expect(rpcCreate).toBeUndefined()
|
|
})
|
|
})
|
|
|
|
describe('createCompanyFromOnboarding — TIC snapshot persistence', () => {
|
|
it('persists the supplied ticLookup to companies.tic_snapshot', async () => {
|
|
const { supabase, calls } = buildSupabase({
|
|
user: { id: 'user-1' },
|
|
rpcResults: {
|
|
create_company_with_owner: { data: 'new-company-id' },
|
|
seed_chart_of_accounts: { data: null },
|
|
},
|
|
})
|
|
mockCreateClient.mockResolvedValue(supabase as never)
|
|
|
|
const ticLookup = {
|
|
companyName: 'Acme AB',
|
|
isCeased: false,
|
|
address: { street: 'Storgatan 1', postalCode: '11122', city: 'Stockholm' },
|
|
registration: { fTax: true, vat: true },
|
|
bankAccounts: [],
|
|
email: null,
|
|
phone: null,
|
|
sniCodes: [{ code: '62010', name: 'Dataprogrammering' }],
|
|
fiscalYear: { startMonthDay: '01-01', endMonthDay: '12-31' },
|
|
legalEntityType: 'AB',
|
|
registrationDate: 0,
|
|
}
|
|
|
|
const result = await createCompanyFromOnboarding({
|
|
teamId: 'team-1',
|
|
settings: {
|
|
entity_type: 'aktiebolag',
|
|
company_name: 'Acme AB',
|
|
org_number: '5560125790',
|
|
},
|
|
fiscalPeriod: {
|
|
startDate: '2026-01-01',
|
|
endDate: '2026-12-31',
|
|
name: 'Räkenskapsår 2026',
|
|
},
|
|
ticLookup,
|
|
})
|
|
|
|
expect(result.companyId).toBe('new-company-id')
|
|
|
|
// The lookup must have been UPDATEd onto the freshly-created company row.
|
|
// Two updates run on `companies`: one for org_number, one for tic_snapshot.
|
|
const companyUpdates = calls.filter(
|
|
(c) => c.table === 'companies' && c.method === 'update',
|
|
)
|
|
const snapshotUpdate = companyUpdates.find((c) => {
|
|
const payload = c.args[0] as Record<string, unknown>
|
|
return 'tic_snapshot' in payload
|
|
})
|
|
expect(snapshotUpdate).toBeDefined()
|
|
const payload = snapshotUpdate!.args[0] as Record<string, unknown>
|
|
expect(payload.tic_snapshot).toEqual(ticLookup)
|
|
expect(payload.tic_snapshot_fetched_at).toBeDefined()
|
|
})
|
|
|
|
it('skips the snapshot update when no ticLookup is supplied (manual signup)', async () => {
|
|
const { supabase, calls } = buildSupabase({
|
|
user: { id: 'user-1' },
|
|
rpcResults: {
|
|
create_company_with_owner: { data: 'new-company-id' },
|
|
seed_chart_of_accounts: { data: null },
|
|
},
|
|
})
|
|
mockCreateClient.mockResolvedValue(supabase as never)
|
|
|
|
const result = await createCompanyFromOnboarding({
|
|
teamId: 'team-1',
|
|
settings: {
|
|
entity_type: 'aktiebolag',
|
|
company_name: 'Manual AB',
|
|
// No org_number — exercises the path where the org_number UPDATE also
|
|
// doesn't run, so we can isolate the no-snapshot guarantee.
|
|
},
|
|
fiscalPeriod: {
|
|
startDate: '2026-01-01',
|
|
endDate: '2026-12-31',
|
|
name: 'Räkenskapsår 2026',
|
|
},
|
|
// ticLookup intentionally omitted
|
|
})
|
|
|
|
expect(result.companyId).toBe('new-company-id')
|
|
|
|
// No update touched tic_snapshot at all.
|
|
const snapshotUpdate = calls.find((c) => {
|
|
if (c.table !== 'companies' || c.method !== 'update') return false
|
|
const payload = c.args[0] as Record<string, unknown>
|
|
return 'tic_snapshot' in payload
|
|
})
|
|
expect(snapshotUpdate).toBeUndefined()
|
|
})
|
|
|
|
it('does NOT call the heavy /profile endpoint at signup (regression: was 13 calls/signup)', async () => {
|
|
// The signup path used to call ensureTicSnapshot which fetches /profile.
|
|
// We removed it because it timed out 100% of the time, costing 13 Lens
|
|
// calls each. This test prevents anyone from re-adding it by checking
|
|
// that fetch is never invoked during the action.
|
|
vi.stubGlobal('fetch', vi.fn())
|
|
|
|
const { supabase } = buildSupabase({
|
|
user: { id: 'user-1' },
|
|
rpcResults: {
|
|
create_company_with_owner: { data: 'new-company-id' },
|
|
seed_chart_of_accounts: { data: null },
|
|
},
|
|
})
|
|
mockCreateClient.mockResolvedValue(supabase as never)
|
|
|
|
await createCompanyFromOnboarding({
|
|
teamId: 'team-1',
|
|
settings: {
|
|
entity_type: 'aktiebolag',
|
|
company_name: 'Acme AB',
|
|
org_number: '5560125790',
|
|
},
|
|
fiscalPeriod: {
|
|
startDate: '2026-01-01',
|
|
endDate: '2026-12-31',
|
|
name: 'Räkenskapsår 2026',
|
|
},
|
|
})
|
|
|
|
expect(fetch).not.toHaveBeenCalled()
|
|
})
|
|
})
|
|
|