Files
tessera-ctl/apps/api/src/prisma/prisma-tenant.extension.spec.ts
T
schalli 7f08b27eea feat(jts-02): groups.service.ts binden, Transaktionen tragfaehig machen, Absicherung sehend machen
prisma-tenant.extension.ts bekommt withTenantTransaction(prisma, tenantId, fn)
— die interaktive Transaktion auf dem UNgebundenen Client mit set_config als
erster Anweisung direkt auf tx, die in Aufgabe 1 als einzige der drei
gemessenen Formen sowohl die Einzelmessung als auch eine Lastprobe unter
echter Nebenlaeufigkeit bestand (die Array-Form auf dem gebundenen Client
verteilt jede Operation auf eine eigene Teiltransaktion; die interaktive Form
auf dem gebundenen Client brach unter 40 parallelen Aufrufen mit P2028 ab).

rls-access-inventory.spec.ts bekommt eine dritte Erkennung fuer
Modellzugriffe ueber den Rueckgabeparameter einer interaktiven Transaktion
(zwei Formen: direkter Empfaenger.$transaction(async...) und das neue
Hilfsmittel withTenantTransaction(...)) — macht das Paar
(groups.service.ts, tenantModuleActivation) erstmals sichtbar, das bislang
keine Pruefung dieses Projekts je gesehen hat.

groups.service.ts: alle zwoelf Methoden inklusive der drei Transaktionen
(update() isDefault:true, reassignDefaultBeforeDelete(), ensureDefaultGroup())
laufen jetzt ueber den Mandantenkontext. Zaehler und Transaktion in
ensureDefaultGroup() sind gemeinsam gebunden (T-JTS-05). addUserToDefaultGroup()
prueft neu, dass der Zielbenutzer zum Mandanten gehoert (Befund E, T-JTS-02) —
die Regel auf GroupMembership prueft nachweislich nur die Gruppenseite.

groups.service.spec.ts bekommt zwei unterscheidbare Clients ueber demselben
Speicher-Fake (Muster aus 260909-ipc, auf die interaktive Form uebertragen)
und 13 neue Bindungsnachweise; alle 42 Bestandstests bleiben gruen.
Klassifikationsdokument nachgezogen. 737 Tests und die Typpruefung gruen.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AMASaSxv5QMY7RncqZriRR
2026-09-09 15:01:24 +02:00

212 lines
8.9 KiB
TypeScript

