Files
accounted/lib/company/__tests__/actions.test.ts
T
Jakob WennbergandClaude Sonnet 5 ec27228a8e style: remove em/en dashes repo-wide, add CLAUDE.md rule against them (#890)
Em dashes (—) and en dashes (–) had spread across comments, docs, tests,
and a few UI strings, reading as AI-generated boilerplate rather than
house style. Replaced each with punctuation matching its context: colon
for explanatory clauses, comma for asides, plain hyphen for numeric/legal
ranges (e.g. "21-23§"), "to"/"till" for date ranges, parentheses for
paired-dash asides. messages/en.json and messages/sv.json were fixed by
hand together to keep sv/en in sync.

Left untouched where the dash is the functional subject rather than
decorative punctuation: date-range-parser.ts's separator regex,
charset-repair.ts's CP1252 byte-mapping table (and its test), the SIE
encoding mojibake docs, generic-csv.ts's minus-sign normalizer, the
agent system-prompt files that already instruct against em dashes, and
a golden iXBRL test fixture compared byte-for-byte.

Also fixes two bugs surfaced along the way: an off-by-one in
ApiKeysPanel's scope-label split (a leftover from an earlier partial
pass), and a charset-repair test that had lost the literal en-dash it
exists to verify.

Regenerated the agent atom seed migration (skills:generate) since 27
SKILL.md files changed. Added a CLAUDE.md rule against em/en dashes,
with an explicit carve-out for the functional-dash cases above.

Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-04 15:58:06 +02:00

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()
})
})