Files
tessera-ctl/apps/api/src/handelsware-datev/handelsware-datev.service.spec.ts
T
schalli 44c1d4351f feat(handelsware-datev): API, Kontenliste mit Zeilenschutz und DATEV-Export
- Prisma-Modelle HandelswareDatevConfig und HandelswareKonto mit Zeilenschutz (Migration 20261002130000)
- XLSX lesen (B1 Kopf, A/B ab Zeile 2, Zahl oder deutscher Text), Konten zuordnen, TXT erzeugen
- neue Konten werden nur beim Export in einer mandantengebundenen Transaktion gespeichert (409 bei geaenderter Liste)
- Konten-CSV Import (alles ersetzen) und Export mit Schutz vor Formeleinschleusung
- Einstellungen nur fuer Administratoren, statische Routen vor accounts/:id

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-02 11:38:22 +02:00

379 lines
15 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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']);
});
});