import { readFileSync } from 'node:fs';
import { join } from 'node:path';
import { describe, expect, it, vi } from 'vitest';
import { forTenant, withTenantTransaction } from './prisma-tenant.extension';
/**
* Prueft ohne laufende Datenbank die FORM des Aufrufs, nicht seinen mit
* Produktionscode erzeugten Inhalt (WINDOWS #20, Lehre aus dem
* tautologischen Test in STATE.md — Pitfall "Tautologischer Test"): ein
* vorgetaeuschter Client zeichnet auf, dass `$transaction` mit einem Feld
* aus zwei Eintraegen aufgerufen wird, dass der Rueckgabewert der zweite
* Eintrag ist, und dass `query` waehrend des Aufbaus dieses Feldes genau
* einmal beruehrt wird.
*/
const EXTENSION_SOURCE_PATH = join(__dirname, 'prisma-tenant.extension.ts');
/**
* Der Kopfkommentar der Datei erklaert absichtlich das ALTE, defekte
* Verhalten (inklusive $executeRawUnsafe und der interaktiven
* $transaction(async ...)-Form) als Beleg fuer die Reparatur. Eine
* Volltextpruefung auf den Quelltext wuerde deshalb faelschlich fehlschlagen
* — Block- und Zeilenkommentare muessen vor der Pruefung des tatsaechlichen
* Codes herausgefiltert werden (gleiches Vorgehen wie in
* auth-lookup-functions.spec.ts fuer die Migrations-SQL).
*/
function stripComments(source: string): string {
return source
.replace(/\/\*[\s\S]*?\*\//g, '')
.replace(/^\s*\/\/.*$/gm, '');
}
/**
* Schneidet den Quelltext einer einzelnen Top-Level-`export function`
* heraus (bis zur naechsten `export function` oder zum Dateiende). Seit
* 260909-jts (Aufgabe 2) enthaelt diese Datei ZWEI Funktionen mit
* unterschiedlichem, jeweils gemessen begruendetem Transaktionsmuster —
* die Array-Form-Garantie unten gilt nachweislich nur fuer `forTenant()`
* selbst, nicht mehr fuer die gesamte Datei.
*/
function extractFunctionSource(source: string, functionName: string): string {
const startMarker = `export function ${functionName}`;
const startIdx = source.indexOf(startMarker);
if (startIdx === -1) return '';
const rest = source.slice(startIdx);
const nextExportIdx = rest.indexOf('\nexport function ', startMarker.length);
return nextExportIdx === -1 ? rest : rest.slice(0, nextExportIdx);
}
describe('forTenant() — Array-Form von $transaction (WINDOWS #20)', () => {
it('ruft $transaction mit einem Feld aus genau zwei Eintraegen auf', async () => {
const transactionCalls: unknown[] = [];
const fakeQueryResult = { id: 'row-1' };
const fakePrisma: any = {
$transaction: vi.fn((arg: unknown) => {
transactionCalls.push(arg);
expect(Array.isArray(arg)).toBe(true);
expect((arg as unknown[]).length).toBe(2);
return Promise.resolve(['set_config-result', fakeQueryResult]);
}),
$extends: (config: any) => {
// Reproduziert nur den Teil der echten $extends-API, den
// $allOperations braucht — kein echter Prisma-Client noetig.
return {
async __invoke(args: unknown, query: (args: unknown) => unknown) {
return config.query.$allOperations({ args, query });
},
};
},
$executeRaw: vi.fn((_strings: TemplateStringsArray, ..._values: unknown[]) => {
return 'set-config-promise';
}),
};
const scoped = forTenant(fakePrisma, 'tenant-a') as any;
let queryCallCount = 0;
const query = (args: unknown) => {
queryCallCount += 1;
return fakeQueryResult;
};
const result = await scoped.__invoke({ where: { id: 'row-1' } }, query);
expect(fakePrisma.$transaction).toHaveBeenCalledTimes(1);
expect(transactionCalls).toHaveLength(1);
expect((transactionCalls[0] as unknown[]).length).toBe(2);
// Rueckgabewert ist der ZWEITE Eintrag des Felds (der Query-Aufruf),
// nicht das Ergebnis von set_config.
expect(result).toBe(fakeQueryResult);
// query() wurde beim Aufbau des Felds genau einmal beruehrt.
expect(queryCallCount).toBe(1);
});
it('setzt den Mandantenkontext ueber ein getaggtes $executeRaw-Template, nicht ueber zusammengebauten Text', async () => {
const fakePrisma: any = {
$transaction: vi.fn((arg: unknown) => Promise.resolve(['set-config-result', 'query-result'])),
$extends: (config: any) => ({
async __invoke(args: unknown, query: (args: unknown) => unknown) {
return config.query.$allOperations({ args, query });
},
}),
$executeRaw: vi.fn((strings: TemplateStringsArray, ...values: unknown[]) => {
// Ein getaggtes Template liefert ein Array von Textstuecken plus die
// interpolierten Werte getrennt — genau das ist der Beleg dafuer,
// dass der Mandantenwert parametrisiert und nicht in den SQL-Text
// eingebaut wird.
expect(Array.isArray(strings)).toBe(true);
expect(values).toContain('tenant-with-quote-\' OR 1=1');
return 'set-config-promise';
}),
};
const scoped = forTenant(fakePrisma, "tenant-with-quote-' OR 1=1") as any;
await scoped.__invoke({}, () => 'query-result');
expect(fakePrisma.$executeRaw).toHaveBeenCalledTimes(1);
});
it('verwendet im tatsaechlichen Code (ohne Kommentare) kein $executeRawUnsafe mehr', () => {
const source = stripComments(readFileSync(EXTENSION_SOURCE_PATH, 'utf-8'));
expect(source).not.toContain('$executeRawUnsafe');
});
it('nutzt im tatsaechlichen Code die Array-Form von $transaction (kein interaktiver async-Callback) — innerhalb von forTenant() selbst', () => {
const source = stripComments(readFileSync(EXTENSION_SOURCE_PATH, 'utf-8'));
const forTenantSource = extractFunctionSource(source, 'forTenant');
expect(forTenantSource).not.toBe('');
expect(forTenantSource).toMatch(/\$transaction\(\s*\[/);
expect(forTenantSource).not.toMatch(/\$transaction\(\s*async/);
});
});
describe('withTenantTransaction() — interaktive Callback-Form auf dem UNgebundenen Client (260909-jts, Aufgabe 1)', () => {
it('nutzt im tatsaechlichen Code die interaktive Callback-Form auf tx, nicht die Array-Form (gemessen: einzige Form, die die Lastprobe bestand)', () => {
const source = stripComments(readFileSync(EXTENSION_SOURCE_PATH, 'utf-8'));
const withTenantTransactionSource = extractFunctionSource(source, 'withTenantTransaction');
expect(withTenantTransactionSource).not.toBe('');
expect(withTenantTransactionSource).toMatch(/\$transaction\(async/);
});
it('setzt den Mandantenkontext als erste Anweisung DIREKT AUF tx, nicht auf dem aeusseren Client', async () => {
const setConfigCalls: unknown[] = [];
const fakeTx: any = {
$executeRaw: vi.fn((strings: TemplateStringsArray, ...values: unknown[]) => {
setConfigCalls.push(values);
return Promise.resolve(1);
}),
};
const fakePrisma: any = {
$transaction: vi.fn((fn: (tx: unknown) => unknown) => fn(fakeTx)),
// Der aeussere Client darf NIE direkt fuer set_config oder die
// eigentliche Abfrage herangezogen werden — nur $transaction selbst.
$executeRaw: vi.fn(() => {
throw new Error('set_config darf nicht auf dem aeusseren Client laufen');
}),
};
const result = await withTenantTransaction(fakePrisma, 'tenant-a', async (tx) => {
expect(tx).toBe(fakeTx);
return 'fn-result';
});
expect(fakePrisma.$transaction).toHaveBeenCalledTimes(1);
expect(fakeTx.$executeRaw).toHaveBeenCalledTimes(1);
expect(setConfigCalls).toEqual([['tenant-a']]);
expect(result).toBe('fn-result');
});
it('reicht denselben tx-Parameter an fn weiter, sodass mehrere Schritte auf derselben Verbindung laufen', async () => {
const touchedByFn: unknown[] = [];
const fakeTx: any = {
$executeRaw: vi.fn(() => Promise.resolve(1)),
group: { create: vi.fn(() => Promise.resolve({ id: 'g1' })) },
user: { findMany: vi.fn(() => Promise.resolve([])) },
};
const fakePrisma: any = {
$transaction: vi.fn((fn: (tx: unknown) => unknown) => fn(fakeTx)),
};
await withTenantTransaction(fakePrisma, 'tenant-a', async (tx) => {
touchedByFn.push(await tx.group.create({ data: {} }));
touchedByFn.push(await tx.user.findMany({ where: {} }));
return null;
});
expect(fakeTx.group.create).toHaveBeenCalledTimes(1);
expect(fakeTx.user.findMany).toHaveBeenCalledTimes(1);
expect(touchedByFn).toEqual([{ id: 'g1' }, []]);
});
it('setzt den Mandantenkontext ueber ein getaggtes Template, nicht ueber zusammengebauten Text (T-02-05)', async () => {
const fakeTx: any = {
$executeRaw: vi.fn((strings: TemplateStringsArray, ...values: unknown[]) => {
expect(Array.isArray(strings)).toBe(true);
expect(values).toContain("tenant-with-quote-' OR 1=1");
return Promise.resolve(1);
}),
};
const fakePrisma: any = {
$transaction: vi.fn((fn: (tx: unknown) => unknown) => fn(fakeTx)),
};
await withTenantTransaction(fakePrisma, "tenant-with-quote-' OR 1=1", async () => 'ok');
expect(fakeTx.$executeRaw).toHaveBeenCalledTimes(1);
});
});