import { BadRequestException, ConflictException, NotFoundException } from '@nestjs/common'; import { describe, expect, it, vi } from 'vitest'; import * as XLSX from 'xlsx'; /** * Zwei Klienten wie in favorites.service.spec.ts: `forTenant` und * `withTenantTransaction` werden auf den Nachbau umgeleitet. Die Transaktion * arbeitet auf einer KOPIE des Bestands und uebernimmt sie nur, wenn die * Funktion ohne Fehler endet — so ist Alles-oder-nichts pruefbar. */ vi.mock('../prisma/prisma-tenant.extension', () => ({ forTenant: vi.fn((db: any, tenantId: string) => db.__bound(tenantId)), withTenantTransaction: vi.fn((db: any, tenantId: string, fn: (tx: any) => any) => db.__transaction(tenantId, fn), ), })); import { HandelswareDatevService } from './handelsware-datev.service'; interface Konto { id: string; tenantId: string; name: string; gegenkonto: number; erloeskonto: number; } function uniqueError() { return Object.assign(new Error('Unique constraint failed'), { code: 'P2002' }); } function makeDb(opts: { config?: { erloeskonto: number | null; startGegenkonto: number | null } | null; konten?: Konto[]; }) { const state = { config: opts.config === undefined ? { erloeskonto: 4711, startGegenkonto: 2000 } : opts.config, konten: [...(opts.konten ?? [])], writes: [] as string[], seq: 100, }; function client(tenantId: string, s: { konten: Konto[] }, record: (w: string) => void) { const own = () => s.konten.filter((k) => k.tenantId === tenantId); return { handelswareDatevConfig: { findUnique: vi.fn(async () => state.config), upsert: vi.fn(async ({ create, update }: any) => { record('config.upsert'); state.config = { ...(state.config ?? {}), ...update, ...create } as any; return state.config; }), }, handelswareKonto: { findMany: vi.fn(async () => [...own()].sort((a, b) => a.name.localeCompare(b.name))), findFirst: vi.fn(async ({ where }: any) => own().find((k) => k.id === where.id) ?? null), create: vi.fn(async ({ data }: any) => { record('konto.create'); if (own().some((k) => k.name === data.name)) throw uniqueError(); const row = { id: `k${++state.seq}`, ...data }; s.konten.push(row); return row; }), update: vi.fn(async ({ where, data }: any) => { record('konto.update'); const row = s.konten.find((k) => k.id === where.id) as Konto; if (data.name !== row.name && own().some((k) => k.name === data.name)) throw uniqueError(); Object.assign(row, data); return row; }), delete: vi.fn(async ({ where }: any) => { record('konto.delete'); s.konten.splice( s.konten.findIndex((k) => k.id === where.id), 1, ); }), deleteMany: vi.fn(async () => { record('konto.deleteMany'); const keep = s.konten.filter((k) => k.tenantId !== tenantId); s.konten.length = 0; s.konten.push(...keep); }), createMany: vi.fn(async ({ data }: any) => { record('konto.createMany'); for (const d of data) { if (own().some((k) => k.name === d.name)) throw uniqueError(); s.konten.push({ id: `k${++state.seq}`, ...d }); } }), }, }; } const db: any = { __state: state, __bound: (tenantId: string) => client(tenantId, state, (w) => state.writes.push(w)), __transaction: async (tenantId: string, fn: (tx: any) => any) => { const copy = { konten: state.konten.map((k) => ({ ...k })) }; const txWrites: string[] = []; const result = await fn(client(tenantId, copy, (w) => txWrites.push(w))); state.konten = copy.konten; state.writes.push(...txWrites.map((w) => `tx:${w}`)); return result; }, }; return db; } function workbook(aoa: unknown[][]): Buffer { const wb = XLSX.utils.book_new(); XLSX.utils.book_append_sheet(wb, XLSX.utils.aoa_to_sheet(aoa), 'Blatt1'); return XLSX.write(wb, { type: 'buffer', bookType: 'xlsx' }) as Buffer; } const FILE = { buffer: workbook([ ['', '2026'], ['Kaffee', 12.5], ['Kakao', -3], ['Kakao', 1], ]), originalname: 'HWA 0326 Test.xlsx', }; const konto = (name: string, gegenkonto: number, erloeskonto = 4000): Konto => ({ id: `id-${name}`, tenantId: 't1', name, gegenkonto, erloeskonto, }); describe('HandelswareDatevService — Vorschau', () => { it('sperrt mit settingsMissing, solange Erloeskonto oder Startwert fehlen', async () => { for (const config of [ null, { erloeskonto: 1, startGegenkonto: null }, { erloeskonto: null, startGegenkonto: 1 }, ]) { const service = new HandelswareDatevService(makeDb({ config })); const err: any = await service.preview('t1', FILE).catch((e) => e); expect(err).toBeInstanceOf(BadRequestException); expect(err.getResponse().code).toBe('settingsMissing'); } }); it('liefert Zeilen, neue Konten, Datumsvorschlag und Dateinamen — und schreibt nichts', async () => { const db = makeDb({ konten: [konto('Kaffee', 2010)] }); const res = await new HandelswareDatevService(db).preview('t1', FILE); expect(res.headerText).toBe('2026'); expect(res.suggestedBuchungsdatum).toBe('3103'); expect(res.exportFilename).toBe('HWA_0326.txt'); expect(res.rows.map((r) => [r.buchungstext, r.gegenkonto, r.isNew])).toEqual([ ['Kaffee', 2010, false], ['Kakao', 2011, true], ['Kakao', 2011, true], ]); expect(res.newAccounts).toEqual([{ name: 'Kakao', gegenkonto: 2011, erloeskonto: 4711 }]); expect(db.__state.writes).toEqual([]); expect(db.__state.konten).toHaveLength(1); }); it('meldet eine kaputte Datei als 400 invalidFile', async () => { const service = new HandelswareDatevService(makeDb({})); const err: any = await service .preview('t1', { buffer: Buffer.from('xx'), originalname: 'a.xlsx' }) .catch((e) => e); expect(err.getResponse().code).toBe('invalidFile'); }); it('gibt Zeilenfehler zurueck statt zu werfen', async () => { const buffer = workbook([ ['', 'X'], ['Kaffee', 'viel'], ]); const res = await new HandelswareDatevService(makeDb({})).preview('t1', { buffer, originalname: 'a.xlsx', }); expect(res.rowErrors).toHaveLength(1); }); }); describe('HandelswareDatevService — Export', () => { const submitted = [{ name: 'Kakao', gegenkonto: 2011 }]; it('speichert die neuen Konten erst beim Export, in der Transaktion, und liefert die TXT', async () => { const db = makeDb({ konten: [konto('Kaffee', 2010)] }); const res = await new HandelswareDatevService(db).export('t1', FILE, '3103', submitted); expect(res.createdCount).toBe(1); expect(res.filename).toBe('HWA_0326.txt'); expect(res.mimeType).toBe('text/plain;charset=utf-8'); expect(Buffer.from(res.content, 'base64').toString('utf8')).toBe( '\t2026\t\t\t\t\r\nKaffee\t12.50\tS\t2010\t3103\t4000\r\nKakao\t3.00\tH\t2011\t3103\t4711\r\nKakao\t1.00\tS\t2011\t3103\t4711\r\n', ); expect(db.__state.konten.map((k: Konto) => k.name).sort()).toEqual(['Kaffee', 'Kakao']); expect(db.__state.writes).toEqual(['tx:konto.createMany']); }); it('409 accountsChanged, wenn sich die Liste seit der Vorschau geaendert hat — nichts gespeichert', async () => { // Inzwischen gibt es schon ein Konto mit Gegenkonto 2011 -> neues Konto waere 2012. const db = makeDb({ konten: [konto('Kaffee', 2010), konto('Saft', 2011)] }); const err: any = await new HandelswareDatevService(db) .export('t1', FILE, '3103', submitted) .catch((e) => e); expect(err).toBeInstanceOf(ConflictException); expect(err.getResponse().code).toBe('accountsChanged'); expect(err.getResponse().message).toBe( 'Die Kontenliste wurde inzwischen geändert. Bitte laden Sie die Datei erneut, um die Vorschau zu aktualisieren.', ); expect(db.__state.konten).toHaveLength(2); expect(db.__state.writes).toEqual([]); }); it('409, wenn der Client ein neues Konto verschweigt oder erfindet', async () => { const db = makeDb({ konten: [konto('Kaffee', 2010)] }); const service = new HandelswareDatevService(db); await expect(service.export('t1', FILE, '3103', [])).rejects.toBeInstanceOf(ConflictException); await expect( service.export('t1', FILE, '3103', [...submitted, { name: 'Erfunden', gegenkonto: 9 }]), ).rejects.toBeInstanceOf(ConflictException); expect(db.__state.konten).toHaveLength(1); }); it('Wettlauf: Eindeutigkeit (P2002) beim Anlegen wird zu 409', async () => { const db = makeDb({ konten: [konto('Kaffee', 2010)] }); const original = db.__transaction; // Ein zweiter Export hat "Kakao" zwischen Berechnung und Speichern angelegt. db.__transaction = (tenantId: string, fn: (tx: any) => any) => original(tenantId, (tx: any) => { tx.handelswareKonto.createMany = async () => { throw uniqueError(); }; return fn(tx); }); const err: any = await new HandelswareDatevService(db) .export('t1', FILE, '3103', submitted) .catch((e) => e); expect(err).toBeInstanceOf(ConflictException); expect(err.getResponse().code).toBe('accountsChanged'); }); it('400 bei ungueltigem Buchungsdatum', async () => { const err: any = await new HandelswareDatevService(makeDb({})) .export('t1', FILE, '3102', submitted) .catch((e) => e); expect(err.getResponse().code).toBe('buchungsdatumInvalid'); }); it('400 bei Zeilenfehlern', async () => { const buffer = workbook([ ['', 'X'], ['Kaffee', 'viel'], ]); const err: any = await new HandelswareDatevService(makeDb({})) .export('t1', { buffer, originalname: 'a 0326.xlsx' }, '3103', []) .catch((e) => e); expect(err).toBeInstanceOf(BadRequestException); expect(err.getResponse().code).toBe('rowErrors'); }); it('400 settingsMissing beim Export ohne Einstellungen', async () => { const err: any = await new HandelswareDatevService(makeDb({ config: null })) .export('t1', FILE, '3103', submitted) .catch((e) => e); expect(err.getResponse().code).toBe('settingsMissing'); }); }); describe('HandelswareDatevService — Kontenliste', () => { it('legt an, sortiert nach Name und meldet doppelte Namen als 409 nameTaken', async () => { const db = makeDb({}); const service = new HandelswareDatevService(db); await service.createAccount('t1', { name: 'Tee', gegenkonto: 2, erloeskonto: 3 }); await service.createAccount('t1', { name: 'Kaffee', gegenkonto: 4, erloeskonto: 5 }); expect((await service.listAccounts('t1')).map((a) => a.name)).toEqual(['Kaffee', 'Tee']); const err: any = await service .createAccount('t1', { name: 'Tee', gegenkonto: 9, erloeskonto: 9 }) .catch((e) => e); expect(err).toBeInstanceOf(ConflictException); expect(err.getResponse().code).toBe('nameTaken'); }); it('aendert ein Konto; Namensklau ist 409; unbekannte id ist 404', async () => { const db = makeDb({ konten: [konto('A', 1), konto('B', 2)] }); const service = new HandelswareDatevService(db); const updated = await service.updateAccount('t1', 'id-A', { name: 'A2', gegenkonto: 7, erloeskonto: 8, }); expect(updated).toMatchObject({ name: 'A2', gegenkonto: 7 }); await expect( service.updateAccount('t1', 'id-A', { name: 'B', gegenkonto: 1, erloeskonto: 1 }), ).rejects.toBeInstanceOf(ConflictException); await expect( service.updateAccount('t1', 'nope', { name: 'X', gegenkonto: 1, erloeskonto: 1 }), ).rejects.toBeInstanceOf(NotFoundException); }); it('loescht ein Konto; unbekannte id ist 404', async () => { const db = makeDb({ konten: [konto('A', 1)] }); const service = new HandelswareDatevService(db); await expect(service.deleteAccount('t1', 'nope')).rejects.toBeInstanceOf(NotFoundException); await expect(service.deleteAccount('t1', 'id-A')).resolves.toEqual({ deleted: true }); expect(db.__state.konten).toHaveLength(0); }); it('CSV-Import ersetzt die Liste in EINER Transaktion (deleteMany + createMany)', async () => { const db = makeDb({ konten: [konto('Alt', 1)] }); const res = await new HandelswareDatevService(db).importAccountsCsv( 't1', Buffer.from('Name;Gegenkonto;Konto\nNeu1;10;20\nNeu2;11'), ); expect(res).toEqual({ count: 2 }); expect(db.__state.konten.map((k: Konto) => k.name)).toEqual(['Neu1', 'Neu2']); expect(db.__state.konten[1].erloeskonto).toBe(4711); expect(db.__state.writes).toEqual(['tx:konto.deleteMany', 'tx:konto.createMany']); }); it('CSV-Import mit einer ungueltigen Zeile aendert nichts', async () => { const db = makeDb({ konten: [konto('Alt', 1)] }); const err: any = await new HandelswareDatevService(db) .importAccountsCsv('t1', Buffer.from('Neu1;10;20\nNeu2;abc;20')) .catch((e) => e); expect(err).toBeInstanceOf(BadRequestException); expect(err.getResponse().code).toBe('csvErrors'); expect(err.getResponse().errors).toHaveLength(1); expect(db.__state.writes).toEqual([]); expect(db.__state.konten.map((k: Konto) => k.name)).toEqual(['Alt']); }); it('CSV-Import: scheitert das Schreiben mittendrin, bleibt die alte Liste', async () => { const db = makeDb({ konten: [konto('Alt', 1)] }); const original = db.__transaction; db.__transaction = (tenantId: string, fn: (tx: any) => any) => original(tenantId, (tx: any) => { tx.handelswareKonto.createMany = async () => { throw new Error('Datenbank weg'); }; return fn(tx); }); await expect( new HandelswareDatevService(db).importAccountsCsv('t1', Buffer.from('Neu;1;2')), ).rejects.toThrow('Datenbank weg'); expect(db.__state.konten.map((k: Konto) => k.name)).toEqual(['Alt']); }); it('CSV-Export liefert BOM-CSV als Base64', async () => { const db = makeDb({ konten: [konto('Käse', 1, 2)] }); const res = await new HandelswareDatevService(db).exportAccountsCsv('t1'); expect(res.filename).toBe('Konten.csv'); expect(Buffer.from(res.content, 'base64').toString('utf8')).toBe('Käse;1;2\r\n'); }); }); describe('HandelswareDatevService — Einstellungen', () => { it('configured nur, wenn beide Zahlen gesetzt sind', async () => { expect(await new HandelswareDatevService(makeDb({ config: null })).getSettings('t1')).toEqual({ erloeskonto: null, startGegenkonto: null, configured: false, }); expect((await new HandelswareDatevService(makeDb({})).getSettings('t1')).configured).toBe(true); }); it('speichert per upsert', async () => { const db = makeDb({ config: null }); const res = await new HandelswareDatevService(db).saveSettings('t1', { erloeskonto: 5, startGegenkonto: 6, }); expect(res).toEqual({ erloeskonto: 5, startGegenkonto: 6, configured: true }); expect(db.__state.writes).toEqual(['config.upsert']); }); });