Files
accounted/extensions/general/tic/__tests__/lookup.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

261 lines
8.9 KiB
TypeScript

import { describe, it, expect, vi, beforeEach } from 'vitest'
// Mock the tic-client functions. /lookup now only calls
// searchCompanyByOrgNumber: the Phase 2 fetchers stay mocked because the
// /profile handler shares the module, but we don't expect /lookup to invoke
// them.
vi.mock('../lib/tic-client', () => ({
searchCompanyByOrgNumber: vi.fn(),
getBankAccounts: vi.fn(),
getIndustryCodes: vi.fn(),
getEmails: vi.fn(),
getPhones: vi.fn(),
getFiscalYears: vi.fn(),
}))
import { ticExtension } from '../index'
import {
searchCompanyByOrgNumber,
getBankAccounts,
getIndustryCodes,
getEmails,
getPhones,
getFiscalYears,
} from '../lib/tic-client'
import { TICAPIError } from '../lib/tic-types'
import type { TICCompanyDocument } from '../lib/tic-types'
const mockSearch = vi.mocked(searchCompanyByOrgNumber)
const mockBank = vi.mocked(getBankAccounts)
const mockIndustries = vi.mocked(getIndustryCodes)
const mockEmails = vi.mocked(getEmails)
const mockPhones = vi.mocked(getPhones)
const mockFiscalYears = vi.mocked(getFiscalYears)
function makeRequest(orgNumber?: string): Request {
const url = orgNumber
? `http://localhost/api/extensions/ext/tic/lookup?org_number=${encodeURIComponent(orgNumber)}`
: 'http://localhost/api/extensions/ext/tic/lookup'
return new Request(url)
}
const lookupHandler = ticExtension.apiRoutes![0].handler
// Search-public document now carries everything /lookup needs at the top
// level: sniCodes, bankAccounts, emailAddresses, phoneNumbers,
// mostRecentFinancialSummary. The previous Phase 2 fan-out duplicated these.
const mockDoc: TICCompanyDocument = {
companyId: 42,
registrationNumber: '5560360793',
names: [
{ nameOrIdentifier: 'Registered Name', companyNamingType: 'registeredName' },
{ nameOrIdentifier: 'Test AB', companyNamingType: 'name' },
],
legalEntityType: 'AB',
registrationDate: 0,
mostRecentRegisteredAddress: {
streetAddress: 'Storgatan 1',
postalCode: '111 22',
city: 'Stockholm',
},
isRegisteredForFTax: true,
isRegisteredForVAT: true,
isCeased: false,
activityStatus: 'isActive',
sniCodes: [
{ rank: 1, sni_2007Code: '62010', sni_2007Name: 'Dataprogrammering', sni_2007Section: 'J' },
],
bankAccounts: [{ accountNumber: '1234567', bankAccountType: 'bankgiro' }],
emailAddresses: [{ emailAddress: 'info@test.se', emailAddressType: 'general' }],
phoneNumbers: [{ phoneNumberFormatted: '08-1234567', e164PhoneNumber: '+4681234567' }],
mostRecentFinancialSummary: {
// 2024-01-01 → 2024-12-31 (Unix seconds, UTC)
periodStart: Math.floor(Date.UTC(2024, 0, 1) / 1000),
periodEnd: Math.floor(Date.UTC(2024, 11, 31) / 1000),
},
}
describe('TIC lookup route', () => {
beforeEach(() => {
vi.clearAllMocks()
})
it('returns 400 when org_number is missing', async () => {
const res = await lookupHandler(makeRequest())
expect(res.status).toBe(400)
})
it('returns 404 when company not found', async () => {
mockSearch.mockResolvedValue(null)
const res = await lookupHandler(makeRequest('000000-0000'))
expect(res.status).toBe(404)
})
it('returns full lookup result from the search doc alone', async () => {
mockSearch.mockResolvedValue(mockDoc)
const res = await lookupHandler(makeRequest('556036-0793'))
expect(res.status).toBe(200)
const { data } = await res.json()
expect(data.companyName).toBe('Test AB')
expect(data.isCeased).toBe(false)
expect(data.address).toEqual({
street: 'Storgatan 1',
postalCode: '111 22',
city: 'Stockholm',
})
expect(data.registration).toEqual({ fTax: true, vat: true })
expect(data.bankAccounts).toEqual([
{ type: 'bankgiro', accountNumber: '1234567', bic: null },
])
expect(data.sniCodes).toEqual([{ code: '62010', name: 'Dataprogrammering' }])
expect(data.email).toBe('info@test.se')
expect(data.phone).toBe('08-1234567')
expect(data.fiscalYear).toEqual({ startMonthDay: '01-01', endMonthDay: '12-31' })
})
it('does NOT fan out to Phase 2 endpoints', async () => {
mockSearch.mockResolvedValue(mockDoc)
await lookupHandler(makeRequest('556036-0793'))
expect(mockBank).not.toHaveBeenCalled()
expect(mockIndustries).not.toHaveBeenCalled()
expect(mockEmails).not.toHaveBeenCalled()
expect(mockPhones).not.toHaveBeenCalled()
expect(mockFiscalYears).not.toHaveBeenCalled()
})
it('derives fiscal year MM-DD from mostRecentFinancialSummary', async () => {
mockSearch.mockResolvedValue({
...mockDoc,
mostRecentFinancialSummary: {
// 2024-07-01 → 2025-06-30 (broken fiscal year)
periodStart: Math.floor(Date.UTC(2024, 6, 1) / 1000),
periodEnd: Math.floor(Date.UTC(2025, 5, 30) / 1000),
},
})
const res = await lookupHandler(makeRequest('556036-0793'))
const { data } = await res.json()
expect(data.fiscalYear).toEqual({ startMonthDay: '07-01', endMonthDay: '06-30' })
})
it('returns fiscalYear null when the company has no closed period yet', async () => {
mockSearch.mockResolvedValue({ ...mockDoc, mostRecentFinancialSummary: undefined })
const res = await lookupHandler(makeRequest('556036-0793'))
const { data } = await res.json()
expect(data.fiscalYear).toBeNull()
})
it('filters non-bankgiro entries from doc.bankAccounts', async () => {
mockSearch.mockResolvedValue({
...mockDoc,
bankAccounts: [
{ accountNumber: '1234567', bankAccountType: 'bankgiro' },
{ accountNumber: 'SE45 5000', bankAccountType: 'iban' },
],
})
const res = await lookupHandler(makeRequest('556036-0793'))
const { data } = await res.json()
expect(data.bankAccounts).toEqual([
{ type: 'bankgiro', accountNumber: '1234567', bic: null },
])
})
it('falls back to e164 when phoneNumberFormatted is missing', async () => {
mockSearch.mockResolvedValue({
...mockDoc,
phoneNumbers: [{ e164PhoneNumber: '+4681234567' }],
})
const res = await lookupHandler(makeRequest('556036-0793'))
const { data } = await res.json()
expect(data.phone).toBe('+4681234567')
})
it('prefers name type over other naming types', async () => {
mockSearch.mockResolvedValue(mockDoc)
const res = await lookupHandler(makeRequest('556036-0793'))
const { data } = await res.json()
expect(data.companyName).toBe('Test AB')
})
it('handles missing optional fields gracefully', async () => {
mockSearch.mockResolvedValue({
...mockDoc,
sniCodes: undefined,
bankAccounts: undefined,
emailAddresses: undefined,
phoneNumbers: undefined,
})
const res = await lookupHandler(makeRequest('556036-0793'))
expect(res.status).toBe(200)
const { data } = await res.json()
expect(data.companyName).toBe('Test AB')
expect(data.bankAccounts).toEqual([])
expect(data.sniCodes).toEqual([])
expect(data.email).toBeNull()
expect(data.phone).toBeNull()
})
it('detects ceased companies via isCeased boolean', async () => {
mockSearch.mockResolvedValue({ ...mockDoc, isCeased: true, activityStatus: 'isNoLongerActive' })
const res = await lookupHandler(makeRequest('556036-0793'))
const { data } = await res.json()
expect(data.isCeased).toBe(true)
})
it('returns 503 when TIC is not configured', async () => {
mockSearch.mockRejectedValue(
new TICAPIError('TIC_API_PROXY_URL is not configured', undefined, 'NOT_CONFIGURED')
)
const res = await lookupHandler(makeRequest('556036-0793'))
expect(res.status).toBe(503)
})
it('returns 429 when TIC rate-limits us', async () => {
mockSearch.mockRejectedValue(
new TICAPIError('Rate limit exceeded', 429, 'RATE_LIMIT_EXCEEDED')
)
const res = await lookupHandler(makeRequest('556036-0793'))
expect(res.status).toBe(429)
})
it('returns 504 when TIC times out', async () => {
mockSearch.mockRejectedValue(new TICAPIError('Request timeout', undefined, 'TIMEOUT'))
const res = await lookupHandler(makeRequest('556036-0793'))
expect(res.status).toBe(504)
})
it('returns 400 when upstream rejects the org number (4xx)', async () => {
mockSearch.mockRejectedValue(new TICAPIError('TIC API error: Bad Request', 400))
const res = await lookupHandler(makeRequest('1234567-1234'))
expect(res.status).toBe(400)
})
it('returns 502 when upstream returns 5xx', async () => {
mockSearch.mockRejectedValue(new TICAPIError('TIC API error: Bad Gateway', 502))
const res = await lookupHandler(makeRequest('556036-0793'))
expect(res.status).toBe(502)
})
it('returns 502 when fetch fails (network error)', async () => {
mockSearch.mockRejectedValue(new TICAPIError('Failed to fetch from TIC: ECONNRESET'))
const res = await lookupHandler(makeRequest('556036-0793'))
expect(res.status).toBe(502)
})
it('returns 500 for non-TICAPIError unexpected errors', async () => {
mockSearch.mockRejectedValue(new Error('boom'))
const res = await lookupHandler(makeRequest('556036-0793'))
expect(res.status).toBe(500)
})
})