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: 'Opportunity', pluralName: 'Opportunities', module: 'pipeline', appCode: 'TestV2', applicationCode: 'crm', domainPrefix: 'pipeline', namespace: 'TestV2', tenantMode: 'strict', schemaTarget: 'extensions', fields: [field('Title')], relations: [], constraints: [], projectPath: '/tmp/project', ...overrides, } } const entityOf = (i: ScaffoldEntityInput) => generate(i).find(f => f.path.endsWith('Opportunity.cs'))!.content const configOf = (i: ScaffoldEntityInput) => generate(i).find(f => f.path.endsWith('OpportunityConfiguration.cs'))!.content describe('scaffold-entity / generate — no relations (regression guard)', () => { it('emits exactly the 3 files and NO relationship config when relations is empty and no tenant', () => { const noTenant = fixture({ tenantMode: 'none' }) const files = generate(noTenant) expect(files).toHaveLength(3) // Files are classified into // folders (namespaces stay flat). expect(files.map(f => f.path)).toEqual(expect.arrayContaining([ expect.stringContaining('Domain/Entities/Crm/Pipeline/Opportunity.cs'), expect.stringContaining('Domain/Entities/Crm/Pipeline/OpportunityEvents.cs'), expect.stringContaining('Infrastructure/Persistence/Configurations/Crm/Pipeline/OpportunityConfiguration.cs'), ])) // Domain namespace stays FLAT despite the folder move (cross-module FK rule). expect(entityOf(noTenant)).toContain('namespace TestV2.Domain.Entities;') expect(configOf(noTenant)).toContain('namespace TestV2.Infrastructure.Persistence.Configurations;') const cfg = configOf(noTenant) expect(cfg).not.toContain('HasOne') expect(cfg).not.toContain('HasForeignKey') expect(entityOf(noTenant)).not.toContain('ICollection<') }) }) describe('scaffold-entity / generate — owning relation (many-to-one)', () => { it('emits the FK column, the reference navigation, and a real FK constraint (default Restrict)', () => { const i = fixture({ relations: [rel({ type: 'many-to-one', targetEntity: 'Contact' })] }) const entity = entityOf(i) const config = configOf(i) expect(entity).toContain('public Guid ContactId { get; private set; }') expect(entity).toContain('public Contact Contact { get; private set; } = null!;') expect(config).toContain('builder.HasOne(e => e.Contact).WithMany().HasForeignKey(e => e.ContactId).OnDelete(DeleteBehavior.Restrict);') // required FK becomes a Create() parameter expect(entity).toMatch(/public static Opportunity Create\([^)]*Guid contactId[^)]*\)/) }) it('maps cascade vocabulary to DeleteBehavior (cascade → Cascade)', () => { const i = fixture({ relations: [rel({ type: 'many-to-one', targetEntity: 'Account', onDelete: 'cascade' })] }) expect(configOf(i)).toContain('.OnDelete(DeleteBehavior.Cascade);') }) it('honors the legacy cascadeDelete boolean when onDelete is absent', () => { const i = fixture({ relations: [rel({ type: 'many-to-one', targetEntity: 'Account', cascadeDelete: true })] }) expect(configOf(i)).toContain('.OnDelete(DeleteBehavior.Cascade);') }) it('a nullable set-null relation yields a nullable FK + nullable nav + SetNull', () => { const i = fixture({ relations: [rel({ type: 'many-to-one', targetEntity: 'Owner', foreignKey: 'OwnerUserId', nullable: true, onDelete: 'set-null' })] }) const entity = entityOf(i) expect(entity).toContain('public Guid? OwnerUserId { get; private set; }') expect(entity).toContain('public Owner? Owner { get; private set; }') expect(configOf(i)).toContain('builder.HasOne(e => e.Owner).WithMany().HasForeignKey(e => e.OwnerUserId).OnDelete(DeleteBehavior.SetNull);') // Create honnête: the optional FK is a DEFAULTED factory parameter (settable // at creation, positional call sites untouched) and flows into the initializer. expect(entity).toContain('Guid? ownerUserId = null') expect(entity).toContain('OwnerUserId = ownerUserId,') }) it('reuses an existing hand-declared Guid field instead of emitting a duplicate', () => { const i = fixture({ fields: [field('Title'), field('ContactId', { type: 'guid', required: false })], relations: [rel({ type: 'many-to-one', targetEntity: 'Contact', foreignKey: 'ContactId', nullable: true })], }) const entity = entityOf(i) expect((entity.match(/public Guid\? ContactId \{ get; private set; \}/g) ?? []).length).toBe(1) expect(configOf(i)).toContain('.HasForeignKey(e => e.ContactId)') }) }) describe('scaffold-entity / generate — inverse relation (one-to-many)', () => { it('emits a collection navigation + HasMany config, and NO FK column on this side', () => { const i = fixture({ relations: [rel({ type: 'one-to-many', targetEntity: 'OrderLine', onDelete: 'cascade' })] }) const entity = entityOf(i) expect(entity).toContain('public ICollection OrderLines { get; private set; } = new List();') expect(configOf(i)).toContain('builder.HasMany(e => e.OrderLines).WithOne().OnDelete(DeleteBehavior.Cascade);') expect(entity).not.toContain('OrderLineId') }) }) describe('scaffold-entity / validate — relation guards', () => { it('rejects set-null on a non-nullable relation', () => { const res = validate(fixture({ relations: [rel({ type: 'many-to-one', targetEntity: 'Owner', onDelete: 'set-null' })] })) expect(res.valid).toBe(false) expect(res.errors.join(' ')).toContain('set-null') }) it('rejects two relations resolving to the same FK column', () => { const res = validate(fixture({ relations: [ rel({ type: 'many-to-one', targetEntity: 'Contact', foreignKey: 'PartyId' }), rel({ type: 'many-to-one', targetEntity: 'TenantOrganisation', foreignKey: 'PartyId', targetScope: 'core' }), ], })) expect(res.valid).toBe(false) expect(res.errors.join(' ')).toContain('duplicate foreign key') }) it('accepts a well-formed owning relation', () => { const res = validate(fixture({ relations: [rel({ type: 'many-to-one', targetEntity: 'Contact' })] })) expect(res.valid).toBe(true) expect(res.errors).toHaveLength(0) }) }) describe('scaffold-entity / generate — DB naming convention (SmartStack.app aligned)', () => { it('table = {domainPrefix}_{PascalPlural} in the target schema', () => { expect(configOf(fixture())).toContain('builder.ToTable("pipeline_Opportunities", SchemaConstants.Extensions);') }) it('pluralizes the entity name when pluralName is omitted', () => { const i = fixture({ name: 'Company', pluralName: undefined }) const cfg = generate(i).find(f => f.path.endsWith('CompanyConfiguration.cs'))!.content expect(cfg).toContain('builder.ToTable("pipeline_Companies", SchemaConstants.Extensions);') }) it('never remaps columns — PascalCase default mapping, no HasColumnName', () => { const cfg = configOf(fixture({ fields: [field('FirstName', { maxLength: 100 })] })) expect(cfg).not.toContain('HasColumnName') expect(cfg).toContain('builder.Property(e => e.FirstName).IsRequired().HasMaxLength(100);') }) it('emits no HasDatabaseName index overrides (EF default index names)', () => { expect(configOf(fixture({ fields: [field('Code', { indexed: true })] }))).not.toContain('HasDatabaseName') }) it('a plain Guid FK gets no Property line and no column remap', () => { const cfg = configOf(fixture({ relations: [rel({ type: 'many-to-one', targetEntity: 'Contact' })] })) expect(cfg).not.toContain('HasColumnName') expect(cfg).not.toContain('builder.Property(e => e.ContactId)') expect(cfg).toContain('.HasForeignKey(e => e.ContactId)') }) }) describe('scaffold-entity / generate — Tenant FK (SmartStackExtensionDbContext-aligned, v3.55+)', () => { it('strict tenant → real FK to core.tenant_Tenants via the base-class Tenant DbSet (no local stub)', () => { const i = fixture({ tenantMode: 'strict' }) const cfg = configOf(i) // Base-class Tenant — no TenantReference stub, no local External/ file. expect(cfg).toContain('builder.HasOne().WithMany().HasForeignKey(e => e.TenantId).OnDelete(DeleteBehavior.Restrict);') expect(cfg).not.toContain('TenantReference') expect(generate(i).some(f => f.path.includes('Configurations/External/'))).toBe(false) // The Tenant namespace is imported (both entity and config). expect(cfg).toContain('using SmartStack.Domain.Platform.Administration.Tenants;') expect(entityOf(i)).toContain('using SmartStack.Domain.Platform.Administration.Tenants;') expect(entityOf(i)).toContain('public Guid TenantId { get; private set; }') }) it('optional tenant → still a real FK, nullable column, base-class Tenant', () => { const i = fixture({ tenantMode: 'optional' }) expect(configOf(i)).toContain('builder.HasOne().WithMany().HasForeignKey(e => e.TenantId)') expect(entityOf(i)).toContain('public Guid? TenantId { get; private set; }') expect(generate(i).some(f => f.path.includes('Configurations/External/'))).toBe(false) }) it('tenantMode none → no Tenant FK and no External stub file', () => { const i = fixture({ tenantMode: 'none' }) expect(configOf(i)).not.toContain('Tenant>') expect(configOf(i)).not.toContain('TenantId') expect(generate(i).some(f => f.path.includes('Configurations/External/'))).toBe(false) }) }) describe('scaffold-entity / generate — core whitelist + cross-module references (non-negotiable FK)', () => { it('core whitelist ref (Department) → real cross-schema FK + nav property, NO stub file', () => { const i = fixture({ tenantMode: 'none', relations: [rel({ type: 'many-to-one', targetEntity: 'Department', foreignKey: 'DepartmentId', nullable: true, targetScope: 'core' })], }) const cfg = configOf(i) const entity = entityOf(i) expect(entity).toContain('public Guid? DepartmentId { get; private set; }') // Real nav property to the Core principal (resolved against the base class). expect(entity).toContain('public Department? Department { get; private set; }') expect(cfg).toContain('builder.HasOne(e => e.Department).WithMany().HasForeignKey(e => e.DepartmentId).OnDelete(DeleteBehavior.Restrict);') // No local stub — base class owns the ExcludeFromMigrations mapping. expect(generate(i).some(f => f.path.includes('Configurations/External/'))).toBe(false) // Department namespace imported in both files. expect(entity).toContain('using SmartStack.Domain.Platform.Administration.References;') expect(cfg).toContain('using SmartStack.Domain.Platform.Administration.References;') }) it('core whitelist ref (User) → required FK + non-null nav + factory parameter', () => { const i = fixture({ tenantMode: 'none', relations: [rel({ type: 'many-to-one', targetEntity: 'User', foreignKey: 'CustomerUserId', targetScope: 'core' })], }) const entity = entityOf(i) expect(entity).toContain('public Guid CustomerUserId { get; private set; }') expect(entity).toContain('public User User { get; private set; } = null!;') expect(entity).toMatch(/public static Opportunity Create\([^)]*Guid customerUserId[^)]*\)/) expect(entity).toContain('using SmartStack.Domain.Platform.Administration.Users;') }) it('cross-module ref → FK to the REAL type, NO navigation, NO stub', () => { const i = fixture({ tenantMode: 'none', relations: [rel({ type: 'many-to-one', targetEntity: 'Contact', foreignKey: 'ContactId', targetScope: 'cross-module' })], }) const cfg = configOf(i) const entity = entityOf(i) expect(entity).toContain('public Guid ContactId { get; private set; }') expect(entity).not.toContain('public Contact Contact') expect(cfg).toContain('builder.HasOne().WithMany().HasForeignKey(e => e.ContactId).OnDelete(DeleteBehavior.Restrict);') expect(generate(i).some(f => f.path.includes('Configurations/External/'))).toBe(false) }) it('same-module ref is unchanged — FK + navigation property (regression)', () => { const i = fixture({ tenantMode: 'none', relations: [rel({ type: 'many-to-one', targetEntity: 'Stage', foreignKey: 'StageId' })] }) expect(entityOf(i)).toContain('public Stage Stage { get; private set; } = null!;') expect(configOf(i)).toContain('builder.HasOne(e => e.Stage).WithMany().HasForeignKey(e => e.StageId)') }) it('an allowlisted audit column (CreatedByUserId) stays a plain Guid — no FK, no relation', () => { const i = fixture({ tenantMode: 'none', fields: [field('Title'), field('CreatedByUserId', { type: 'guid', required: false })] }) const cfg = configOf(i) expect(entityOf(i)).toContain('public Guid? CreatedByUserId { get; private set; }') expect(cfg).not.toContain('CreatedByUserId') }) }) describe('scaffold-entity / validate — scope guards (V1 whitelist)', () => { it('rejects a core relation to a non-whitelist entity (UserSession) with whitelist guidance', () => { const res = validate(fixture({ relations: [rel({ type: 'many-to-one', targetEntity: 'UserSession', targetScope: 'core' })] })) expect(res.valid).toBe(false) expect(res.errors.join(' ')).toContain('whitelist') expect(res.errors.join(' ')).toContain('UserSession') expect(res.errors.join(' ')).toContain('ICoreDataService') }) it('rejects a non-same-module scope on a collection relation', () => { const res = validate(fixture({ relations: [rel({ type: 'one-to-many', targetEntity: 'Invoice', targetScope: 'cross-module' })] })) expect(res.valid).toBe(false) expect(res.errors.join(' ')).toContain('only valid on a many-to-one') }) it('accepts a well-formed core relation to a whitelist entity (Department)', () => { const res = validate(fixture({ relations: [rel({ type: 'many-to-one', targetEntity: 'Department', nullable: true, targetScope: 'core' })] })) expect(res.valid).toBe(true) expect(res.errors).toHaveLength(0) }) it('accepts every V1 whitelist entity (User, Role, Tenant, TenantOrganisation, Department, JobTitle, Office, Language, Group)', () => { const whitelist = ['User', 'Role', 'Tenant', 'TenantOrganisation', 'Department', 'JobTitle', 'Office', 'Language', 'Group'] for (const target of whitelist) { // Tenant's default FK column (TenantId) is reserved for the auto-emitted // tenant FK (from tenantMode), so a manual Tenant relation must use a // distinct FK name — Tenant is still whitelisted and must be accepted. const foreignKey = target === 'Tenant' ? 'OwnerTenantId' : `${target}Id` const res = validate(fixture({ relations: [rel({ type: 'many-to-one', targetEntity: target, foreignKey, nullable: true, targetScope: 'core' })], })) expect(res.valid, `${target} should be accepted`).toBe(true) } }) }) describe('scaffold-entity / generate — dataScope (own/assigned row-level columns)', () => { it('own: synthesizes a required indexed OwnerUserId, marks IOwnedEntity, sets at Create, never updates', () => { const i = fixture({ dataScope: { mode: 'own', ownerProperty: 'OwnerUserId', assignedProperty: 'AssignedToUserId' } }) const entity = entityOf(i) const config = configOf(i) expect(entity).toContain('public Guid OwnerUserId { get; private set; }') expect(entity).toContain(', IOwnedEntity') expect(entity).toContain('using SmartStack.Domain.Common;') expect(config).toContain('builder.HasIndex(e => e.OwnerUserId);') // Owner is a Create() parameter… expect(entity).toMatch(/public static Opportunity Create\([^)]*Guid ownerUserId[^)]*\)/) // …but NEVER an Update() parameter (ownership is immutable). expect(entity).not.toMatch(/public void Update\([^)]*ownerUserId[^)]*\)/) }) it('assigned: synthesizes a nullable indexed AssignedToUserId, marks IAssignedEntity, updatable (reassignment)', () => { const i = fixture({ dataScope: { mode: 'assigned', ownerProperty: 'OwnerUserId', assignedProperty: 'AssignedToUserId' } }) const entity = entityOf(i) expect(entity).toContain('public Guid? AssignedToUserId { get; private set; }') expect(entity).toContain(', IAssignedEntity') expect(entity).not.toContain('IOwnedEntity') expect(configOf(i)).toContain('builder.HasIndex(e => e.AssignedToUserId);') expect(entity).toMatch(/public void Update\([^)]*Guid\? assignedToUserId[^)]*\)/) }) it('own-assigned: both columns + both marker interfaces', () => { const i = fixture({ dataScope: { mode: 'own-assigned', ownerProperty: 'OwnerUserId', assignedProperty: 'AssignedToUserId' } }) const entity = entityOf(i) expect(entity).toContain('public Guid OwnerUserId { get; private set; }') expect(entity).toContain('public Guid? AssignedToUserId { get; private set; }') expect(entity).toContain(', IOwnedEntity, IAssignedEntity') }) it('custom owner column satisfies IOwnedEntity via an explicit interface member', () => { const i = fixture({ dataScope: { mode: 'own', ownerProperty: 'CreatedByUserId', assignedProperty: 'AssignedToUserId' } }) const entity = entityOf(i) expect(entity).toContain('public Guid CreatedByUserId { get; private set; }') expect(entity).toContain('Guid IOwnedEntity.OwnerUserId => CreatedByUserId;') }) it('reuses a hand-declared Guid owner field instead of duplicating it', () => { const i = fixture({ fields: [field('Title'), field('OwnerUserId', { type: 'guid' })], dataScope: { mode: 'own', ownerProperty: 'OwnerUserId', assignedProperty: 'AssignedToUserId' }, }) const entity = entityOf(i) expect(entity.match(/public Guid OwnerUserId/g)).toHaveLength(1) }) it('no dataScope → no marker interfaces; SmartStack.Domain.Common only carried by the tenant interface', () => { const entity = entityOf(fixture()) expect(entity).not.toContain('IOwnedEntity') expect(entity).not.toContain('IAssignedEntity') expect(entity).toContain('using SmartStack.Domain.Common;') // ITenantEntity (tenantMode strict) const noTenant = entityOf(fixture({ tenantMode: 'none' })) expect(noTenant).not.toContain('using SmartStack.Domain.Common;') }) }) describe('scaffold-entity / generate — verified package contract (compile-truth regression guard)', () => { it('never emits the phantom SmartStack.Core.Domain namespace', () => { for (const file of generate(fixture({ dataScope: { mode: 'own', ownerProperty: 'OwnerUserId', assignedProperty: 'AssignedToUserId' } }))) { expect(file.content, file.path).not.toContain('SmartStack.Core.Domain') } }) it('entity inherits the project-local ExtensionBaseEntity shim and imports {ns}.Domain.Common', () => { const entity = entityOf(fixture()) expect(entity).toContain('using TestV2.Domain.Common;') expect(entity).toContain('public class Opportunity : ExtensionBaseEntity, ITenantEntity') expect(entity).not.toMatch(/:\s*BaseEntity\b/) }) it('events implement IDomainEvent (SmartStack.Domain.Support.Events) with the required OccurredAt', () => { const events = generate(fixture()).find(f => f.path.endsWith('OpportunityEvents.cs'))!.content expect(events).toContain('using SmartStack.Domain.Support.Events;') expect(events).toContain('public record OpportunityCreatedEvent(Guid OpportunityId, DateTime OccurredAt) : IDomainEvent;') const entity = entityOf(fixture()) expect(entity).toContain('entity.AddDomainEvent(new OpportunityCreatedEvent(entity.Id, DateTime.UtcNow));') expect(entity).toContain('AddDomainEvent(new OpportunityUpdatedEvent(Id, DateTime.UtcNow));') }) it('configuration relies on the local SchemaConstants via the enclosing namespace (no SmartStack.Infrastructure using)', () => { const config = configOf(fixture()) expect(config).toContain('SchemaConstants.Extensions') expect(config).not.toContain('using SmartStack.Infrastructure.Persistence;') }) }) describe('scaffold-entity / validate — dataScope', () => { it('accepts a valid own scope and reminds to run scaffold-data-scope', () => { const r = validate({ ...fixture(), dataScope: { mode: 'own' } }) expect(r.valid).toBe(true) expect(r.warnings.some(w => w.includes('scaffold-data-scope'))).toBe(true) }) it('rejects an ownership column colliding with a non-Guid field', () => { const r = validate({ ...fixture({ fields: [field('Title'), field('OwnerUserId', { type: 'string' })] }), dataScope: { mode: 'own' }, }) expect(r.valid).toBe(false) expect(r.errors.some(e => e.includes('non-Guid'))).toBe(true) }) it('rejects a reserved ownership column (TenantId) and own-assigned with a single column', () => { const reserved = validate({ ...fixture(), dataScope: { mode: 'own', ownerProperty: 'TenantId' } }) expect(reserved.valid).toBe(false) const same = validate({ ...fixture(), dataScope: { mode: 'own-assigned', ownerProperty: 'OwnerUserId', assignedProperty: 'OwnerUserId' } }) expect(same.valid).toBe(false) }) }) describe('scaffold-entity / generate — codedEntity (system-allocated business code)', () => { const coded = { codeKey: 'crm.opportunity', maxLength: 64, unique: true } it('emits the Code column, ICodedEntity explicit members, config constraint + unique index', () => { const i = fixture({ codedEntity: coded }) const entity = entityOf(i) const config = configOf(i) expect(entity).toContain('using SmartStack.Domain.CodeGeneration;') expect(entity).toContain(', ICodedEntity') expect(entity).toContain('public string Code { get; private set; } = string.Empty;') expect(entity).toContain('string ICodedEntity.CodeKey => "crm.opportunity";') expect(entity).toContain('bool ICodedEntity.HasCode => !string.IsNullOrWhiteSpace(Code);') expect(entity).toContain('void ICodedEntity.ApplyCode(string code) => Code = code;') expect(config).toContain('builder.Property(e => e.Code).IsRequired().HasMaxLength(64);') // strict tenant → composite unique (TenantId, Code); the bare non-unique index is gone expect(config).toContain('builder.HasIndex(e => new { e.TenantId, e.Code }).IsUnique();') expect(config).not.toContain('builder.HasIndex(e => e.Code);') }) it('tenantMode optional → the socle pattern: two filtered unique indexes (tenant / global)', () => { const config = configOf(fixture({ tenantMode: 'optional', codedEntity: coded })) expect(config).toContain('builder.HasIndex(e => new { e.TenantId, e.Code }).IsUnique().HasFilter("[TenantId] IS NOT NULL");') expect(config).toContain('builder.HasIndex(e => e.Code).IsUnique().HasFilter("[TenantId] IS NULL").HasDatabaseName("IX_pipeline_Opportunities_Code_Global");') }) it('tenantMode none → simple unique index on Code', () => { const config = configOf(fixture({ tenantMode: 'none', codedEntity: coded })) expect(config).toContain('builder.HasIndex(e => e.Code).IsUnique();') expect(config).not.toContain('e.TenantId, e.Code') }) it('unique: false → legacy non-unique index + validate warns about the opt-out', () => { const optOut = { ...coded, unique: false } const config = configOf(fixture({ codedEntity: optOut })) expect(config).toContain('builder.HasIndex(e => e.Code);') expect(config).not.toContain('IsUnique') const r = validate({ ...fixture(), codedEntity: { codeKey: 'crm.opportunity', unique: false } }) expect(r.valid).toBe(true) expect(r.warnings.some(w => w.includes('unique: false') && w.includes('opts out'))).toBe(true) }) it('unique defaults to true through validation (Zod default)', () => { const r = validate({ ...fixture(), codedEntity: { codeKey: 'crm.opportunity' } }) expect(r.valid).toBe(true) expect(r.warnings.some(w => w.includes('opts out'))).toBe(false) }) it('the Code is engine-assigned: never a Create() nor Update() parameter', () => { const entity = entityOf(fixture({ codedEntity: coded })) expect(entity).not.toMatch(/Create\([^)]*\bcode\b[^)]*\)/i) expect(entity).not.toMatch(/public void Update\([^)]*\bcode\b[^)]*\)/i) }) it('validate rejects a hand-declared Code field alongside codedEntity and reminds to run the descriptor half', () => { const clash = validate({ ...fixture({ fields: [field('Title'), field('Code')] }), codedEntity: { codeKey: 'crm.opportunity' } }) expect(clash.valid).toBe(false) const ok = validate({ ...fixture(), codedEntity: { codeKey: 'crm.opportunity' } }) expect(ok.valid).toBe(true) expect(ok.warnings.some(w => w.includes('scaffold-coded-entity'))).toBe(true) }) it('no codedEntity → no Code artifacts (regression guard)', () => { const entity = entityOf(fixture()) expect(entity).not.toContain('ICodedEntity') expect(configOf(fixture())).not.toContain('e.Code') }) it('a derived-token format populates GetCodeInputs with the referenced scalar fields (canonical casing)', () => { // The historical always-empty dictionary made {ABBR:ClientName:3}-{SEQ:4} // fail at allocation time — the "the socle can't do it" trap. const entity = entityOf(fixture({ fields: [field('Title'), field('ClientName')], codedEntity: { ...coded, format: 'OPP-{ABBR:clientname:3}-{SEQ:4}' }, })) expect(entity).toContain('["ClientName"] = ClientName,') expect(entity).not.toContain('GetCodeInputs() => new Dictionary();') }) it('a format without derived tokens keeps the empty dictionary', () => { const entity = entityOf(fixture({ codedEntity: { ...coded, format: 'OPP-{YY}-{SEQ:4}' } })) expect(entity).toContain('GetCodeInputs() => new Dictionary();') }) it('validate errs when a derived token references a missing or computed field, warns when no format is passed', () => { const missing = validate({ ...fixture(), codedEntity: { codeKey: 'crm.opportunity', format: 'OPP-{SLUG:ClientName}-{SEQ:4}' }, }) expect(missing.valid).toBe(false) expect(missing.errors.some(e => e.includes('{…:ClientName}') && e.includes('no scalar field'))).toBe(true) const computed = validate({ ...fixture({ fields: [field('Title'), field('Total', { formula: 'A * B' })] }), codedEntity: { codeKey: 'crm.opportunity', format: 'OPP-{SLUG:Total}-{SEQ:4}' }, }) expect(computed.valid).toBe(false) expect(computed.errors.some(e => e.includes('COMPUTED'))).toBe(true) const noFormat = validate({ ...fixture(), codedEntity: { codeKey: 'crm.opportunity' } }) expect(noFormat.valid).toBe(true) expect(noFormat.warnings.some(w => w.includes('GetCodeInputs() is emitted EMPTY'))).toBe(true) }) }) describe('scaffold-entity / generate — versioned (rowversion optimistic concurrency, offline-write 409)', () => { it('emits IVersionedEntity + the EF/DB-managed RowVersion property + .IsRowVersion() config', () => { const i = fixture({ versioned: true }) const entity = entityOf(i) const config = configOf(i) expect(entity).toContain('public class Opportunity : ExtensionBaseEntity, ITenantEntity, IVersionedEntity') expect(entity).toContain('public byte[] RowVersion { get; private set; } = Array.Empty();') // IVersionedEntity lives in SmartStack.Domain.Common (verified socle contract). expect(entity).toContain('using SmartStack.Domain.Common;') expect(config).toContain('builder.Property(e => e.RowVersion).IsRowVersion();') }) it('versioned with tenantMode none still imports SmartStack.Domain.Common (IVersionedEntity lives there)', () => { const entity = entityOf(fixture({ tenantMode: 'none', versioned: true })) expect(entity).toContain('using SmartStack.Domain.Common;') expect(entity).toContain('public class Opportunity : ExtensionBaseEntity, IVersionedEntity') }) it('RowVersion is EF-managed: never a Create() nor Update() parameter, no generic Property/index line', () => { const i = fixture({ versioned: true }) const entity = entityOf(i) expect(entity).not.toMatch(/Create\([^)]*[Rr]owVersion[^)]*\)/) expect(entity).not.toMatch(/public void Update\([^)]*[Rr]owVersion[^)]*\)/) const config = configOf(i) expect(config).not.toContain('builder.HasIndex(e => e.RowVersion)') expect(config).not.toContain('builder.Property(e => e.RowVersion).IsRequired()') }) it('composes with the other seams — versioned interface appended last (socle order)', () => { const i = fixture({ dataScope: { mode: 'own', ownerProperty: 'OwnerUserId', assignedProperty: 'AssignedToUserId' }, codedEntity: { codeKey: 'crm.opportunity', maxLength: 64, unique: true }, versioned: true, }) expect(entityOf(i)).toContain('public class Opportunity : ExtensionBaseEntity, ITenantEntity, IOwnedEntity, ICodedEntity, IVersionedEntity') }) it('non-versioned spec leaves ZERO RowVersion/IVersionedEntity trace in any file (regression guard)', () => { for (const file of generate(fixture())) { expect(file.content, file.path).not.toContain('RowVersion') expect(file.content, file.path).not.toContain('IVersionedEntity') expect(file.content, file.path).not.toContain('IsRowVersion') } }) }) describe('scaffold-entity / validate — versioned', () => { it('rejects a hand-declared RowVersion field alongside versioned (the seam owns the column)', () => { const clash = validate({ ...fixture({ fields: [field('Title'), field('RowVersion', { type: 'string' })] }), versioned: true }) expect(clash.valid).toBe(false) expect(clash.errors.join(' ')).toContain('RowVersion') }) it('signals the governed migration + the scaffold-business mirror (warning, never auto-run)', () => { const ok = validate({ ...fixture(), versioned: true }) expect(ok.valid).toBe(true) expect(ok.warnings.some(w => w.includes('/efcore') && w.includes('NEVER auto-run'))).toBe(true) expect(ok.warnings.some(w => w.includes('scaffold-business'))).toBe(true) }) it('no versioned → no rowversion warning (regression)', () => { const r = validate(fixture()) expect(r.warnings.some(w => w.includes('rowversion'))).toBe(false) }) }) describe('scaffold-entity / generate — optional fields are nullable, STRINGS INCLUDED', () => { // Historical csType() excluded strings from the `?` suffix: a PRD attribute // declared nullable came out `public string X { get; private set; }` — the // NRT annotation lied, EF inferred NOT NULL, the migration wrote // `nullable: false`, and every Create() (which sets required fields only) // failed at SaveChanges with "Required properties missing". Runtime-fatal on // every entity carrying an optional string. it('an optional string property is emitted as `string?` with no initializer', () => { const src = entityOf(fixture({ fields: [field('Title'), field('Notes', { required: false })] })) expect(src).toContain('public string? Notes { get; private set; }') expect(src).not.toContain('public string Notes') }) it('a required string keeps `string` + the null-forgiving initializer', () => { const src = entityOf(fixture({ fields: [field('Title')] })) expect(src).toContain('public string Title { get; private set; } = null!;') }) it('the EF configuration does not mark the optional string IsRequired (column stays nullable)', () => { const cfg = configOf(fixture({ fields: [field('Title'), field('Notes', { required: false, maxLength: 500 })] })) expect(cfg).toContain('builder.Property(e => e.Title).IsRequired()') expect(cfg).toContain('builder.Property(e => e.Notes).HasMaxLength(500);') expect(cfg).not.toMatch(/Notes\)\.IsRequired/) }) it('other optional scalars keep their nullable mapping (no regression)', () => { const src = entityOf(fixture({ fields: [field('Title'), field('DueAt', { type: 'date', required: false }), field('Amount', { type: 'decimal', required: false })] })) expect(src).toContain('public DateOnly? DueAt { get; private set; }') expect(src).toContain('public decimal? Amount { get; private set; }') }) }) describe('scaffold-entity / generate — declared unique index + decimal precision', () => { // BA `Index: (Code) unique` used to be unreachable outside the codedEntity // seam (12 referential Code columns shipped with a plain index — no SQL net // under BR "code unique", concurrent inserts could duplicate), and // `decimal(5,3)` fell through to the SQL Server default decimal(18,2). it('unique on a strict-tenant entity → composite (TenantId, X) unique index', () => { const cfg = configOf(fixture({ fields: [field('Title'), field('Code', { unique: true, maxLength: 20 })] })) expect(cfg).toContain('builder.HasIndex(e => new { e.TenantId, e.Code }).IsUnique();') }) it('unique on an optional-tenant entity → the two filtered indexes (socle pattern)', () => { const cfg = configOf(fixture({ tenantMode: 'optional', fields: [field('Title'), field('Code', { unique: true })] })) expect(cfg).toContain('.IsUnique().HasFilter("[TenantId] IS NOT NULL");') expect(cfg).toMatch(/HasIndex\(e => e\.Code\)\.IsUnique\(\)\.HasFilter\("\[TenantId\] IS NULL"\)/) }) it('unique on a tenant-less entity → plain unique index', () => { const cfg = configOf(fixture({ tenantMode: 'none', fields: [field('Title'), field('Code', { unique: true })] })) expect(cfg).toContain('builder.HasIndex(e => e.Code).IsUnique();') }) it('indexed + unique on the same field emits ONLY the unique index', () => { const cfg = configOf(fixture({ fields: [field('Title'), field('Code', { unique: true, indexed: true })] })) expect(cfg).not.toMatch(/HasIndex\(e => e\.Code\);/) }) it('precision/scale → .HasPrecision(p, s)', () => { const cfg = configOf(fixture({ fields: [field('Title'), field('Rate', { type: 'decimal', precision: 5, scale: 3 })] })) expect(cfg).toContain('builder.Property(e => e.Rate).HasPrecision(5, 3);') }) })