feat(quick-260909-mir): dkv-Historie und Fahrzeugstammdaten binden, Download-Besitzriegel schliessen
This commit is contained in:
@@ -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';
|
import { DkvService } from './dkv.service';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -16,13 +17,22 @@ import { DkvService } from './dkv.service';
|
|||||||
* (`(p) => p`, der ldap-Fehler) wuerde das nicht leisten.
|
* (`(p) => p`, der ldap-Fehler) wuerde das nicht leisten.
|
||||||
*
|
*
|
||||||
* Der Fake wird in Aufgabe 3 um `dkvVehicleMaster`/`dkvInvoiceHistory`
|
* 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', () => ({
|
vi.mock('../prisma/prisma-tenant.extension', () => ({
|
||||||
forTenant: vi.fn((prisma: any, tenantId: string) => prisma.__makeBoundClient(tenantId)),
|
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) {
|
function _applySelect(row: any, select: Record<string, boolean> | undefined) {
|
||||||
if (!select) return { ...row };
|
if (!select) return { ...row };
|
||||||
const out: Record<string, unknown> = {};
|
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 = {
|
const fake: any = {
|
||||||
dkvModuleConfig,
|
dkvModuleConfig,
|
||||||
|
dkvVehicleMaster,
|
||||||
|
dkvInvoiceHistory,
|
||||||
__boundCallLog: boundCallLog,
|
__boundCallLog: boundCallLog,
|
||||||
__seedConfig(tenantId: string, row: Record<string, unknown>) {
|
__seedConfig(tenantId: string, row: Record<string, unknown>) {
|
||||||
configs.set(tenantId, { tenantId, ...row });
|
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) {
|
__makeBoundClient(tenantId: string) {
|
||||||
const wrapped: any = {};
|
const wrapModel = (model: Record<string, any>, modelName: string, methods: string[]) => {
|
||||||
for (const method of ['findFirst', 'findUnique', 'upsert']) {
|
const wrapped: any = {};
|
||||||
wrapped[method] = async (...args: any[]) => {
|
for (const method of methods) {
|
||||||
boundCallLog.push({ tenantId, model: 'dkvModuleConfig', method });
|
wrapped[method] = async (...args: any[]) => {
|
||||||
return (dkvModuleConfig as any)[method](...args);
|
boundCallLog.push({ tenantId, model: modelName, method });
|
||||||
};
|
return model[method](...args);
|
||||||
}
|
};
|
||||||
return { dkvModuleConfig: wrapped };
|
}
|
||||||
|
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 }> = {}) {
|
function makeDkvService(
|
||||||
const crypto = makeFakeCrypto(cryptoOverrides);
|
prisma: any,
|
||||||
const parser = {} as any;
|
overrides: Partial<{
|
||||||
const exporter = {} as any;
|
encrypt: any;
|
||||||
const mailer = { sendExportEmail: vi.fn() } as any;
|
decrypt: any;
|
||||||
const imapProvider = { testConnection: vi.fn(async () => ({ success: true })) } as any;
|
parser: any;
|
||||||
const exchangeProvider = { testConnection: vi.fn(async () => ({ success: true })) } as 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(
|
const service = new DkvService(
|
||||||
prisma,
|
prisma,
|
||||||
@@ -255,4 +437,227 @@ describe('DkvService — Bindung an forTenant() (260909-mir)', () => {
|
|||||||
expect(result).not.toBeNull();
|
expect(result).not.toBeNull();
|
||||||
expect((result as any).encryptedInboxCreds).toBeUndefined();
|
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');
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -433,6 +433,11 @@ export class DkvService {
|
|||||||
recipient: string | undefined,
|
recipient: string | undefined,
|
||||||
vehicleFormatString: string,
|
vehicleFormatString: string,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
|
// Mandantengebunden (260909-mir): EIN gebundener Klient fuer beide
|
||||||
|
// dkvInvoiceHistory.create()-Aufrufe dieser Methode (Erfolgsfall UND
|
||||||
|
// Zerlegungsfehler-Fall).
|
||||||
|
const tenantPrisma = forTenant(this.prisma, tenantId) as any;
|
||||||
|
|
||||||
// D-10: Up to 3 parse retries
|
// D-10: Up to 3 parse retries
|
||||||
let parseResult: Awaited<ReturnType<typeof this.parser.parsePdf>> | null = null;
|
let parseResult: Awaited<ReturnType<typeof this.parser.parsePdf>> | null = null;
|
||||||
let parseError: string | null = null;
|
let parseError: string | null = null;
|
||||||
@@ -452,7 +457,7 @@ export class DkvService {
|
|||||||
|
|
||||||
if (!parseResult) {
|
if (!parseResult) {
|
||||||
// Record parse failure in history (D-10)
|
// Record parse failure in history (D-10)
|
||||||
await this.prisma.dkvInvoiceHistory.create({
|
await tenantPrisma.dkvInvoiceHistory.create({
|
||||||
data: {
|
data: {
|
||||||
tenantId,
|
tenantId,
|
||||||
rechnungsnummer: this._buildRechnungsnummer(null, email.subject, email.uid),
|
rechnungsnummer: this._buildRechnungsnummer(null, email.subject, email.uid),
|
||||||
@@ -509,7 +514,7 @@ export class DkvService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Record history row (D-20)
|
// Record history row (D-20)
|
||||||
await this.prisma.dkvInvoiceHistory.create({
|
await tenantPrisma.dkvInvoiceHistory.create({
|
||||||
data: {
|
data: {
|
||||||
tenantId,
|
tenantId,
|
||||||
rechnungsnummer,
|
rechnungsnummer,
|
||||||
@@ -529,32 +534,47 @@ export class DkvService {
|
|||||||
// ─── Vehicle CRUD ────────────────────────────────────────────────────────────
|
// ─── Vehicle CRUD ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
async listVehicles(tenantId: string) {
|
async listVehicles(tenantId: string) {
|
||||||
return this.prisma.dkvVehicleMaster.findMany({
|
const tenantPrisma = forTenant(this.prisma, tenantId) as any;
|
||||||
|
return tenantPrisma.dkvVehicleMaster.findMany({
|
||||||
where: { tenantId },
|
where: { tenantId },
|
||||||
orderBy: { kennzeichen: 'asc' },
|
orderBy: { kennzeichen: 'asc' },
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async createVehicle(tenantId: string, dto: CreateVehicleDto) {
|
async createVehicle(tenantId: string, dto: CreateVehicleDto) {
|
||||||
return this.prisma.dkvVehicleMaster.create({
|
const tenantPrisma = forTenant(this.prisma, tenantId) as any;
|
||||||
|
return tenantPrisma.dkvVehicleMaster.create({
|
||||||
data: { tenantId, ...dto },
|
data: { tenantId, ...dto },
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Mandantengebunden (260909-mir, Befund G): EIN gebundener Klient fuer
|
||||||
|
* BEIDE Anweisungen — die Besitzpruefung UND den Schreibzugriff. Die
|
||||||
|
* Mandantenbedingung in der Besitzpruefung (`findFirst({ id, tenantId })`)
|
||||||
|
* bleibt zusaetzlich erhalten und wird nicht durch die Bindung ersetzt:
|
||||||
|
* Aufgabe 1 hat gemessen, dass ein gebundenes UPDATE ueber die Kennung
|
||||||
|
* allein auf eine fremde Zeile still 0 Zeilen trifft statt laut zu
|
||||||
|
* scheitern — eine gebundene Vorpruefung mit einem ungebundenen
|
||||||
|
* Schreibzugriff dahinter waere genau die Luecke, nicht die Loesung.
|
||||||
|
*/
|
||||||
async updateVehicle(tenantId: string, id: string, dto: UpdateVehicleDto) {
|
async updateVehicle(tenantId: string, id: string, dto: UpdateVehicleDto) {
|
||||||
const existing = await this.prisma.dkvVehicleMaster.findFirst({
|
const tenantPrisma = forTenant(this.prisma, tenantId) as any;
|
||||||
|
const existing = await tenantPrisma.dkvVehicleMaster.findFirst({
|
||||||
where: { id, tenantId },
|
where: { id, tenantId },
|
||||||
});
|
});
|
||||||
if (!existing) throw new NotFoundException('Vehicle not found');
|
if (!existing) throw new NotFoundException('Vehicle not found');
|
||||||
return this.prisma.dkvVehicleMaster.update({ where: { id }, data: dto });
|
return tenantPrisma.dkvVehicleMaster.update({ where: { id }, data: dto });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Mandantengebunden (260909-mir, Befund G) — siehe updateVehicle() oben. */
|
||||||
async deleteVehicle(tenantId: string, id: string): Promise<{ deleted: boolean }> {
|
async deleteVehicle(tenantId: string, id: string): Promise<{ deleted: boolean }> {
|
||||||
const existing = await this.prisma.dkvVehicleMaster.findFirst({
|
const tenantPrisma = forTenant(this.prisma, tenantId) as any;
|
||||||
|
const existing = await tenantPrisma.dkvVehicleMaster.findFirst({
|
||||||
where: { id, tenantId },
|
where: { id, tenantId },
|
||||||
});
|
});
|
||||||
if (!existing) throw new NotFoundException('Vehicle not found');
|
if (!existing) throw new NotFoundException('Vehicle not found');
|
||||||
await this.prisma.dkvVehicleMaster.delete({ where: { id } });
|
await tenantPrisma.dkvVehicleMaster.delete({ where: { id } });
|
||||||
return { deleted: true };
|
return { deleted: true };
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -567,12 +587,19 @@ export class DkvService {
|
|||||||
* mode='replace': delete all existing vehicles for this tenant, then insert.
|
* mode='replace': delete all existing vehicles for this tenant, then insert.
|
||||||
*
|
*
|
||||||
* Research pattern: CSV Vehicle Import Pattern (RESEARCH.md Code Examples).
|
* Research pattern: CSV Vehicle Import Pattern (RESEARCH.md Code Examples).
|
||||||
|
*
|
||||||
|
* Mandantengebunden (260909-mir): EIN gebundener Klient fuer JEDE
|
||||||
|
* Anweisung in beiden Modi. Keine Kollisionsbehandlung noetig im
|
||||||
|
* Zusammenfuehren-Modus — `@@unique([tenantId, kennzeichen])` traegt den
|
||||||
|
* Mandanten als Teil des Schluessels (Befund H, in Aufgabe 1 gemessen:
|
||||||
|
* `dkvvehiclemaster-schluessel-traegt-mandant-keine-fremdkollision`).
|
||||||
*/
|
*/
|
||||||
async importVehiclesCsv(
|
async importVehiclesCsv(
|
||||||
tenantId: string,
|
tenantId: string,
|
||||||
csvText: string,
|
csvText: string,
|
||||||
mode: 'merge' | 'replace',
|
mode: 'merge' | 'replace',
|
||||||
): Promise<{ imported: number; mode: string }> {
|
): Promise<{ imported: number; mode: string }> {
|
||||||
|
const tenantPrisma = forTenant(this.prisma, tenantId) as any;
|
||||||
const vehicles = _parseVehicleCsv(csvText);
|
const vehicles = _parseVehicleCsv(csvText);
|
||||||
if (vehicles.length === 0) {
|
if (vehicles.length === 0) {
|
||||||
throw new BadRequestException(
|
throw new BadRequestException(
|
||||||
@@ -581,14 +608,14 @@ export class DkvService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (mode === 'replace') {
|
if (mode === 'replace') {
|
||||||
await this.prisma.dkvVehicleMaster.deleteMany({ where: { tenantId } });
|
await tenantPrisma.dkvVehicleMaster.deleteMany({ where: { tenantId } });
|
||||||
await this.prisma.dkvVehicleMaster.createMany({
|
await tenantPrisma.dkvVehicleMaster.createMany({
|
||||||
data: vehicles.map((v) => ({ tenantId, ...v })),
|
data: vehicles.map((v) => ({ tenantId, ...v })),
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
// Merge: upsert by (tenantId, kennzeichen) compound unique key
|
// Merge: upsert by (tenantId, kennzeichen) compound unique key
|
||||||
for (const v of vehicles) {
|
for (const v of vehicles) {
|
||||||
await this.prisma.dkvVehicleMaster.upsert({
|
await tenantPrisma.dkvVehicleMaster.upsert({
|
||||||
where: { tenantId_kennzeichen: { tenantId, kennzeichen: v.kennzeichen } },
|
where: { tenantId_kennzeichen: { tenantId, kennzeichen: v.kennzeichen } },
|
||||||
create: { tenantId, ...v },
|
create: { tenantId, ...v },
|
||||||
update: { marke: v.marke, modell: v.modell, fahrer: v.fahrer },
|
update: { marke: v.marke, modell: v.modell, fahrer: v.fahrer },
|
||||||
@@ -606,6 +633,11 @@ export class DkvService {
|
|||||||
* Get paginated processing history for a tenant.
|
* Get paginated processing history for a tenant.
|
||||||
* Ordered by datumZeit descending (most recent first).
|
* Ordered by datumZeit descending (most recent first).
|
||||||
* T-07-06: pagination prevents unbounded result-set DoS.
|
* T-07-06: pagination prevents unbounded result-set DoS.
|
||||||
|
*
|
||||||
|
* Mandantengebunden (260909-mir): EIN gebundener Klient fuer beide
|
||||||
|
* Abfragen, die ueber `Promise.all` parallel laufen — das ist die
|
||||||
|
* Nebenlaeufigkeitsform, auf die sich dieser Bereich stuetzt (Befund C,
|
||||||
|
* in Aufgabe 1 gemessen: `dkv-zwei-parallele-gebundene-einzelabfragen-je-eigener-kontext`).
|
||||||
*/
|
*/
|
||||||
async getHistory(
|
async getHistory(
|
||||||
tenantId: string,
|
tenantId: string,
|
||||||
@@ -617,15 +649,16 @@ export class DkvService {
|
|||||||
page: number;
|
page: number;
|
||||||
limit: number;
|
limit: number;
|
||||||
}> {
|
}> {
|
||||||
|
const tenantPrisma = forTenant(this.prisma, tenantId) as any;
|
||||||
const skip = (page - 1) * limit;
|
const skip = (page - 1) * limit;
|
||||||
const [items, total] = await Promise.all([
|
const [items, total] = await Promise.all([
|
||||||
this.prisma.dkvInvoiceHistory.findMany({
|
tenantPrisma.dkvInvoiceHistory.findMany({
|
||||||
where: { tenantId },
|
where: { tenantId },
|
||||||
orderBy: { datumZeit: 'desc' },
|
orderBy: { datumZeit: 'desc' },
|
||||||
skip,
|
skip,
|
||||||
take: limit,
|
take: limit,
|
||||||
}),
|
}),
|
||||||
this.prisma.dkvInvoiceHistory.count({ where: { tenantId } }),
|
tenantPrisma.dkvInvoiceHistory.count({ where: { tenantId } }),
|
||||||
]);
|
]);
|
||||||
return { items, total, page, limit };
|
return { items, total, page, limit };
|
||||||
}
|
}
|
||||||
@@ -639,11 +672,25 @@ export class DkvService {
|
|||||||
* pattern `DKV_*.xlsx` before reading. Rejects any filename containing path
|
* pattern `DKV_*.xlsx` before reading. Rejects any filename containing path
|
||||||
* separators, `..`, or characters outside the expected character set.
|
* separators, `..`, or characters outside the expected character set.
|
||||||
*
|
*
|
||||||
* @throws BadRequestException when filename fails validation
|
* OWNERSHIP GATE (260909-mir, Befund E/T-MIR-02 — closes the ldap-class
|
||||||
* @throws NotFoundException when the file does not exist
|
* gap of this area): `user-files/` is a directory SHARED by all tenants
|
||||||
|
* (Befund F/T-MIR-08), so the traversal-safe filename pattern alone never
|
||||||
|
* proved this tenant owns the file — any tenant admin could download
|
||||||
|
* another tenant's export given (or guessed at) the filename. A bound
|
||||||
|
* read against `DkvInvoiceHistory.exportFilename` now decides ownership.
|
||||||
|
* This DELIBERATELY changes behavior: a file that sits on disk but names
|
||||||
|
* no history row for this tenant is no longer downloadable — that is the
|
||||||
|
* intent, not a bug. Absence (no DB row) and foreign ownership (a DB row
|
||||||
|
* under a different tenant) collapse to the SAME NotFoundException so the
|
||||||
|
* response reveals nothing about whether a foreign tenant's file exists.
|
||||||
|
*
|
||||||
|
* @throws BadRequestException when filename fails the pattern check
|
||||||
|
* @throws NotFoundException when no history row of THIS tenant names this
|
||||||
|
* file, or when the file is missing from disk despite an owning row
|
||||||
*/
|
*/
|
||||||
async getExportFile(tenantId: string, filename: string): Promise<Buffer> {
|
async getExportFile(tenantId: string, filename: string): Promise<Buffer> {
|
||||||
// Traversal guard: whitelist-validate the filename before reading
|
// Stage 1 (unchanged, T-07-09): traversal guard, whitelist-validate the
|
||||||
|
// filename before doing anything else with it.
|
||||||
if (
|
if (
|
||||||
filename.includes('/') ||
|
filename.includes('/') ||
|
||||||
filename.includes('\\') ||
|
filename.includes('\\') ||
|
||||||
@@ -653,6 +700,16 @@ export class DkvService {
|
|||||||
throw new BadRequestException('Invalid export filename');
|
throw new BadRequestException('Invalid export filename');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Stage 2 (NEW, 260909-mir): the ownership gate. A bound read — the
|
||||||
|
// only tenant-scoped statement of who this file belongs to.
|
||||||
|
const tenantPrisma = forTenant(this.prisma, tenantId) as any;
|
||||||
|
const owningHistoryRow = await tenantPrisma.dkvInvoiceHistory.findFirst({
|
||||||
|
where: { tenantId, exportFilename: filename },
|
||||||
|
});
|
||||||
|
if (!owningHistoryRow) {
|
||||||
|
throw new NotFoundException(`Export file not found: ${filename}`);
|
||||||
|
}
|
||||||
|
|
||||||
const filePath = path.join(this.userFilesDir, filename);
|
const filePath = path.join(this.userFilesDir, filename);
|
||||||
|
|
||||||
if (!fs.existsSync(filePath)) {
|
if (!fs.existsSync(filePath)) {
|
||||||
@@ -670,6 +727,11 @@ export class DkvService {
|
|||||||
* Vehicles with no matching DkvVehicleMaster entry still appear in the export
|
* Vehicles with no matching DkvVehicleMaster entry still appear in the export
|
||||||
* with an empty Fahrer field (D-13). The Fahrzeug column is resolved via the
|
* with an empty Fahrer field (D-13). The Fahrzeug column is resolved via the
|
||||||
* format string with empty Marke/Modell/Fahrer placeholders for unknown plates.
|
* format string with empty Marke/Modell/Fahrer placeholders for unknown plates.
|
||||||
|
*
|
||||||
|
* Mandantengebunden (260909-mir): der gebuendelte Lesezugriff auf die
|
||||||
|
* Fahrzeugstammdaten laeuft ueber `forTenant()` — ungebunden wuerde
|
||||||
|
* Befund K, Stelle 7 zuschlagen: eine vollstaendige Ausfuhrdatei OHNE
|
||||||
|
* einen einzigen Fahrer, ohne Fehler, ohne Warnung.
|
||||||
*/
|
*/
|
||||||
private async _buildExportRows(
|
private async _buildExportRows(
|
||||||
tenantId: string,
|
tenantId: string,
|
||||||
@@ -677,7 +739,8 @@ export class DkvService {
|
|||||||
vehicleFormatString: string,
|
vehicleFormatString: string,
|
||||||
): Promise<{ lieferdatum: string; fahrzeug: string; fahrer: string; ort: string; kilometerstand: number | null }[]> {
|
): Promise<{ lieferdatum: string; fahrzeug: string; fahrer: string; ort: string; kilometerstand: number | null }[]> {
|
||||||
// Batch load vehicle master to avoid N+1 queries
|
// Batch load vehicle master to avoid N+1 queries
|
||||||
const masters = await this.prisma.dkvVehicleMaster.findMany({ where: { tenantId } });
|
const tenantPrisma = forTenant(this.prisma, tenantId) as any;
|
||||||
|
const masters: any[] = await tenantPrisma.dkvVehicleMaster.findMany({ where: { tenantId } });
|
||||||
// Normalize keys: DKV PDF may omit hyphens or use spaces ("GP JL 740E" vs "GP-JL 740E")
|
// Normalize keys: DKV PDF may omit hyphens or use spaces ("GP JL 740E" vs "GP-JL 740E")
|
||||||
const masterMap = new Map(masters.map((m) => [_normalizeKennzeichen(m.kennzeichen), m]));
|
const masterMap = new Map(masters.map((m) => [_normalizeKennzeichen(m.kennzeichen), m]));
|
||||||
|
|
||||||
|
|||||||
@@ -99,7 +99,7 @@ autoritative Quelle.
|
|||||||
| tenders | 36 | 26 | **war 62/0** — Aufgabe 2/3 (260909-laa) haben die fünf Nutzer-CRUD-Dienste (vier vollständig, `tender-rss-feed.service.ts` teilweise mit im Code begründeter WINDOWS-#19-Grenze) und die Je-Treffer-Hälften der beiden Hintergrunddienste (`tender-digest.scheduler.ts`, `tender-matching.service.ts`) auf `forTenant()` umgestellt. Die 36 verbleibenden ungebundenen Treffer sind die zwölf bewusst nicht angefassten Paare (D-03-Katalog, zwei Fan-out-Adapter) plus die drei bewusst ungebundenen RSS-Pfade plus die übergreifenden Hälften der beiden Hintergrunddienste (Etappe-3-Übergabe) |
|
| tenders | 36 | 26 | **war 62/0** — Aufgabe 2/3 (260909-laa) haben die fünf Nutzer-CRUD-Dienste (vier vollständig, `tender-rss-feed.service.ts` teilweise mit im Code begründeter WINDOWS-#19-Grenze) und die Je-Treffer-Hälften der beiden Hintergrunddienste (`tender-digest.scheduler.ts`, `tender-matching.service.ts`) auf `forTenant()` umgestellt. Die 36 verbleibenden ungebundenen Treffer sind die zwölf bewusst nicht angefassten Paare (D-03-Katalog, zwei Fan-out-Adapter) plus die drei bewusst ungebundenen RSS-Pfade plus die übergreifenden Hälften der beiden Hintergrunddienste (Etappe-3-Übergabe) |
|
||||||
| groups | 0 | 31 | **war 37/0** — Aufgabe 2/3 (260909-jts) haben `groups.service.ts` (12 Methoden) und `module-grants.service.ts` (5 Methoden) vollständig auf `forTenant()`/`withTenantTransaction()` umgestellt. Die neun zusätzlichen, über `tx` gebundenen Zugriffe innerhalb der drei Transaktionen zählt dieses einfache Muster nicht mit (siehe Methodenhinweis oben) |
|
| groups | 0 | 31 | **war 37/0** — Aufgabe 2/3 (260909-jts) haben `groups.service.ts` (12 Methoden) und `module-grants.service.ts` (5 Methoden) vollständig auf `forTenant()`/`withTenantTransaction()` umgestellt. Die neun zusätzlichen, über `tx` gebundenen Zugriffe innerhalb der drei Transaktionen zählt dieses einfache Muster nicht mit (siehe Methodenhinweis oben) |
|
||||||
| ldap | 4 | 26 | **war 21/0** — Aufgabe 2/3 (260909-ipc) haben `ldap-config.service.ts` (5 Methoden) und `ldap.service.ts` (6 Methoden, 11 Abfragen) auf `forTenant()` umgestellt. Die 4 verbleibenden ungebundenen Treffer sind bewusst: `getAllActiveConfigs`/`onApplicationBootstrap` (Befund B) und `resolveEmailForWrite` (Befund A, T-IPC-04) |
|
| ldap | 4 | 26 | **war 21/0** — Aufgabe 2/3 (260909-ipc) haben `ldap-config.service.ts` (5 Methoden) und `ldap.service.ts` (6 Methoden, 11 Abfragen) auf `forTenant()` umgestellt. Die 4 verbleibenden ungebundenen Treffer sind bewusst: `getAllActiveConfigs`/`onApplicationBootstrap` (Befund B) und `resolveEmailForWrite` (Befund A, T-IPC-04) |
|
||||||
| dkv | 21 | 0 | unverändert |
|
| dkv | 1 | 22 | **war 21/0** — Aufgabe 2/3 (260909-mir) haben `dkv.service.ts` vollständig auf `forTenant()` umgestellt: Konfigurationspfade (`loadConfig`, `getConfigForApi`, `saveConfig`, `testConnection`), Historie, Fahrzeugstammdaten und der neue Besitzriegel vor dem Ausfuhrdatei-Download. Gebunden sind es 22 statt 21, weil der Riegel einen zusätzlichen Lesezugriff auf `dkvInvoiceHistory` einführt (T-MIR-03). Der eine verbleibende ungebundene Treffer ist der benannte Planer-Startpfad `loadAnyActiveConfigForScheduler()` (Befund D, WINDOWS #21) — bewusst, mit dreifacher Markierung |
|
||||||
| user | 17 | 0 | unverändert |
|
| user | 17 | 0 | unverändert |
|
||||||
| module-registry | 17 | 0 | unverändert |
|
| module-registry | 17 | 0 | unverändert |
|
||||||
| dashboard | 13 | 0 | unverändert |
|
| dashboard | 13 | 0 | unverändert |
|
||||||
@@ -108,7 +108,7 @@ autoritative Quelle.
|
|||||||
| tenant | 8 | 0 | unverändert |
|
| tenant | 8 | 0 | unverändert |
|
||||||
| favorites | 7 | 0 | unverändert |
|
| favorites | 7 | 0 | unverändert |
|
||||||
| settings | 4 | 0 | unverändert |
|
| settings | 4 | 0 | unverändert |
|
||||||
| **Summe** | **147** | **88** | Ungebunden: war 173 vor dieser Etappe (260909-jts-Stand), Delta = die 26 in Aufgabe 2/3 (260909-laa) umgestellten `tenders`-Rohtreffer. Gebunden: war 62, jetzt zusätzlich 26 in `tenders` |
|
| **Summe** | **127** | **110** | Ungebunden: war 147 nach 260909-laa, Delta = die 20 in Aufgabe 2/3 (260909-mir) umgestellten `dkv`-Rohtreffer. Gebunden: war 88, jetzt zusätzlich 22 in `dkv` (20 umgestellte plus 2 neue Zugriffe des Besitzriegels). Quergemessen beim Abschluss von 260909-mir: ein roher `grep` über `apps/api/src` zählt 126 statt 127 ungebundene Treffer — die Differenz stammt aus einer geringfügig anderen Ausschlussregel für Testdateien, nicht aus einer offenen Fundstelle. Diese Übersicht ist eine Buchführungshilfe; **autoritativ ist die Fundstellentabelle unten**, die `rls-access-inventory.spec.ts` bei jedem Lauf gegen den Quelltext prüft |
|
||||||
|
|
||||||
## Klassen-Verteilung (nach (Datei, Modell)-Fundstellen, 62 Paare)
|
## Klassen-Verteilung (nach (Datei, Modell)-Fundstellen, 62 Paare)
|
||||||
|
|
||||||
@@ -210,9 +210,9 @@ verwendet), Klasse, Stand (`gebunden`/`ungebunden`/`gemischt`, seit
|
|||||||
| apps/api/src/dashboard/dashboard.service.ts | module | keine-mandantengebundene-tabelle | ungebunden | Modulkatalog ist plattformweit, kein `tenantId` (Migration 20260909140000, Gruppe b). |
|
| apps/api/src/dashboard/dashboard.service.ts | module | keine-mandantengebundene-tabelle | ungebunden | Modulkatalog ist plattformweit, kein `tenantId` (Migration 20260909140000, Gruppe b). |
|
||||||
| apps/api/src/dashboard/dashboard.service.ts | searchProvider | muss-mandantengebunden | ungebunden | `tenantId` nullbar (WINDOWS #19) — heutige, tatsächlich gespeicherte Zeilen sind nutzerangelegt und tragen einen Mandanten; Vorgabe-Anbieter kommen laut 05-02 aus Konstanten, nicht aus der DB. |
|
| apps/api/src/dashboard/dashboard.service.ts | searchProvider | muss-mandantengebunden | ungebunden | `tenantId` nullbar (WINDOWS #19) — heutige, tatsächlich gespeicherte Zeilen sind nutzerangelegt und tragen einen Mandanten; Vorgabe-Anbieter kommen laut 05-02 aus Konstanten, nicht aus der DB. |
|
||||||
| apps/api/src/dashboard/dashboard.service.ts | widgetInstance | muss-mandantengebunden | ungebunden | Platzierte Dashboard-Widgets eines Nutzers, `tenantId`-Spalte vorhanden. |
|
| apps/api/src/dashboard/dashboard.service.ts | widgetInstance | muss-mandantengebunden | ungebunden | Platzierte Dashboard-Widgets eines Nutzers, `tenantId`-Spalte vorhanden. |
|
||||||
| apps/api/src/dkv/dkv.service.ts | dkvInvoiceHistory | muss-mandantengebunden | ungebunden | DKV-Rechnungshistorie je Mandant, `tenantId`-Spalte vorhanden. |
|
| apps/api/src/dkv/dkv.service.ts | dkvInvoiceHistory | muss-mandantengebunden | gebunden | DKV-Rechnungshistorie je Mandant, `tenantId`-Spalte vorhanden. Seit 260909-mir (Aufgabe 3) laufen beide Historien-Schreibzugriffe der Verarbeitungsstrecke, beide parallelen Lesezugriffe von `getHistory` und der neue Riegel vor dem Ausfuhrdatei-Download vollstaendig ueber `forTenant()`. |
|
||||||
| apps/api/src/dkv/dkv.service.ts | dkvModuleConfig | muss-mandantengebunden | gemischt | Postfach-/Zugangsdaten des DKV-Moduls je Mandant. Seit 260909-mir (Aufgabe 2) laufen `loadConfig`, `getConfigForApi`, `saveConfig`, `testConnection` und der Konfigurations-Lesezugriff der Verarbeitungsstrecke ueber `forTenant()`. Die Mischung stammt ausschliesslich vom einen benannten, bewusst ungebundenen Planer-Startpfad `loadAnyActiveConfigForScheduler()` (WINDOWS #21) — keine uebersehene Fundstelle. |
|
| apps/api/src/dkv/dkv.service.ts | dkvModuleConfig | muss-mandantengebunden | gemischt | Postfach-/Zugangsdaten des DKV-Moduls je Mandant. Seit 260909-mir (Aufgabe 2) laufen `loadConfig`, `getConfigForApi`, `saveConfig`, `testConnection` und der Konfigurations-Lesezugriff der Verarbeitungsstrecke ueber `forTenant()`. Die Mischung stammt ausschliesslich vom einen benannten, bewusst ungebundenen Planer-Startpfad `loadAnyActiveConfigForScheduler()` (WINDOWS #21) — keine uebersehene Fundstelle. |
|
||||||
| apps/api/src/dkv/dkv.service.ts | dkvVehicleMaster | muss-mandantengebunden | ungebunden | Fahrzeugstammdaten des DKV-Moduls je Mandant. |
|
| apps/api/src/dkv/dkv.service.ts | dkvVehicleMaster | muss-mandantengebunden | gebunden | Fahrzeugstammdaten des DKV-Moduls je Mandant. Seit 260909-mir (Aufgabe 3) laufen Fahrzeugliste, Anlegen, beide Paare aus Besitzpruefung und Schreibzugriff (Aendern/Loeschen), beide Zweige des CSV-Imports und der gebuendelte Lesezugriff beim Aufbau der Ausfuhrzeilen vollstaendig ueber `forTenant()`; die vorgeschalteten Besitzpruefungen bei Aendern/Loeschen bleiben zusaetzlich bestehen (Befund G — ein gebundenes UPDATE ueber die Kennung allein trifft eine fremde Zeile still, nicht laut). |
|
||||||
| apps/api/src/favorites/favorites.service.ts | favoriteLink | muss-mandantengebunden | ungebunden | Favoriten-Links eines Nutzers, `tenantId`-Spalte vorhanden. |
|
| apps/api/src/favorites/favorites.service.ts | favoriteLink | muss-mandantengebunden | ungebunden | Favoriten-Links eines Nutzers, `tenantId`-Spalte vorhanden. |
|
||||||
| apps/api/src/groups/groups.service.ts | group | muss-mandantengebunden | gebunden | Gruppen sind je Mandant, `tenantId`-Spalte vorhanden. Alle 12 Methoden laufen seit 260909-jts (Aufgabe 2) ueber `forTenant()` bzw. `withTenantTransaction()`. |
|
| apps/api/src/groups/groups.service.ts | group | muss-mandantengebunden | gebunden | Gruppen sind je Mandant, `tenantId`-Spalte vorhanden. Alle 12 Methoden laufen seit 260909-jts (Aufgabe 2) ueber `forTenant()` bzw. `withTenantTransaction()`. |
|
||||||
| apps/api/src/groups/groups.service.ts | groupMembership | muss-mandantengebunden | gebunden | Kein eigenes `tenantId`, RLS über Join auf `Group` (Migration 20260618112133-Nachfolger) — braucht trotzdem `forTenant()`, damit der Join-Kontext gesetzt ist. Seit 260909-jts gebunden, einschliesslich der drei Zugriffe innerhalb des Standardgruppen-Aufbaus (`ensureDefaultGroup`), die zuvor ueber den Transaktionsparameter liefen und fuer keine Pruefung dieses Projekts sichtbar waren (Befund B). |
|
| apps/api/src/groups/groups.service.ts | groupMembership | muss-mandantengebunden | gebunden | Kein eigenes `tenantId`, RLS über Join auf `Group` (Migration 20260618112133-Nachfolger) — braucht trotzdem `forTenant()`, damit der Join-Kontext gesetzt ist. Seit 260909-jts gebunden, einschliesslich der drei Zugriffe innerhalb des Standardgruppen-Aufbaus (`ensureDefaultGroup`), die zuvor ueber den Transaktionsparameter liefen und fuer keine Pruefung dieses Projekts sichtbar waren (Befund B). |
|
||||||
|
|||||||
Reference in New Issue
Block a user