Batch from a real migration walkthrough (Fortnox -> Accounted, 2026-08-20): - Årsredovisning: the "Låst version" select was empty with no explanation because the only version was a draft and "Lås version för underskrift" is disabled while the four Lagstadgade upplysningar checkboxes and the content confirmation count as blockers. The select is now disabled with a hint that names the blocker count and links to Fullständighetskontroll, the four AR-NOTE-*-UNCONFIRMED issues carry remediation text, the lock button explains why it is grey, and "Markera som signerad" says what it still needs (locked version, bevisreferens, date). - Moderföretagets org.nr accepts a foreign registration identifier (CHE-123.456.789, HRB 12345, 923 609 016); personnummer shapes stay out. - Fortnox underlag discovery: log status, body and Fortnox's message on failure, show the message in the UI, treat a 400 with behörighet/scope text as scopes-required, and fall back to an unfiltered voucherfileconnections list when the financialyear filter answers 400. - Kontomapping: the Momskod column had min-w only; table-fixed collapsed it and its selects overflowed into Konfidens. Real w-72 now. - SIE import warnings pluralise correctly for one skipped voucher; the Verifikationsserie option says the source series is preserved. Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com> Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
166 lines
6.2 KiB
TypeScript
166 lines
6.2 KiB
TypeScript
import { describe, expect, it, vi } from 'vitest';
|
|
|
|
import {
|
|
downloadFortnoxArchiveFile,
|
|
fetchFortnoxFileConnections,
|
|
fetchFortnoxFinancialYears,
|
|
} from '../attachments';
|
|
import { FortnoxApiError, type FortnoxClient } from '../client';
|
|
|
|
function clientWith(methods: Partial<FortnoxClient>): FortnoxClient {
|
|
return methods as FortnoxClient;
|
|
}
|
|
|
|
describe('Fortnox attachments', () => {
|
|
it('maps financial years and skips malformed rows', async () => {
|
|
const getPaginated = vi.fn().mockResolvedValue([
|
|
{ Id: 3, FromDate: '2021-01-01', ToDate: '2021-12-31' },
|
|
{ Id: '4', FromDate: '2022-01-01', ToDate: '2022-12-31' },
|
|
{ Id: null, FromDate: '2023-01-01', ToDate: '2023-12-31' },
|
|
]);
|
|
const client = clientWith({ getPaginated } as Partial<FortnoxClient>);
|
|
|
|
await expect(fetchFortnoxFinancialYears(client, 'token')).resolves.toEqual([
|
|
{ id: 3, fromDate: '2021-01-01', toDate: '2021-12-31' },
|
|
{ id: 4, fromDate: '2022-01-01', toDate: '2022-12-31' },
|
|
]);
|
|
expect(getPaginated).toHaveBeenCalledWith(
|
|
'token',
|
|
'/financialyears',
|
|
'FinancialYears',
|
|
{ pageSize: 500 },
|
|
);
|
|
});
|
|
|
|
it('queries each financial year, coerces fields, skips malformed rows, and deduplicates', async () => {
|
|
const duplicate = {
|
|
FileId: 'file-1',
|
|
Name: 'kvitto.pdf',
|
|
VoucherSeries: 'A',
|
|
VoucherNumber: '12',
|
|
VoucherYear: '3',
|
|
};
|
|
const getPaginated = vi
|
|
.fn()
|
|
.mockResolvedValueOnce([
|
|
duplicate,
|
|
duplicate,
|
|
{ FileId: '', VoucherSeries: 'A', VoucherNumber: '13', VoucherYear: '3' },
|
|
])
|
|
.mockResolvedValueOnce([
|
|
{
|
|
FileId: 'file-2',
|
|
Name: ' faktura.png ',
|
|
VoucherSeries: 'B',
|
|
VoucherNumber: 7,
|
|
VoucherYear: 4,
|
|
},
|
|
]);
|
|
const client = clientWith({ getPaginated } as Partial<FortnoxClient>);
|
|
|
|
await expect(fetchFortnoxFileConnections(client, 'token', [3, 4])).resolves.toEqual([
|
|
{
|
|
fileId: 'file-1',
|
|
name: 'kvitto.pdf',
|
|
series: 'A',
|
|
number: 12,
|
|
financialYearId: 3,
|
|
},
|
|
{
|
|
fileId: 'file-2',
|
|
name: 'faktura.png',
|
|
series: 'B',
|
|
number: 7,
|
|
financialYearId: 4,
|
|
},
|
|
]);
|
|
expect(getPaginated).toHaveBeenNthCalledWith(
|
|
1,
|
|
'token',
|
|
'/voucherfileconnections?financialyear=3',
|
|
'VoucherFileConnections',
|
|
{ pageSize: 500 },
|
|
);
|
|
expect(getPaginated).toHaveBeenNthCalledWith(
|
|
2,
|
|
'token',
|
|
'/voucherfileconnections?financialyear=4',
|
|
'VoucherFileConnections',
|
|
{ pageSize: 500 },
|
|
);
|
|
});
|
|
|
|
it('falls back to one unfiltered listing when Fortnox rejects the financialyear filter with 400', async () => {
|
|
const getPaginated = vi
|
|
.fn()
|
|
.mockRejectedValueOnce(new FortnoxApiError('Fortnox API error: 400 Bad Request', 400, ''))
|
|
.mockResolvedValueOnce([
|
|
{ FileId: 'file-1', Name: 'kvitto.pdf', VoucherSeries: 'A', VoucherNumber: 12, VoucherYear: 3 },
|
|
{ FileId: 'file-2', Name: 'faktura.png', VoucherSeries: 'B', VoucherNumber: 7, VoucherYear: 4 },
|
|
{ FileId: 'file-9', Name: 'gammalt.pdf', VoucherSeries: 'A', VoucherNumber: 1, VoucherYear: 1 },
|
|
]);
|
|
const client = clientWith({ getPaginated } as Partial<FortnoxClient>);
|
|
|
|
await expect(fetchFortnoxFileConnections(client, 'token', [3, 4])).resolves.toEqual([
|
|
{ fileId: 'file-1', name: 'kvitto.pdf', series: 'A', number: 12, financialYearId: 3 },
|
|
{ fileId: 'file-2', name: 'faktura.png', series: 'B', number: 7, financialYearId: 4 },
|
|
]);
|
|
expect(getPaginated).toHaveBeenCalledTimes(2);
|
|
expect(getPaginated).toHaveBeenNthCalledWith(
|
|
2,
|
|
'token',
|
|
'/voucherfileconnections',
|
|
'VoucherFileConnections',
|
|
{ pageSize: 500 },
|
|
);
|
|
});
|
|
|
|
it('propagates a 400 from the unfiltered fallback and non-400 failures untouched', async () => {
|
|
const scopeError = new FortnoxApiError('Fortnox API error: 400 Bad Request', 400, 'behörighet');
|
|
const getPaginated = vi
|
|
.fn()
|
|
.mockRejectedValueOnce(new FortnoxApiError('Fortnox API error: 400 Bad Request', 400, ''))
|
|
.mockRejectedValueOnce(scopeError);
|
|
const client = clientWith({ getPaginated } as Partial<FortnoxClient>);
|
|
await expect(fetchFortnoxFileConnections(client, 'token', [3])).rejects.toBe(scopeError);
|
|
|
|
const forbidden = new FortnoxApiError('forbidden', 403);
|
|
const client403 = clientWith({
|
|
getPaginated: vi.fn().mockRejectedValue(forbidden),
|
|
} as Partial<FortnoxClient>);
|
|
await expect(fetchFortnoxFileConnections(client403, 'token', [3])).rejects.toBe(forbidden);
|
|
});
|
|
|
|
it('downloads through the archive path without fallback when it succeeds', async () => {
|
|
const response = { bytes: new ArrayBuffer(2), contentType: 'application/pdf' };
|
|
const getBinary = vi.fn().mockResolvedValue(response);
|
|
const client = clientWith({ getBinary } as Partial<FortnoxClient>);
|
|
|
|
await expect(downloadFortnoxArchiveFile(client, 'token', 'file-1')).resolves.toBe(response);
|
|
expect(getBinary).toHaveBeenCalledTimes(1);
|
|
expect(getBinary).toHaveBeenCalledWith('token', '/archive/file-1');
|
|
});
|
|
|
|
it('retries the query-form archive endpoint once after a 404', async () => {
|
|
const response = { bytes: new ArrayBuffer(2), contentType: 'image/jpeg' };
|
|
const getBinary = vi
|
|
.fn()
|
|
.mockRejectedValueOnce(new FortnoxApiError('not found', 404))
|
|
.mockResolvedValueOnce(response);
|
|
const client = clientWith({ getBinary } as Partial<FortnoxClient>);
|
|
|
|
await expect(downloadFortnoxArchiveFile(client, 'token', 'file-1')).resolves.toBe(response);
|
|
expect(getBinary).toHaveBeenNthCalledWith(1, 'token', '/archive/file-1');
|
|
expect(getBinary).toHaveBeenNthCalledWith(2, 'token', '/archive/?fileid=file-1');
|
|
});
|
|
|
|
it('propagates non-404 archive failures without trying the fallback', async () => {
|
|
const error = new FortnoxApiError('server error', 500);
|
|
const getBinary = vi.fn().mockRejectedValue(error);
|
|
const client = clientWith({ getBinary } as Partial<FortnoxClient>);
|
|
|
|
await expect(downloadFortnoxArchiveFile(client, 'token', 'file-1')).rejects.toBe(error);
|
|
expect(getBinary).toHaveBeenCalledTimes(1);
|
|
});
|
|
});
|