/** * indexes.test.ts — §27: `unique` is never lost on an FK-bearing index. * The incident: 23 BA-declared unique indexes, the 16 scalar ones all emitted, * the 7 FK-bearing ones ALL silently dropped (the synthesized FK column never * carried `unique`, and composites were not representable at all) — leaving * BR-level uniqueness on a racy app-layer AnyAsync blind to soft-deleted rows. */ import { describe, it, expect } from 'vitest' import { generate } from '../generate.js' import { validate } from '../validate.js' import type { ScaffoldEntityInput, EntityField, EntityRelation } from '../types.js' function field(name: string, over: Partial = {}): EntityField { return { name, type: 'string', required: true, isKey: false, indexed: false, ...over } } function rel(over: Partial & Pick): EntityRelation { return { nullable: false, cascadeDelete: false, ...over } } function fixture(overrides: Partial = {}): ScaffoldEntityInput { return { name: 'Driver', pluralName: 'Drivers', module: 'conducteurs', appCode: 'Flotte', applicationCode: 'flotte', domainPrefix: 'cond', namespace: 'Flotte', tenantMode: 'strict', schemaTarget: 'extensions', fields: [field('MobilePhone', { required: false })], relations: [], constraints: [], projectPath: '/tmp/project', ...overrides, } as ScaffoldEntityInput } const configOf = (i: ScaffoldEntityInput) => generate(i).find(f => f.path.endsWith('DriverConfiguration.cs'))!.content describe('scaffold-entity / relation.unique — the FK-bearing unique (Driver (UserId) unique)', () => { it('strict tenant → composite (TenantId, UserId) unique index on the SYNTHESIZED FK column', () => { const cfg = configOf(fixture({ relations: [rel({ type: 'many-to-one', targetEntity: 'User', targetScope: 'core', unique: true })], })) expect(cfg).toContain('builder.HasIndex(e => new { e.TenantId, e.UserId }).IsUnique();') }) it('optional tenant → the two filtered unique indexes (socle pattern)', () => { const cfg = configOf(fixture({ tenantMode: 'optional', relations: [rel({ type: 'one-to-one', targetEntity: 'Vehicle', unique: true })], })) expect(cfg).toContain('builder.HasIndex(e => new { e.TenantId, e.VehicleId }).IsUnique().HasFilter("[TenantId] IS NOT NULL");') expect(cfg).toMatch(/HasIndex\(e => e\.VehicleId\)\.IsUnique\(\)\.HasFilter\("\[TenantId\] IS NULL"\)/) }) it('no tenant → plain unique index on the FK', () => { const cfg = configOf(fixture({ tenantMode: 'none', relations: [rel({ type: 'many-to-one', targetEntity: 'VehicleRegistration', unique: true })], })) expect(cfg).toContain('builder.HasIndex(e => e.VehicleRegistrationId).IsUnique();') }) it('a relation WITHOUT unique keeps the historical shape (no unique index on the FK)', () => { const cfg = configOf(fixture({ relations: [rel({ type: 'many-to-one', targetEntity: 'Vehicle' })], })) expect(cfg).not.toMatch(/e\.VehicleId \}\)\.IsUnique/) }) it('relation.unique applies to a HAND-DECLARED FK Guid field too (the reuse path)', () => { const cfg = configOf(fixture({ fields: [field('UserId', { type: 'guid' }), field('MobilePhone', { required: false })], relations: [rel({ type: 'many-to-one', targetEntity: 'User', targetScope: 'core', unique: true })], })) expect(cfg).toContain('builder.HasIndex(e => new { e.TenantId, e.UserId }).IsUnique();') }) it('unique on a collection relation is rejected (only the owning side carries the FK)', () => { const r = validate(fixture({ relations: [rel({ type: 'one-to-many', targetEntity: 'Licence', unique: true })], })) expect(r.valid).toBe(false) expect(r.errors.some(e => e.includes('unique') && e.includes('OWNING'))).toBe(true) }) }) describe('scaffold-entity / indexes[] — composites (LicenceAlertEmission shape)', () => { it('composite unique with an FK column → tenant-aware composite (the BR-009 double-notification net)', () => { const cfg = configOf(fixture({ fields: [field('Kind'), field('ThresholdDays', { type: 'int' })], relations: [rel({ type: 'many-to-one', targetEntity: 'DrivingLicence' })], indexes: [{ fields: ['DrivingLicenceId', 'Kind', 'ThresholdDays'], unique: true }], })) expect(cfg).toContain('builder.HasIndex(e => new { e.TenantId, e.DrivingLicenceId, e.Kind, e.ThresholdDays }).IsUnique();') }) it('composite unique on an optional-tenant entity → the two filtered indexes with a joined global name', () => { const cfg = configOf(fixture({ tenantMode: 'optional', fields: [field('Kind')], relations: [rel({ type: 'many-to-one', targetEntity: 'DueDate' })], indexes: [{ fields: ['DueDateId', 'Kind'], unique: true }], })) expect(cfg).toContain('builder.HasIndex(e => new { e.TenantId, e.DueDateId, e.Kind }).IsUnique().HasFilter("[TenantId] IS NOT NULL");') expect(cfg).toContain('.HasDatabaseName("IX_cond_Drivers_DueDateId_Kind_Global");') }) it('non-unique composite → plain multi-column HasIndex', () => { const cfg = configOf(fixture({ fields: [field('Status'), field('Kind')], indexes: [{ fields: ['Status', 'Kind'], unique: false }], })) expect(cfg).toContain('builder.HasIndex(e => new { e.Status, e.Kind });') }) it('a single-column indexes[] entry duplicating a field flag is NOT emitted twice', () => { const cfg = configOf(fixture({ fields: [field('Code', { unique: true }), field('MobilePhone', { required: false })], indexes: [{ fields: ['Code'], unique: true }], })) const occurrences = cfg.match(/e\.Code \}\)\.IsUnique\(\);/g) ?? [] expect(occurrences).toHaveLength(1) }) it('an index over an unknown column is a validation ERROR (would fail the .NET build far from here)', () => { const r = validate(fixture({ indexes: [{ fields: ['Kind'], unique: true }], // typo of Kind })) expect(r.valid).toBe(false) expect(r.errors.some(e => e.includes('Kind') && e.includes('**Index**'))).toBe(true) }) it('index columns may reference synthesized FK and scope columns (never an error)', () => { const r = validate(fixture({ relations: [rel({ type: 'many-to-one', targetEntity: 'Vehicle' })], dataScope: { mode: 'own', ownerProperty: 'OwnerUserId', assignedProperty: 'AssignedToUserId' }, indexes: [{ fields: ['VehicleId', 'OwnerUserId'], unique: false }], })) expect(r.errors.filter(e => e.includes('**Index**'))).toHaveLength(0) }) }) describe('scaffold-entity / indexes[] — audit regressions', () => { it('a single-column UNIQUE indexes[] entry is emitted even when the field is merely `indexed` (audit #2)', () => { const cfg = configOf(fixture({ fields: [field('Number', { indexed: true }), field('MobilePhone', { required: false })], indexes: [{ fields: ['Number'], unique: true }], })) // Was: only `HasIndex(e => e.Number);` — the declared uniqueness vanished // and DEV-API-031 erred on something no re-run could heal. expect(cfg).toContain('builder.HasIndex(e => new { e.TenantId, e.Number }).IsUnique();') }) it('a single-column NON-unique entry stays deduplicated against the field flag', () => { const cfg = configOf(fixture({ fields: [field('Number', { indexed: true })], indexes: [{ fields: ['Number'], unique: false }], })) expect(cfg.match(/HasIndex\(e => e\.Number\);/g) ?? []).toHaveLength(1) }) it('relation.unique matches a hand-declared FK whatever its CASE, without mutating the spec (audit #12)', () => { const spec = fixture({ fields: [field('vehicleId', { type: 'guid' }), field('MobilePhone', { required: false })], relations: [rel({ type: 'one-to-one', targetEntity: 'Vehicle', unique: true })], }) const cfg = configOf(spec) expect(cfg).toContain('builder.HasIndex(e => new { e.TenantId, e.vehicleId }).IsUnique();') // The caller's spec object is untouched — Phase 1 reuses it downstream. // (The fixture never sets `unique`, so still-undefined IS the proof: // the old in-place write would have turned it into `true`.) expect(spec.fields[0]!.unique).toBeUndefined() }) }) describe('scaffold-entity / declared index naming TenantId — CS0833 guard', () => { // The doctrine itself used to prescribe `(TenantId, Code) unique` (audit-data-model // DM-012, doc-templates, the ba-entities fixture). The unique branch prefixes the // discriminator, so that declaration emitted `new { e.TenantId, e.TenantId, e.Code }` // — "an anonymous type cannot have multiple properties with the same name". it('strict: (TenantId, Code) unique emits ONE TenantId in the anonymous type', () => { const cfg = configOf(fixture({ fields: [field('Code')], indexes: [{ fields: ['TenantId', 'Code'], unique: true }], })) expect(cfg).toContain('builder.HasIndex(e => new { e.TenantId, e.Code }).IsUnique();') expect(cfg).not.toContain('e.TenantId, e.TenantId') }) it('none: a declared TenantId names no column — dropped from the index, never emitted', () => { const cfg = configOf(fixture({ tenantMode: 'none', fields: [field('Code')], indexes: [{ fields: ['TenantId', 'Code'], unique: true }], })) expect(cfg).toContain('builder.HasIndex(e => e.Code).IsUnique();') expect(cfg).not.toContain('e.TenantId') }) it('strict: a NON-unique (TenantId, Status) is a legitimate composite and is kept as declared', () => { const cfg = configOf(fixture({ fields: [field('Status')], indexes: [{ fields: ['TenantId', 'Status'], unique: false }], })) expect(cfg).toContain('builder.HasIndex(e => new { e.TenantId, e.Status });') }) })