Files
accounted/lib/agent/composer/__tests__/tic-fetch.test.ts
T
Jakob Wennberg 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

315 lines
10 KiB
TypeScript

import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
import { ensureTicSnapshot } from '../tic-fetch'
// `ensureTicSnapshot` is the single chokepoint between the agent build path
// and the TIC /profile endpoint. Every TIC call from agent onboarding goes
// through here, and the 3000/mo Lens budget makes the cache / fallback
// branches load-bearing. These tests cover:
//
// - cache hit (fresh): no /profile call
// - cache miss (no snapshot): /profile fetched + persisted
// - cache hit but stale (>7d): refetch + persist
// - cache hit but v1 shape + upgradeV1=true: refetch + persist
// - cache hit but v1 shape + upgradeV1=false: stays v1 (budget protection)
// - /profile fetch fails: returns existing snapshot (degraded, doesn't crash)
// - company has no org_number anywhere: returns fallback null
const ORIGIN = 'http://localhost:3000'
const COMPANY_ID = 'company-uuid'
function buildSupabase(
selectResult: { data: unknown; error: unknown } = { data: null, error: null },
settingsSelectResult: { data: unknown; error: unknown } = { data: null, error: null },
updateResult: { error: unknown } = { error: null },
) {
const updateCalls: unknown[][] = []
const fromCalls: string[] = []
const from = vi.fn().mockImplementation((table: string) => {
fromCalls.push(table)
// Build a chain that resolves to whichever select result matches the
// table. The function under test queries `companies` first, then
// optionally `company_settings`, then `companies.update`.
const chain: Record<string, unknown> = {}
const methods = ['select', 'eq', 'limit', 'maybeSingle', 'single']
for (const m of methods) {
chain[m] = () => {
if (m === 'single') {
return Promise.resolve(table === 'companies' ? selectResult : settingsSelectResult)
}
if (m === 'maybeSingle') {
return Promise.resolve(table === 'companies' ? selectResult : settingsSelectResult)
}
return chain
}
}
chain.update = (payload: unknown) => {
updateCalls.push([payload])
const updateChain: Record<string, unknown> = {}
updateChain.eq = () => Promise.resolve(updateResult)
return updateChain
}
return chain
})
return {
supabase: { from } as never,
fromCalls,
updateCalls,
}
}
beforeEach(() => {
vi.stubGlobal('fetch', vi.fn())
})
afterEach(() => {
vi.restoreAllMocks()
})
describe('ensureTicSnapshot: cache hit', () => {
it('returns cached snapshot without hitting TIC when the row is fresh and v2-shaped', async () => {
const fetchedAt = new Date(Date.now() - 60_000).toISOString() // 1 min ago
const cached = { statuses: [], companyName: 'Cached AB' }
const { supabase, fromCalls } = buildSupabase({
data: { org_number: '5560125790', tic_snapshot: cached, tic_snapshot_fetched_at: fetchedAt },
error: null,
})
const result = await ensureTicSnapshot({
supabase,
companyId: COMPANY_ID,
cookieHeader: '',
origin: ORIGIN,
})
expect(result.source).toBe('cached')
expect(result.snapshot).toEqual(cached)
expect(fetch).not.toHaveBeenCalled()
// Only the companies SELECT should have run: no profile fetch, no update.
expect(fromCalls).toEqual(['companies'])
})
it('does NOT call /profile when cache is fresh and upgradeV1=false, even if snapshot is v1', async () => {
// V1 snapshot = no `statuses` key. Without upgradeV1=true, we accept it
// as-is to protect the TIC budget across the customer base.
const v1Snapshot = { companyName: 'V1 AB' /* no `statuses` */ }
const fetchedAt = new Date(Date.now() - 60_000).toISOString()
const { supabase } = buildSupabase({
data: { org_number: '5560125790', tic_snapshot: v1Snapshot, tic_snapshot_fetched_at: fetchedAt },
error: null,
})
const result = await ensureTicSnapshot({
supabase,
companyId: COMPANY_ID,
cookieHeader: '',
origin: ORIGIN,
// upgradeV1 omitted -> defaults to false
})
expect(result.source).toBe('cached')
expect(result.snapshot).toEqual(v1Snapshot)
expect(fetch).not.toHaveBeenCalled()
})
})
describe('ensureTicSnapshot: cache miss & refetch', () => {
it('fetches /profile and persists when no snapshot exists', async () => {
const profile = { statuses: [], companyName: 'Fresh AB' }
vi.mocked(fetch).mockResolvedValue(
new Response(JSON.stringify({ data: profile }), { status: 200 }),
)
const { supabase, updateCalls } = buildSupabase({
data: { org_number: '5560125790', tic_snapshot: null, tic_snapshot_fetched_at: null },
error: null,
})
const result = await ensureTicSnapshot({
supabase,
companyId: COMPANY_ID,
cookieHeader: 'sb-auth=abc',
origin: ORIGIN,
})
expect(result.source).toBe('fetched')
expect(result.snapshot).toEqual(profile)
// Hit the /profile endpoint with cookie forwarded
expect(fetch).toHaveBeenCalledTimes(1)
const fetchUrl = vi.mocked(fetch).mock.calls[0][0] as string
expect(fetchUrl).toContain('/api/extensions/ext/tic/profile')
expect(fetchUrl).toContain('org_number=5560125790')
// Persisted via UPDATE
expect(updateCalls).toHaveLength(1)
const persisted = updateCalls[0][0] as Record<string, unknown>
expect(persisted.tic_snapshot).toEqual(profile)
expect(persisted.tic_snapshot_fetched_at).toBeDefined()
})
it('refetches when cached snapshot is stale (>7 days)', async () => {
const eightDaysAgo = new Date(Date.now() - 8 * 24 * 3600_000).toISOString()
const oldSnapshot = { statuses: [], companyName: 'Old AB' }
const freshProfile = { statuses: [], companyName: 'Fresh AB' }
vi.mocked(fetch).mockResolvedValue(
new Response(JSON.stringify({ data: freshProfile }), { status: 200 }),
)
const { supabase } = buildSupabase({
data: {
org_number: '5560125790',
tic_snapshot: oldSnapshot,
tic_snapshot_fetched_at: eightDaysAgo,
},
error: null,
})
const result = await ensureTicSnapshot({
supabase,
companyId: COMPANY_ID,
cookieHeader: '',
origin: ORIGIN,
})
expect(result.source).toBe('fetched')
expect(result.snapshot).toEqual(freshProfile)
expect(fetch).toHaveBeenCalledTimes(1)
})
it('refetches v1 snapshot when upgradeV1=true, even if still fresh', async () => {
const v1Snapshot = { companyName: 'V1 AB' /* no statuses */ }
const v2Profile = { statuses: [], companyName: 'V2 AB' }
vi.mocked(fetch).mockResolvedValue(
new Response(JSON.stringify({ data: v2Profile }), { status: 200 }),
)
const { supabase } = buildSupabase({
data: {
org_number: '5560125790',
tic_snapshot: v1Snapshot,
tic_snapshot_fetched_at: new Date().toISOString(),
},
error: null,
})
const result = await ensureTicSnapshot({
supabase,
companyId: COMPANY_ID,
cookieHeader: '',
origin: ORIGIN,
upgradeV1: true,
})
expect(result.source).toBe('fetched')
expect(result.snapshot).toEqual(v2Profile)
expect(fetch).toHaveBeenCalledTimes(1)
})
})
describe('ensureTicSnapshot: degraded paths', () => {
it('returns fallback null when companies row does not exist', async () => {
const { supabase } = buildSupabase({ data: null, error: null })
const result = await ensureTicSnapshot({
supabase,
companyId: COMPANY_ID,
cookieHeader: '',
origin: ORIGIN,
})
expect(result.source).toBe('fallback')
expect(result.snapshot).toBeNull()
expect(fetch).not.toHaveBeenCalled()
})
it('falls back to company_settings.org_number when companies.org_number is null', async () => {
const profile = { statuses: [], companyName: 'EF Person' }
vi.mocked(fetch).mockResolvedValue(
new Response(JSON.stringify({ data: profile }), { status: 200 }),
)
const { supabase } = buildSupabase(
{ data: { org_number: null, tic_snapshot: null, tic_snapshot_fetched_at: null }, error: null },
{ data: { org_number: '8001011231' }, error: null },
)
const result = await ensureTicSnapshot({
supabase,
companyId: COMPANY_ID,
cookieHeader: '',
origin: ORIGIN,
})
expect(result.source).toBe('fetched')
// Org number from company_settings flowed into the profile URL
const fetchUrl = vi.mocked(fetch).mock.calls[0][0] as string
expect(fetchUrl).toContain('org_number=8001011231')
})
it('returns fallback null when no org_number is available anywhere', async () => {
const { supabase } = buildSupabase(
{ data: { org_number: null, tic_snapshot: null, tic_snapshot_fetched_at: null }, error: null },
{ data: { org_number: null }, error: null },
)
const result = await ensureTicSnapshot({
supabase,
companyId: COMPANY_ID,
cookieHeader: '',
origin: ORIGIN,
})
expect(result.source).toBe('fallback')
expect(result.snapshot).toBeNull()
expect(fetch).not.toHaveBeenCalled()
})
it('returns existing (stale) snapshot when /profile fetch fails', async () => {
const staleSnapshot = { companyName: 'Stale AB' }
vi.mocked(fetch).mockResolvedValue(new Response('upstream down', { status: 502 }))
const { supabase } = buildSupabase({
data: {
org_number: '5560125790',
tic_snapshot: staleSnapshot,
tic_snapshot_fetched_at: new Date(Date.now() - 8 * 24 * 3600_000).toISOString(), // forces refetch
},
error: null,
})
const result = await ensureTicSnapshot({
supabase,
companyId: COMPANY_ID,
cookieHeader: '',
origin: ORIGIN,
})
expect(result.source).toBe('fallback')
// Degrade to the stale snapshot rather than crash: the agent build path
// depends on this contract so a TIC outage never blocks onboarding.
expect(result.snapshot).toEqual(staleSnapshot)
})
it('returns existing snapshot when /profile fetch throws (network error / timeout)', async () => {
const staleSnapshot = { companyName: 'Stale AB' }
vi.mocked(fetch).mockRejectedValue(new Error('ECONNREFUSED'))
const { supabase } = buildSupabase({
data: {
org_number: '5560125790',
tic_snapshot: staleSnapshot,
tic_snapshot_fetched_at: new Date(Date.now() - 8 * 24 * 3600_000).toISOString(),
},
error: null,
})
const result = await ensureTicSnapshot({
supabase,
companyId: COMPANY_ID,
cookieHeader: '',
origin: ORIGIN,
})
expect(result.source).toBe('fallback')
expect(result.snapshot).toEqual(staleSnapshot)
})
})