Files
tessera-ctl/apps/api/src/groups/migration-sql.spec.ts
T
schalli c5c704bae9 feat(15-01): Group/GroupMembership/ModuleGrant schema + D-06 backfill migration
- Group/GroupMembership/ModuleGrant models plus MembershipSource enum
  (D-05), placed under TenantModuleActivation with German block comment
- Hand-SQL appended to the generated migration: partial unique index for
  one default group per tenant (D-13), CHECK num_nonnulls xor-constraint
  plus two partial unique indexes for ModuleGrant (D-04), and the D-06
  backfill (Group -> GroupMembership -> ModuleGrant, each INSERT guarded
  by WHERE NOT EXISTS for idempotent re-runs on `prisma migrate deploy`)
- apps/api/src/groups/migration-sql.spec.ts verifies the hand-SQL by
  reading migration.sql directly, no DB required
- Verified against the local DB: default-group count matches tenant
  count, membership/grant counts match existing users/active
  activations, and the XOR constraint rejects a group+user-less insert
2026-08-04 15:03:48 +02:00

69 lines
2.8 KiB
TypeScript

import { readdirSync, readFileSync } from 'node:fs';
import { join } from 'node:path';
import { describe, expect, it } from 'vitest';
/**
* Prüft die hand-editierten SQL-Ergänzungen in den beiden Phase-15-
* Migrationen (Plan 15-01, Task 1 + Task 3), ohne eine Datenbank zu
* brauchen — reiner Textabgleich der generierten migration.sql-Dateien.
* Stil folgt dem Repo-Muster hand-editierter Migrationen
* (20260618112133_rls_policies, 20260721150000_tender_cpv_divisions_backfill).
*/
const MIGRATIONS_DIR = join(__dirname, '../../prisma/migrations');
function readMigrationSql(suffix: string): string {
const dirs = readdirSync(MIGRATIONS_DIR, { withFileTypes: true })
.filter((entry) => entry.isDirectory() && entry.name.endsWith(suffix))
.map((entry) => entry.name);
if (dirs.length !== 1) {
throw new Error(
`Expected exactly one migration directory ending in "${suffix}", found ${dirs.length}: ${dirs.join(', ')}`,
);
}
return readFileSync(join(MIGRATIONS_DIR, dirs[0], 'migration.sql'), 'utf-8');
}
describe('add_groups_and_module_grants migration.sql (D-04, D-06, D-13)', () => {
const sql = readMigrationSql('_add_groups_and_module_grants');
it('erzwingt genau eine Standardgruppe pro Mandant (D-13, partieller Unique-Index)', () => {
expect(sql).toContain('Group_one_default_per_tenant');
expect(sql).toContain('WHERE "isDefault" = true');
});
it('erzwingt die Entweder-oder-Beziehung Gruppe XOR Benutzer per CHECK-Constraint (D-04)', () => {
expect(sql).toContain('ModuleGrant_group_xor_user');
expect(sql).toContain('num_nonnulls("groupId", "userId") = 1');
});
it('schützt beide ModuleGrant-Varianten (Gruppe/Benutzer) je Modul über partielle Unique-Indizes', () => {
expect(sql).toContain('ModuleGrant_tenant_module_group_unique');
expect(sql).toContain('ModuleGrant_tenant_module_user_unique');
});
it('D-06-Backfill: alle drei INSERT-Statements verwenden gen_random_uuid() für neue IDs', () => {
const occurrences = sql.match(/gen_random_uuid\(\)/g) ?? [];
expect(occurrences.length).toBe(3);
});
it('D-06-Backfill: alle drei INSERT-Statements tragen einen NOT-EXISTS-Wächter (idempotent bei Wiederholungslauf)', () => {
const occurrences = sql.match(/NOT EXISTS/g) ?? [];
expect(occurrences.length).toBe(3);
});
it('D-06-Backfill läuft in der Reihenfolge Group vor GroupMembership vor ModuleGrant', () => {
const groupIdx = sql.indexOf('INSERT INTO "Group"');
const membershipIdx = sql.indexOf('INSERT INTO "GroupMembership"');
const grantIdx = sql.indexOf('INSERT INTO "ModuleGrant"');
expect(groupIdx).toBeGreaterThan(-1);
expect(membershipIdx).toBeGreaterThan(-1);
expect(grantIdx).toBeGreaterThan(-1);
expect(groupIdx).toBeLessThan(membershipIdx);
expect(membershipIdx).toBeLessThan(grantIdx);
});
});