44c1d4351f
- 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>
379 lines
15 KiB
TypeScript
379 lines
15 KiB
TypeScript
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']);
|
||
});
|
||
});
|