feat(quick-260909-mir): dkv-Historie und Fahrzeugstammdaten binden, Download-Besitzriegel schliessen

This commit is contained in:
2026-09-10 08:48:54 +02:00
parent 222f453747
commit 5e8237d313
3 changed files with 506 additions and 38 deletions
+422 -17
View File
@@ -1,4 +1,5 @@
import { describe, expect, it, vi } from 'vitest';
import * as fs from 'fs';
import { afterEach, describe, expect, it, vi } from 'vitest';
import { DkvService } from './dkv.service';
/**
@@ -16,13 +17,22 @@ import { DkvService } from './dkv.service';
* (`(p) => p`, der ldap-Fehler) wuerde das nicht leisten.
*
* Der Fake wird in Aufgabe 3 um `dkvVehicleMaster`/`dkvInvoiceHistory`
* ERWEITERT, nicht ersetzt.
* ERWEITERT, nicht ersetzt (siehe unten in makeFakePrisma()).
*/
vi.mock('../prisma/prisma-tenant.extension', () => ({
forTenant: vi.fn((prisma: any, tenantId: string) => prisma.__makeBoundClient(tenantId)),
}));
// `import * as fs from 'fs'` under ESM has a non-configurable module
// namespace — vi.spyOn(fs, 'existsSync') fails with "Cannot redefine
// property". vi.mock() replaces the module at import time instead, which
// works regardless of namespace configurability (Tests 8-10, Aufgabe 3).
vi.mock('fs', async (importOriginal) => {
const actual = await importOriginal<typeof import('fs')>();
return { ...actual, existsSync: vi.fn(), readFileSync: vi.fn() };
});
function _applySelect(row: any, select: Record<string, boolean> | undefined) {
if (!select) return { ...row };
const out: Record<string, unknown> = {};
@@ -78,21 +88,179 @@ function makeFakePrisma() {
),
};
// ─── dkvVehicleMaster (Aufgabe 3) ─────────────────────────────────────────
const vehicles = new Map<string, any>(); // key: id
let vehicleAutoId = 0;
const dkvVehicleMaster = {
findMany: vi.fn(async ({ where }: { where?: { tenantId?: string } } = {}) => {
return Array.from(vehicles.values()).filter(
(v) => !where?.tenantId || v.tenantId === where.tenantId,
);
}),
create: vi.fn(async ({ data }: { data: Record<string, unknown> }) => {
const id = `veh-${++vehicleAutoId}`;
const record = { id, ...data };
vehicles.set(id, record);
return record;
}),
findFirst: vi.fn(async ({ where }: { where: { id: string; tenantId: string } }) => {
const row = vehicles.get(where.id);
return row && row.tenantId === where.tenantId ? row : null;
}),
update: vi.fn(async ({ where, data }: { where: { id: string }; data: Record<string, unknown> }) => {
const existing = vehicles.get(where.id);
const record = { ...existing, ...data };
vehicles.set(where.id, record);
return record;
}),
delete: vi.fn(async ({ where }: { where: { id: string } }) => {
const existing = vehicles.get(where.id);
vehicles.delete(where.id);
return existing;
}),
deleteMany: vi.fn(async ({ where }: { where?: { tenantId?: string } } = {}) => {
let count = 0;
for (const [id, v] of vehicles) {
if (!where?.tenantId || v.tenantId === where.tenantId) {
vehicles.delete(id);
count++;
}
}
return { count };
}),
createMany: vi.fn(async ({ data }: { data: Record<string, unknown>[] }) => {
for (const d of data) {
const id = `veh-${++vehicleAutoId}`;
vehicles.set(id, { id, ...d });
}
return { count: data.length };
}),
upsert: vi.fn(
async ({
where,
create,
update,
}: {
where: { tenantId_kennzeichen: { tenantId: string; kennzeichen: string } };
create: Record<string, unknown>;
update: Record<string, unknown>;
}) => {
const key = where.tenantId_kennzeichen;
const existing = Array.from(vehicles.values()).find(
(v) => v.tenantId === key.tenantId && v.kennzeichen === key.kennzeichen,
);
if (existing) {
const record = { ...existing, ...update };
vehicles.set(existing.id, record);
return record;
}
const id = `veh-${++vehicleAutoId}`;
const record = { id, ...create };
vehicles.set(id, record);
return record;
},
),
};
// ─── dkvInvoiceHistory (Aufgabe 3) ────────────────────────────────────────
const history = new Map<string, any>(); // key: id
let historyAutoId = 0;
const dkvInvoiceHistory = {
findMany: vi.fn(
async ({
where,
skip,
take,
}: { where?: { tenantId?: string }; skip?: number; take?: number } = {}) => {
let rows = Array.from(history.values()).filter(
(h) => !where?.tenantId || h.tenantId === where.tenantId,
);
if (typeof skip === 'number') rows = rows.slice(skip);
if (typeof take === 'number') rows = rows.slice(0, take);
return rows;
},
),
count: vi.fn(async ({ where }: { where?: { tenantId?: string } } = {}) => {
return Array.from(history.values()).filter(
(h) => !where?.tenantId || h.tenantId === where.tenantId,
).length;
}),
create: vi.fn(async ({ data }: { data: Record<string, unknown> }) => {
const id = `hist-${++historyAutoId}`;
const record = { id, ...data };
history.set(id, record);
return record;
}),
findFirst: vi.fn(
async ({
where,
}: {
where: { tenantId?: string; exportFilename?: string };
}) => {
return (
Array.from(history.values()).find(
(h) =>
(!where.tenantId || h.tenantId === where.tenantId) &&
(!where.exportFilename || h.exportFilename === where.exportFilename),
) ?? null
);
},
),
};
const fake: any = {
dkvModuleConfig,
dkvVehicleMaster,
dkvInvoiceHistory,
__boundCallLog: boundCallLog,
__seedConfig(tenantId: string, row: Record<string, unknown>) {
configs.set(tenantId, { tenantId, ...row });
},
__seedVehicle(row: { id: string; tenantId: string; kennzeichen: string; marke?: string; modell?: string; fahrer?: string }) {
vehicles.set(row.id, { marke: '', modell: '', fahrer: '', ...row });
},
__seedHistory(row: { id: string; tenantId: string; exportFilename?: string | null }) {
history.set(row.id, {
rechnungsnummer: 'RG-TEST',
anzahlFahrzeuge: 0,
anzahlTransaktionen: 0,
status: 'Verarbeitet',
exportFilename: null,
...row,
});
},
__makeBoundClient(tenantId: string) {
const wrapped: any = {};
for (const method of ['findFirst', 'findUnique', 'upsert']) {
wrapped[method] = async (...args: any[]) => {
boundCallLog.push({ tenantId, model: 'dkvModuleConfig', method });
return (dkvModuleConfig as any)[method](...args);
};
}
return { dkvModuleConfig: wrapped };
const wrapModel = (model: Record<string, any>, modelName: string, methods: string[]) => {
const wrapped: any = {};
for (const method of methods) {
wrapped[method] = async (...args: any[]) => {
boundCallLog.push({ tenantId, model: modelName, method });
return model[method](...args);
};
}
return wrapped;
};
return {
dkvModuleConfig: wrapModel(dkvModuleConfig, 'dkvModuleConfig', ['findFirst', 'findUnique', 'upsert']),
dkvVehicleMaster: wrapModel(dkvVehicleMaster, 'dkvVehicleMaster', [
'findMany',
'create',
'findFirst',
'update',
'delete',
'deleteMany',
'createMany',
'upsert',
]),
dkvInvoiceHistory: wrapModel(dkvInvoiceHistory, 'dkvInvoiceHistory', [
'findMany',
'count',
'create',
'findFirst',
]),
};
},
};
@@ -117,13 +285,27 @@ function makeFakeCrypto(overrides: Partial<{ encrypt: any; decrypt: any }> = {})
};
}
function makeDkvService(prisma: any, cryptoOverrides: Partial<{ encrypt: any; decrypt: any }> = {}) {
const crypto = makeFakeCrypto(cryptoOverrides);
const parser = {} as any;
const exporter = {} as any;
const mailer = { sendExportEmail: vi.fn() } as any;
const imapProvider = { testConnection: vi.fn(async () => ({ success: true })) } as any;
const exchangeProvider = { testConnection: vi.fn(async () => ({ success: true })) } as any;
function makeDkvService(
prisma: any,
overrides: Partial<{
encrypt: any;
decrypt: any;
parser: any;
exporter: any;
mailer: any;
imapProvider: any;
exchangeProvider: any;
}> = {},
) {
const crypto = makeFakeCrypto(overrides);
const parser = overrides.parser ?? ({} as any);
const exporter = overrides.exporter ?? ({} as any);
const mailer = overrides.mailer ?? ({ sendExportEmail: vi.fn() } as any);
const imapProvider =
overrides.imapProvider ??
({ testConnection: vi.fn(async () => ({ success: true })), fetchPdfAttachments: vi.fn(async () => []) } as any);
const exchangeProvider =
overrides.exchangeProvider ?? ({ testConnection: vi.fn(async () => ({ success: true })) } as any);
const service = new DkvService(
prisma,
@@ -255,4 +437,227 @@ describe('DkvService — Bindung an forTenant() (260909-mir)', () => {
expect(result).not.toBeNull();
expect((result as any).encryptedInboxCreds).toBeUndefined();
});
// ─── Aufgabe 3 (260909-mir): dkvVehicleMaster / dkvInvoiceHistory / getExportFile ───
afterEach(() => {
vi.restoreAllMocks();
});
it('Test 1: listVehicles/createVehicle stehen gebunden im Protokoll und liefern bzw. schreiben nur unter dem uebergebenen Mandanten', async () => {
const prisma = makeFakePrisma();
prisma.__seedVehicle({ id: 'veh-a1', tenantId: 't1', kennzeichen: 'A-ONLY-1' });
prisma.__seedVehicle({ id: 'veh-b1', tenantId: 't2', kennzeichen: 'B-ONLY-1' });
const { service } = makeDkvService(prisma);
const listed = await service.listVehicles('t1');
expect(listed).toHaveLength(1);
expect(listed[0].tenantId).toBe('t1');
await service.createVehicle('t1', { kennzeichen: 'NEU-1', marke: 'X', modell: 'Y', fahrer: 'Z' } as any);
expectBoundCall(prisma, 't1', 'dkvVehicleMaster', 'findMany');
expectBoundCall(prisma, 't1', 'dkvVehicleMaster', 'create');
const listedAgain = await service.listVehicles('t1');
expect(listedAgain).toHaveLength(2);
expect(listedAgain.every((v: any) => v.tenantId === 't1')).toBe(true);
});
it('Test 2: updateVehicle/deleteVehicle — BEIDE Anweisungen (Besitzpruefung und Schreibzugriff) stehen gebunden im Protokoll', async () => {
const prisma = makeFakePrisma();
prisma.__seedVehicle({ id: 'veh-a1', tenantId: 't1', kennzeichen: 'A-ONLY-1' });
prisma.__seedVehicle({ id: 'veh-a2', tenantId: 't1', kennzeichen: 'A-ONLY-2' });
const { service } = makeDkvService(prisma);
await service.updateVehicle('t1', 'veh-a1', { marke: 'Neu' } as any);
expectBoundCall(prisma, 't1', 'dkvVehicleMaster', 'findFirst');
expectBoundCall(prisma, 't1', 'dkvVehicleMaster', 'update');
await service.deleteVehicle('t1', 'veh-a2');
const deleteFindFirstCalls = prisma.__boundCallLog.filter(
(c: any) => c.tenantId === 't1' && c.model === 'dkvVehicleMaster' && c.method === 'findFirst',
);
expect(deleteFindFirstCalls.length).toBeGreaterThanOrEqual(2);
expectBoundCall(prisma, 't1', 'dkvVehicleMaster', 'delete');
});
it('Test 3: updateVehicle/deleteVehicle auf ein Fahrzeug eines FREMDEN Mandanten werfen weiterhin die vorhandene NotFoundException', async () => {
const prisma = makeFakePrisma();
prisma.__seedVehicle({ id: 'veh-b1', tenantId: 't2', kennzeichen: 'B-ONLY-1' });
const { service } = makeDkvService(prisma);
await expect(service.updateVehicle('t1', 'veh-b1', { marke: 'X' } as any)).rejects.toThrow('Vehicle not found');
await expect(service.deleteVehicle('t1', 'veh-b1')).rejects.toThrow('Vehicle not found');
});
it('Test 4: importVehiclesCsv in beiden Modi — jede Anweisung steht gebunden im Protokoll; der Ersetzen-Modus loescht ausschliesslich Fahrzeuge des eigenen Mandanten', async () => {
const prisma = makeFakePrisma();
prisma.__seedVehicle({ id: 'veh-a1', tenantId: 't1', kennzeichen: 'ALT-1' });
prisma.__seedVehicle({ id: 'veh-b1', tenantId: 't2', kennzeichen: 'B-ONLY-1' });
const { service } = makeDkvService(prisma);
const csv = 'Kennzeichen;Marke;Modell;Fahrer\nNEU-1;Marke;Modell;Fahrer';
await service.importVehiclesCsv('t1', csv, 'replace');
expectBoundCall(prisma, 't1', 'dkvVehicleMaster', 'deleteMany');
expectBoundCall(prisma, 't1', 'dkvVehicleMaster', 'createMany');
const t1Vehicles = await service.listVehicles('t1');
const t2Vehicles = await service.listVehicles('t2');
expect(t1Vehicles.map((v: any) => v.kennzeichen)).toEqual(['NEU-1']);
expect(t2Vehicles).toHaveLength(1); // t2's vehicle survived the t1-scoped replace
const csvMerge = 'Kennzeichen;Marke;Modell;Fahrer\nNEU-1;Marke2;Modell2;Fahrer2\nWEITERES-1;M;M;F';
await service.importVehiclesCsv('t1', csvMerge, 'merge');
expectBoundCall(prisma, 't1', 'dkvVehicleMaster', 'upsert');
});
it('Test 5: getHistory — beide parallel gestarteten Abfragen stehen gebunden im Protokoll, die Gesamtzahl zaehlt nur eigene Zeilen', async () => {
const prisma = makeFakePrisma();
prisma.__seedHistory({ id: 'hist-a1', tenantId: 't1' });
prisma.__seedHistory({ id: 'hist-a2', tenantId: 't1' });
prisma.__seedHistory({ id: 'hist-b1', tenantId: 't2' });
const { service } = makeDkvService(prisma);
const result = await service.getHistory('t1', 1, 20);
expect(result.total).toBe(2);
expect(result.items).toHaveLength(2);
expectBoundCall(prisma, 't1', 'dkvInvoiceHistory', 'findMany');
expectBoundCall(prisma, 't1', 'dkvInvoiceHistory', 'count');
});
it('Test 6: die beiden Historien-Schreibzugriffe der Verarbeitungsstrecke (Erfolgsfall und Zerlegungsfehler) stehen gebunden im Protokoll', async () => {
// Erfolgsfall
const prismaOk = makeFakePrisma();
prismaOk.__seedConfig('t1', {
id: 'cfg-1',
protocol: 'imap',
isActive: true,
host: 'mail.example.invalid',
folder: 'INBOX',
encryptedInboxCreds: 'enc({"username":"u","password":"p"})',
});
const email = { subject: 'RG', uid: 1, attachments: [{ buffer: Buffer.from('pdf-bytes') }] };
const parserOk = {
parsePdf: vi.fn(async () => ({
vehicles: [],
rechnungsnummer: 'RG-1',
rechnungsdatum: '01.01.2026',
})),
};
const exporter = {
buildExcelBuffer: vi.fn(() => Buffer.from('xlsx')),
writeAndPrune: vi.fn(() => 'RG-DKV-RG-1-260101.xlsx'),
resolveFahrzeug: vi.fn(() => ''),
};
const imapProvider = { fetchPdfAttachments: vi.fn(async () => [email]) };
const { service: serviceOk } = makeDkvService(prismaOk, { parser: parserOk, exporter, imapProvider });
await serviceOk.checkNow('t1');
expectBoundCall(prismaOk, 't1', 'dkvInvoiceHistory', 'create');
// Zerlegungsfehler-Fall
const prismaFail = makeFakePrisma();
prismaFail.__seedConfig('t1', {
id: 'cfg-2',
protocol: 'imap',
isActive: true,
host: 'mail.example.invalid',
folder: 'INBOX',
encryptedInboxCreds: 'enc({"username":"u","password":"p"})',
});
const parserFail = { parsePdf: vi.fn(async () => { throw new Error('kaputt'); }) };
const imapProviderFail = { fetchPdfAttachments: vi.fn(async () => [email]) };
const { service: serviceFail } = makeDkvService(prismaFail, {
parser: parserFail,
exporter,
imapProvider: imapProviderFail,
});
await serviceFail.checkNow('t1');
expectBoundCall(prismaFail, 't1', 'dkvInvoiceHistory', 'create');
});
it('Test 7: der gebuendelte Lesezugriff auf die Fahrzeugstammdaten beim Aufbau der Ausfuhrzeilen steht gebunden im Protokoll und zieht keine Fahrzeuge eines zweiten Mandanten in die Ausfuhrdatei', async () => {
const prisma = makeFakePrisma();
prisma.__seedConfig('t1', {
id: 'cfg-1',
protocol: 'imap',
isActive: true,
host: 'mail.example.invalid',
folder: 'INBOX',
encryptedInboxCreds: 'enc({"username":"u","password":"p"})',
});
prisma.__seedVehicle({ id: 'veh-a1', tenantId: 't1', kennzeichen: 'A-1', marke: 'MarkeA', modell: 'ModellA', fahrer: 'FahrerA' });
prisma.__seedVehicle({ id: 'veh-b1', tenantId: 't2', kennzeichen: 'A-1', marke: 'FREMD', modell: 'FREMD', fahrer: 'FREMD-Fahrer' });
const email = { subject: 'RG', uid: 1, attachments: [{ buffer: Buffer.from('pdf-bytes') }] };
let capturedRows: any[] | null = null;
const parser = {
parsePdf: vi.fn(async () => ({
vehicles: [{ kennzeichen: 'A-1', transactions: [{ lieferdatum: '01.01.2026', ort: 'Ort', kilometerstand: 100 }] }],
rechnungsnummer: 'RG-1',
rechnungsdatum: '01.01.2026',
})),
};
const exporter = {
buildExcelBuffer: vi.fn((rows: any[]) => {
capturedRows = rows;
return Buffer.from('xlsx');
}),
writeAndPrune: vi.fn(() => 'RG-DKV-RG-1-260101.xlsx'),
resolveFahrzeug: vi.fn((v: any) => `${v.marke}/${v.modell}/${v.kennzeichen}`),
};
const imapProvider = { fetchPdfAttachments: vi.fn(async () => [email]) };
const { service } = makeDkvService(prisma, { parser, exporter, imapProvider });
await service.checkNow('t1');
expectBoundCall(prisma, 't1', 'dkvVehicleMaster', 'findMany');
expect(capturedRows).not.toBeNull();
expect(capturedRows![0].fahrer).toBe('FahrerA');
expect(capturedRows![0].fahrer).not.toBe('FREMD-Fahrer');
});
it('Test 8: getExportFile liefert eine Datei, zu der eine Historienzeile DIESES Mandanten mit passendem Dateinamen existiert', async () => {
const prisma = makeFakePrisma();
prisma.__seedHistory({ id: 'hist-a1', tenantId: 't1', exportFilename: 'RG-DKV-TEST-A.xlsx' });
const { service } = makeDkvService(prisma);
vi.mocked(fs.existsSync).mockReturnValue(true);
vi.mocked(fs.readFileSync).mockReturnValue(Buffer.from('xlsx-bytes'));
const buffer = await service.getExportFile('t1', 'RG-DKV-TEST-A.xlsx');
expect(buffer.toString()).toBe('xlsx-bytes');
});
it('Test 9: getExportFile verweigert dieselbe Datei einem ZWEITEN Mandanten mit der vorhandenen NotFoundException, obwohl die Datei existiert und das Namensmuster besteht (Befund E — die geschlossene Luecke)', async () => {
const prisma = makeFakePrisma();
prisma.__seedHistory({ id: 'hist-a1', tenantId: 't1', exportFilename: 'RG-DKV-TEST-A.xlsx' });
const { service } = makeDkvService(prisma);
vi.mocked(fs.existsSync).mockReturnValue(true);
vi.mocked(fs.readFileSync).mockReturnValue(Buffer.from('xlsx-bytes'));
await expect(service.getExportFile('t2', 'RG-DKV-TEST-A.xlsx')).rejects.toThrow(
'Export file not found: RG-DKV-TEST-A.xlsx',
);
});
it('Test 10: der Riegel laeuft ueber einen GEBUNDENEN Lesezugriff auf die Historie — im Bindungsprotokoll nachweisbar, nicht nur am Ergebnis', async () => {
const prisma = makeFakePrisma();
prisma.__seedHistory({ id: 'hist-a1', tenantId: 't1', exportFilename: 'RG-DKV-TEST-A.xlsx' });
const { service } = makeDkvService(prisma);
vi.mocked(fs.existsSync).mockReturnValue(true);
vi.mocked(fs.readFileSync).mockReturnValue(Buffer.from('xlsx-bytes'));
await service.getExportFile('t1', 'RG-DKV-TEST-A.xlsx');
expectBoundCall(prisma, 't1', 'dkvInvoiceHistory', 'findFirst');
});
});