The Fortnox migration now imports the asset register (GET /3/assets + /3/assets/types) as local register rows via createAsset: category from the type's anskaffningskonto BAS class, useful life from the source's depreciation window (K2 schablon fallback), never any journal entries (values arrived via SIE; the source's depreciated-to date is recorded in notes for review of the first proposal). Sold/scrapped/voided assets are skipped, re-runs dedupe, one bad asset counts as skipped. Gated behind FORTNOX_ASSET_SCOPES_APPROVED=false until the portal registration for integration 39254 carries the Assets scope, so hosted consents are unchanged and the wizard shows an honest skipped row. Co-authored-by: pgronberg <pgronberg@users.noreply.github.com>
242 lines
9.2 KiB
TypeScript
242 lines
9.2 KiB
TypeScript
import { describe, it, expect, beforeEach, vi, type Mock } from 'vitest'
|
|
import { createMockSupabase, createMockRequest, parseJsonResponse } from '@/tests/helpers'
|
|
import type { ExtensionContext } from '@/lib/extensions/types'
|
|
|
|
/**
|
|
* Guards the server-side "SIE import required first" rule on the entity-migration
|
|
* route (POST /migrate).
|
|
*
|
|
* Provider API import only ever writes subledger entities (customers, suppliers,
|
|
* invoices): it never posts to the general ledger. The GL (kontoplan, ingående
|
|
* balanser, verifikationer) arrives via SIE. Importing entities without the
|
|
* SIE-derived ledger leaves an incomplete bokföring under BFL, so the route MUST
|
|
* refuse to run for EVERY provider until a completed SIE import exists for the
|
|
* company. Fortnox used to be exempt (it pulls SIE itself via API), but the
|
|
* wizard lets the user uncheck "Bokföringsdata (SIE)" while keeping entities
|
|
* checked (#2000), so the exemption is gone. The rule is "must exist", not "must
|
|
* be part of this run": an entities-only re-run after a full migration passes.
|
|
*
|
|
* Previously this was only an advisory banner + step-gating in the React wizard,
|
|
* which a direct API call or a stale client could bypass. This test locks the
|
|
* enforcement at the authoritative seam: the route handler.
|
|
*/
|
|
|
|
vi.mock('../lib/migration-orchestrator', () => ({
|
|
executeMigration: vi.fn().mockResolvedValue({ customers: { total: 0, imported: 0, skipped: 0 } }),
|
|
}))
|
|
|
|
// index.ts imports many helpers from provider-client at module load; stub the
|
|
// whole module and give getConsent/acceptConsent controllable behaviour.
|
|
vi.mock('../lib/provider-client', () => ({
|
|
createConsent: vi.fn(),
|
|
getConsent: vi.fn(),
|
|
listConsents: vi.fn(),
|
|
generateOtc: vi.fn(),
|
|
consumeOAuthState: vi.fn(),
|
|
getAuthUrl: vi.fn(),
|
|
exchangeAuthToken: vi.fn(),
|
|
submitProviderToken: vi.fn(),
|
|
acceptConsent: vi.fn().mockResolvedValue(undefined),
|
|
deleteConsent: vi.fn(),
|
|
resolveConsent: vi.fn(),
|
|
fetchCompanyInfoDirect: vi.fn(),
|
|
// Real classes: index.ts branches on `instanceof` in the catch blocks.
|
|
ProviderTokenInvalidError: class ProviderTokenInvalidError extends Error {},
|
|
ConsentNotFoundError: class ConsentNotFoundError extends Error {},
|
|
}))
|
|
|
|
import { arcimMigrationExtension } from '../index'
|
|
import { executeMigration } from '../lib/migration-orchestrator'
|
|
import { getConsent } from '../lib/provider-client'
|
|
|
|
const migrateRoute = (arcimMigrationExtension.apiRoutes ?? []).find(
|
|
(r) => r.method === 'POST' && r.path === '/migrate',
|
|
)!
|
|
|
|
type RouteHandler = (request: Request, ctx?: ExtensionContext) => Promise<Response>
|
|
const handler = migrateRoute.handler as RouteHandler
|
|
|
|
function buildCtx(count: number | null): ExtensionContext {
|
|
const { supabase, mockResult } = createMockSupabase()
|
|
// The guard awaits `from('sie_imports').select(..,{count,head}).eq().eq()`.
|
|
mockResult({ count })
|
|
;(supabase as unknown as { auth: unknown }).auth = {
|
|
getUser: vi.fn().mockResolvedValue({ data: { user: { id: 'user-1' } } }),
|
|
}
|
|
return { supabase, companyId: 'company-1' } as unknown as ExtensionContext
|
|
}
|
|
|
|
function migrateRequest(body: Record<string, unknown> = { consentId: 'consent-1' }) {
|
|
return createMockRequest('http://localhost/api/extensions/ext/arcim-migration/migrate', {
|
|
method: 'POST',
|
|
body,
|
|
})
|
|
}
|
|
|
|
type GuardErrorBody = { error: { code: string; message: string; message_en?: string } }
|
|
|
|
describe('POST /migrate: SIE-import-required guard', () => {
|
|
beforeEach(() => {
|
|
vi.clearAllMocks()
|
|
})
|
|
|
|
it('returns 401 when there is no authenticated user', async () => {
|
|
const ctx = buildCtx(0)
|
|
;(ctx.supabase as unknown as { auth: { getUser: Mock } }).auth.getUser
|
|
.mockResolvedValue({ data: { user: null } })
|
|
|
|
const res = await handler(migrateRequest(), ctx)
|
|
|
|
expect(res.status).toBe(401)
|
|
expect(getConsent).not.toHaveBeenCalled()
|
|
expect(executeMigration).not.toHaveBeenCalled()
|
|
})
|
|
|
|
it('returns 400 when consentId is missing', async () => {
|
|
const res = await handler(migrateRequest({}), buildCtx(1))
|
|
|
|
expect(res.status).toBe(400)
|
|
expect(getConsent).not.toHaveBeenCalled()
|
|
expect(executeMigration).not.toHaveBeenCalled()
|
|
})
|
|
|
|
it('blocks a non-Fortnox provider when no completed SIE import exists', async () => {
|
|
;(getConsent as Mock).mockResolvedValue({ id: 'consent-1', status: 1, provider: 'visma' })
|
|
|
|
const res = await handler(migrateRequest(), buildCtx(0))
|
|
const { status, body } = await parseJsonResponse<GuardErrorBody>(res)
|
|
|
|
expect(status).toBe(409)
|
|
expect(body.error.code).toBe('PROVIDER_SIE_IMPORT_REQUIRED')
|
|
// Visma has no SIE-over-API: the static registry text ("ladda upp en
|
|
// SIE-fil") is the right instruction and must stay.
|
|
expect(body.error.message).toMatch(/ladda upp en SIE-fil/i)
|
|
expect(executeMigration).not.toHaveBeenCalled()
|
|
})
|
|
|
|
it('allows a non-Fortnox provider once a completed SIE import exists', async () => {
|
|
;(getConsent as Mock).mockResolvedValue({ id: 'consent-1', status: 1, provider: 'visma' })
|
|
|
|
const res = await handler(migrateRequest(), buildCtx(1))
|
|
|
|
expect(res.status).toBe(200)
|
|
expect(executeMigration).toHaveBeenCalledTimes(1)
|
|
})
|
|
|
|
it('blocks Fortnox when no completed SIE import exists (SIE step unchecked in the wizard)', async () => {
|
|
;(getConsent as Mock).mockResolvedValue({ id: 'consent-1', status: 1, provider: 'fortnox' })
|
|
|
|
const res = await handler(migrateRequest(), buildCtx(0))
|
|
const { status, body } = await parseJsonResponse<GuardErrorBody>(res)
|
|
|
|
expect(status).toBe(409)
|
|
expect(body.error.code).toBe('PROVIDER_SIE_IMPORT_REQUIRED')
|
|
expect(executeMigration).not.toHaveBeenCalled()
|
|
})
|
|
|
|
it('tells SIE-over-API providers to tick the wizard checkbox, not to upload a file', async () => {
|
|
;(getConsent as Mock).mockResolvedValue({ id: 'consent-1', status: 1, provider: 'fortnox' })
|
|
|
|
const res = await handler(migrateRequest(), buildCtx(0))
|
|
const { body } = await parseJsonResponse<GuardErrorBody>(res)
|
|
|
|
expect(body.error.message).toContain('Bokföringsdata (SIE)')
|
|
expect(body.error.message).not.toMatch(/ladda upp/i)
|
|
expect(body.error.message_en).toContain('Bokföringsdata (SIE)')
|
|
expect(body.error.message_en).not.toMatch(/upload/i)
|
|
})
|
|
|
|
it('allows a company-info-only run with no SIE import (writes no ledger data)', async () => {
|
|
;(getConsent as Mock).mockResolvedValue({ id: 'consent-1', status: 1, provider: 'fortnox' })
|
|
|
|
const res = await handler(
|
|
migrateRequest({
|
|
consentId: 'consent-1',
|
|
importCompanyInfo: true,
|
|
importCustomers: false,
|
|
importSuppliers: false,
|
|
importSalesInvoices: false,
|
|
importSupplierInvoices: false,
|
|
importAssets: false,
|
|
}),
|
|
buildCtx(0),
|
|
)
|
|
|
|
expect(res.status).toBe(200)
|
|
expect(executeMigration).toHaveBeenCalledTimes(1)
|
|
})
|
|
|
|
// The asset register is entity data too: its rows carry BAS account triples
|
|
// and depreciation plans that only mean something against an imported chart
|
|
// of accounts, so an assets-only run is gated like any other entity import.
|
|
it('blocks an assets-only run with no SIE import', async () => {
|
|
;(getConsent as Mock).mockResolvedValue({ id: 'consent-1', status: 1, provider: 'fortnox' })
|
|
|
|
const res = await handler(
|
|
migrateRequest({
|
|
consentId: 'consent-1',
|
|
importCompanyInfo: false,
|
|
importCustomers: false,
|
|
importSuppliers: false,
|
|
importSalesInvoices: false,
|
|
importSupplierInvoices: false,
|
|
importAssets: true,
|
|
}),
|
|
buildCtx(0),
|
|
)
|
|
|
|
expect(res.status).toBe(409)
|
|
expect(executeMigration).not.toHaveBeenCalled()
|
|
})
|
|
|
|
// Backward compatibility: a client written before the asset option existed
|
|
// omits the field entirely. That request must behave as it did then, so the
|
|
// omitted option neither imports assets nor trips this guard.
|
|
it('allows a company-info-only run that omits importAssets entirely', async () => {
|
|
;(getConsent as Mock).mockResolvedValue({ id: 'consent-1', status: 1, provider: 'fortnox' })
|
|
|
|
const res = await handler(
|
|
migrateRequest({
|
|
consentId: 'consent-1',
|
|
importCompanyInfo: true,
|
|
importCustomers: false,
|
|
importSuppliers: false,
|
|
importSalesInvoices: false,
|
|
importSupplierInvoices: false,
|
|
}),
|
|
buildCtx(0),
|
|
)
|
|
|
|
expect(res.status).toBe(200)
|
|
expect(executeMigration).toHaveBeenCalledTimes(1)
|
|
})
|
|
|
|
it('still blocks when company info is combined with any entity flag', async () => {
|
|
;(getConsent as Mock).mockResolvedValue({ id: 'consent-1', status: 1, provider: 'fortnox' })
|
|
|
|
const res = await handler(
|
|
migrateRequest({
|
|
consentId: 'consent-1',
|
|
importCompanyInfo: true,
|
|
importCustomers: false,
|
|
importSuppliers: true,
|
|
importSalesInvoices: false,
|
|
importSupplierInvoices: false,
|
|
}),
|
|
buildCtx(0),
|
|
)
|
|
|
|
expect(res.status).toBe(409)
|
|
expect(executeMigration).not.toHaveBeenCalled()
|
|
})
|
|
|
|
it('allows Fortnox once a completed SIE import exists (entities-only re-run)', async () => {
|
|
;(getConsent as Mock).mockResolvedValue({ id: 'consent-1', status: 1, provider: 'fortnox' })
|
|
|
|
const res = await handler(migrateRequest(), buildCtx(1))
|
|
|
|
expect(res.status).toBe(200)
|
|
expect(executeMigration).toHaveBeenCalledTimes(1)
|
|
})
|
|
})
|