import { describe, expect, it } from 'vitest'; import { buildKantineExport, KantineExportError, processKantineCsv, } from './kantine-datev.pipeline'; const SETTINGS = { beraterNr: '1234567', mandantNr: '12345', lohnart: '1111' }; const HEADER = 'PersNr;Name;Menge;EK;Netto;ZuAb;MwSt;Zuschuss;Betrag;Von;Bis'; const ok = (nr: string, name: string, betrag: string, bis = '31.03.2026') => `${nr};${name};1;1,00;1,00;0;0;0;${betrag};01.03.2026;${bis}`; function csv(lines: string[], eol = '\r\n'): string { return [HEADER, ...lines].join(eol); } describe('processKantineCsv', () => { it('liest Windows-1252 mit Umlauten und CRLF', () => { const buf = Buffer.from( csv([ok('100', 'Müller, Jürgen', '7,94'), ok('200', 'Köhler', '51,5')]), 'latin1', ); const p = processKantineCsv(buf, SETTINGS); expect(p.rowCount).toBe(2); expect(p.abrechnungsMonat).toBe('03/2026'); expect(p.totalCents).toBe(794 + 5150); expect(p.errors).toEqual([]); expect(p.canExport).toBe(true); expect(p.blockedReason).toBeNull(); }); it('liest UTF-8 mit BOM und LF gleich', () => { const body = csv([ok('100', 'Müller', '7,94')], '\n'); const buf = Buffer.concat([Buffer.from([0xef, 0xbb, 0xbf]), Buffer.from(body, 'utf8')]); const p = processKantineCsv(buf, SETTINGS); expect(p.rowCount).toBe(1); expect(p.errors).toEqual([]); }); it('zaehlt in der Summe nur Zeilen mit gueltigem Betrag', () => { const p = processKantineCsv( Buffer.from(csv([ok('100', 'A', '7,94'), ok('101', 'B', 'abc')])), SETTINGS, ); expect(p.totalCents).toBe(794); expect(p.errors).toEqual([expect.objectContaining({ row: 3, code: 'betragFormat' })]); expect(p.canExport).toBe(false); expect(p.blockedReason).toBe('errors'); }); it('meldet zwei Monate als Warnung, nicht als Fehler', () => { const p = processKantineCsv( Buffer.from(csv([ok('100', 'A', '1,00'), `101;B;1;1;1;0;0;0;1,00;01.04.2026;30.04.2026`])), SETTINGS, ); expect(p.warnings).toHaveLength(1); expect(p.errors).toEqual([]); expect(p.abrechnungsMonat).toBe('03/2026'); }); it('meldet eine Datei ohne Datenzeilen mit noRows', () => { const p = processKantineCsv(Buffer.from(csv([])), SETTINGS); expect(p.rowCount).toBe(0); expect(p.errors.map((e) => e.code)).toEqual(['noRows']); expect(p.canExport).toBe(false); }); it('sperrt ohne Einstellungen mit settingsMissing', () => { const p = processKantineCsv(Buffer.from(csv([ok('100', 'A', '1,00')])), null); expect(p.canExport).toBe(false); expect(p.blockedReason).toBe('settingsMissing'); }); it('gibt keine Zeileninhalte in der Vorschau zurueck', () => { const p = processKantineCsv(Buffer.from(csv([ok('100', 'Geheimname', 'x')])), SETTINGS); expect(JSON.stringify(p)).not.toContain('Geheimname'); }); }); describe('buildKantineExport', () => { it('erzeugt Dateiname und Base64-Inhalt', () => { const r = buildKantineExport(Buffer.from(csv([ok('100', 'A', '7,94')])), SETTINGS); expect(r.filename).toBe('LuG_1234567_12345_03_2026.sic'); expect(r.mimeType).toBe('text/plain'); expect(Buffer.from(r.content, 'base64').toString('utf8')).toBe( '1234567\t12345\t03/2026\t\t\t\t\t\t\t\t\r\n\t100\t\t1111\t-7.94\t\t\t\t\t\t\r\n', ); }); it('verweigert den Export bei Fehlern', () => { expect(() => buildKantineExport(Buffer.from(csv([ok('x', 'A', '7,94')])), SETTINGS)).toThrow( KantineExportError, ); try { buildKantineExport(Buffer.from(csv([ok('x', 'A', '7,94')])), SETTINGS); } catch (e) { expect((e as KantineExportError).code).toBe('hasErrors'); } }); it('verweigert den Export ohne Einstellungen', () => { try { buildKantineExport(Buffer.from(csv([ok('100', 'A', '7,94')])), null); expect.unreachable(); } catch (e) { expect((e as KantineExportError).code).toBe('settingsMissing'); } }); });