664 lines
26 KiB
TypeScript
664 lines
26 KiB
TypeScript
import * as fs from 'fs';
|
|
import { afterEach, describe, expect, it, vi } from 'vitest';
|
|
import { DkvService } from './dkv.service';
|
|
|
|
/**
|
|
* DkvService.spec — RED-first (TDD) Nachweis fuer die Bindung an
|
|
* forTenant() (260909-mir, Aufgabe 2/3). Dieser Bereich hatte VOR diesem
|
|
* Durchlauf KEINE einzige Testdatei (Befund J) — dieser Fake ist deshalb die
|
|
* Voraussetzung dafuer, dass irgendeine Aussage dieses Plans nachpruefbar
|
|
* ist, nicht eine Zugabe.
|
|
*
|
|
* Zwei-Klienten-Nachweis (Muster aus groups.service.spec.ts /
|
|
* tender-triage.service.spec.ts): `__makeBoundClient(tenantId)` wrappt
|
|
* DIESELBEN In-Memory-Maps mit einer protokollierenden Schicht je Modell.
|
|
* Der ungebundene Fake protokolliert NICHT, der gebundene schon — eine
|
|
* vergessene Bindung wird dadurch sichtbar, ein reiner Identitaets-Mock
|
|
* (`(p) => p`, der ldap-Fehler) wuerde das nicht leisten.
|
|
*
|
|
* Der Fake wird in Aufgabe 3 um `dkvVehicleMaster`/`dkvInvoiceHistory`
|
|
* 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> = {};
|
|
for (const key of Object.keys(select)) {
|
|
if (select[key]) out[key] = (row as any)[key];
|
|
}
|
|
return out;
|
|
}
|
|
|
|
function makeFakePrisma() {
|
|
const configs = new Map<string, any>(); // key: tenantId
|
|
const boundCallLog: { tenantId: string; model: string; method: string }[] = [];
|
|
|
|
const dkvModuleConfig = {
|
|
findFirst: vi.fn(async ({ select }: { select?: Record<string, boolean> } = {}) => {
|
|
// Arbitrary-but-first row — die Form, die der Planer-Startpfad heute
|
|
// benutzt (findFirst() ganz ohne Bedingung). Map bewahrt
|
|
// Einfuegereihenfolge, das genuegt fuer "irgendeine" Zeile.
|
|
const first = configs.values().next().value;
|
|
return first ? _applySelect(first, select) : null;
|
|
}),
|
|
findUnique: vi.fn(
|
|
async ({
|
|
where,
|
|
select,
|
|
}: {
|
|
where: { tenantId: string };
|
|
select?: Record<string, boolean>;
|
|
}) => {
|
|
const row = configs.get(where.tenantId);
|
|
return row ? _applySelect(row, select) : null;
|
|
},
|
|
),
|
|
upsert: vi.fn(
|
|
async ({
|
|
where,
|
|
create,
|
|
update,
|
|
select,
|
|
}: {
|
|
where: { tenantId: string };
|
|
create: Record<string, unknown>;
|
|
update: Record<string, unknown>;
|
|
select?: Record<string, boolean>;
|
|
}) => {
|
|
const existing = configs.get(where.tenantId);
|
|
const record = existing
|
|
? { ...existing, ...update }
|
|
: { id: `cfg-${configs.size + 1}`, ...create };
|
|
configs.set(where.tenantId, record);
|
|
return _applySelect(record, select);
|
|
},
|
|
),
|
|
};
|
|
|
|
// ─── 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 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',
|
|
]),
|
|
};
|
|
},
|
|
};
|
|
|
|
return fake;
|
|
}
|
|
|
|
function expectBoundCall(prisma: any, tenantId: string, model: string, method: string) {
|
|
const found = prisma.__boundCallLog.some(
|
|
(c: any) => c.tenantId === tenantId && c.model === model && c.method === method,
|
|
);
|
|
expect(
|
|
found,
|
|
`erwarteter gebundener Aufruf ${model}.${method}(tenant=${tenantId}) fehlt im Protokoll: ${JSON.stringify(prisma.__boundCallLog)}`,
|
|
).toBe(true);
|
|
}
|
|
|
|
/** Schlichte Attrappen fuer die uebrigen Konstruktor-Abhaengigkeiten von DkvService. */
|
|
function makeFakeCrypto(overrides: Partial<{ encrypt: any; decrypt: any }> = {}) {
|
|
return {
|
|
encrypt: overrides.encrypt ?? vi.fn((plain: string) => `enc(${plain})`),
|
|
decrypt: overrides.decrypt ?? vi.fn((stored: string) => stored.replace(/^enc\(/, '').replace(/\)$/, '')),
|
|
};
|
|
}
|
|
|
|
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,
|
|
crypto as any,
|
|
parser,
|
|
exporter,
|
|
mailer,
|
|
imapProvider,
|
|
exchangeProvider,
|
|
);
|
|
return { service, crypto };
|
|
}
|
|
|
|
describe('DkvService — Bindung an forTenant() (260909-mir)', () => {
|
|
it('Test 1: getConfigForApi(tenantId) — beide Lesezugriffe auf dkvModuleConfig stehen im Bindungsprotokoll unter genau diesem Mandanten', async () => {
|
|
const prisma = makeFakePrisma();
|
|
prisma.__seedConfig('t1', { id: 'cfg-1', protocol: 'imap', isActive: true, encryptedInboxCreds: null });
|
|
const { service } = makeDkvService(prisma);
|
|
|
|
await service.getConfigForApi('t1');
|
|
|
|
const configCalls = prisma.__boundCallLog.filter(
|
|
(c: any) => c.tenantId === 't1' && c.model === 'dkvModuleConfig' && c.method === 'findUnique',
|
|
);
|
|
expect(
|
|
configCalls.length,
|
|
`erwarte mindestens zwei gebundene findUnique-Aufrufe (sicherer + roher Lesezugriff) fuer t1, gefunden: ${JSON.stringify(prisma.__boundCallLog)}`,
|
|
).toBeGreaterThanOrEqual(2);
|
|
});
|
|
|
|
it('Test 2: getConfigForApi eines Mandanten liefert NICHT die Konfiguration eines zweiten Mandanten, wenn beide im Speicher liegen', async () => {
|
|
const prisma = makeFakePrisma();
|
|
prisma.__seedConfig('t1', { id: 'cfg-1', protocol: 'imap', isActive: true, host: 'mail-a.example.invalid', encryptedInboxCreds: null });
|
|
prisma.__seedConfig('t2', { id: 'cfg-2', protocol: 'imap', isActive: true, host: 'mail-b.example.invalid', encryptedInboxCreds: null });
|
|
const { service } = makeDkvService(prisma);
|
|
|
|
const configForT1 = await service.getConfigForApi('t1');
|
|
|
|
expect(configForT1?.host).toBe('mail-a.example.invalid');
|
|
expect(configForT1?.tenantId).toBe('t1');
|
|
});
|
|
|
|
it('Test 3: saveConfig(tenantId, dto) mit gesetztem Benutzernamen und LEEREM Passwort — Lese- und Schreibzugriff sind gebunden, das gespeicherte Passwort bleibt unveraendert', async () => {
|
|
const prisma = makeFakePrisma();
|
|
const crypto = makeFakeCrypto();
|
|
prisma.__seedConfig('t1', {
|
|
id: 'cfg-1',
|
|
protocol: 'imap',
|
|
isActive: true,
|
|
encryptedInboxCreds: crypto.encrypt(JSON.stringify({ username: 'alt-user', password: 'geheim-123' })),
|
|
});
|
|
const { service } = makeDkvService(prisma, { encrypt: crypto.encrypt, decrypt: crypto.decrypt });
|
|
|
|
await service.saveConfig('t1', {
|
|
protocol: 'imap',
|
|
encryption: 'ssl-tls',
|
|
username: 'neu-user',
|
|
password: '',
|
|
} as any);
|
|
|
|
expectBoundCall(prisma, 't1', 'dkvModuleConfig', 'findUnique');
|
|
expectBoundCall(prisma, 't1', 'dkvModuleConfig', 'upsert');
|
|
|
|
const raw = await prisma.dkvModuleConfig.findUnique({ where: { tenantId: 't1' } });
|
|
const rawCreds = JSON.parse(crypto.decrypt(raw.encryptedInboxCreds));
|
|
expect(rawCreds.password).toBe('geheim-123');
|
|
expect(rawCreds.username).toBe('neu-user');
|
|
});
|
|
|
|
it('Test 4: testConnection(tenantId, dto) mit leerem Passwort — der Rueckgriff auf die gespeicherten Zugangsdaten steht gebunden im Protokoll', async () => {
|
|
const prisma = makeFakePrisma();
|
|
const crypto = makeFakeCrypto();
|
|
prisma.__seedConfig('t1', {
|
|
id: 'cfg-1',
|
|
protocol: 'imap',
|
|
isActive: true,
|
|
encryptedInboxCreds: crypto.encrypt(JSON.stringify({ username: 'user-a', password: 'geheim-123' })),
|
|
});
|
|
const { service } = makeDkvService(prisma, { encrypt: crypto.encrypt, decrypt: crypto.decrypt });
|
|
|
|
await service.testConnection('t1', {
|
|
protocol: 'imap',
|
|
encryption: 'ssl-tls',
|
|
password: '',
|
|
} as any);
|
|
|
|
expectBoundCall(prisma, 't1', 'dkvModuleConfig', 'findUnique');
|
|
});
|
|
|
|
it('Test 5: die Verarbeitungsstrecke liest ihre Konfiguration gebunden; bei fehlender Konfiguration bricht sie wie bisher still ab', async () => {
|
|
const prisma = makeFakePrisma();
|
|
// Kein __seedConfig fuer t1 — die Konfiguration fehlt bewusst.
|
|
const { service } = makeDkvService(prisma);
|
|
|
|
const result = await service.checkNow('t1');
|
|
|
|
expectBoundCall(prisma, 't1', 'dkvModuleConfig', 'findUnique');
|
|
// Verhalten bleibt unveraendert: kein Fehler, checkNow meldet 'ok' —
|
|
// die Rechnungsverarbeitung stellt fuer diesen Mandanten still die
|
|
// Arbeit ein (Befund K, Stelle 2), das wird hier nur festgeschrieben.
|
|
expect(result.status).toBe('ok');
|
|
});
|
|
|
|
it('Test 6: der bewusst uebergreifende Planer-Startpfad steht NICHT im Bindungsprotokoll — Fehlen der Bindung ist hier die bestandene Erwartung, NICHT spaeter "reparieren"', async () => {
|
|
const prisma = makeFakePrisma();
|
|
prisma.__seedConfig('t1', { id: 'cfg-1', protocol: 'imap', isActive: true, encryptedInboxCreds: 'enc(egal)' });
|
|
const { service } = makeDkvService(prisma);
|
|
|
|
await service.loadAnyActiveConfigForScheduler();
|
|
|
|
expect(
|
|
prisma.__boundCallLog.length,
|
|
`der Planer-Startpfad darf KEINEN gebundenen Aufruf erzeugen, gefunden: ${JSON.stringify(prisma.__boundCallLog)}`,
|
|
).toBe(0);
|
|
});
|
|
|
|
it('Test 7: der Planer-Startpfad liefert die verschluesselten Zugangsdaten NICHT mit (Befund D — Entlastung wird festgeschrieben, nicht geglaubt)', async () => {
|
|
const prisma = makeFakePrisma();
|
|
prisma.__seedConfig('t1', {
|
|
id: 'cfg-1',
|
|
protocol: 'imap',
|
|
isActive: true,
|
|
encryptedInboxCreds: 'enc(sollte-nie-hier-auftauchen)',
|
|
});
|
|
const { service } = makeDkvService(prisma);
|
|
|
|
const result = await service.loadAnyActiveConfigForScheduler();
|
|
|
|
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');
|
|
});
|
|
});
|