/** * scaffold-entity — CLI entry contract. `index.ts` used to hand the RAW spec to * generate(): with `tenantMode`/`schemaTarget` omitted (the documented * defaults) the entity lost ITenantEntity, the named tenant filter was never * mounted (the DEV-API-032 cross-tenant class), `required` defaulted to * nothing and a spec without `relations` crashed. This runs the real entry * point against a temp project with a defaults-only spec. * * QC 2026-09-24 (lot 13): the lot-1 "parity" test compared generate(parse(x)) * to generate(parse(parse(x))) — schema output on both sides, so a schema * stripping a key the renderer reads could never be detected. The parity * block now holds two REAL properties (lib/__tests__/helpers/spec-parity): * no-strip against the RAW example, defaults-only against the schema's * DECLARED defaults — each proven to fire. */ import { describe, it, expect, beforeAll, afterAll } from 'vitest' import { mkdtempSync, readFileSync, rmSync } from 'node:fs' import { tmpdir } from 'node:os' import { dirname, join, resolve } from 'node:path' import { fileURLToPath } from 'node:url' import { z } from 'zod' import { runCli } from '../../../../../../lib/__tests__/helpers/run-cli.js' import { specParity } from '../../../../../../lib/__tests__/helpers/spec-parity.js' import { validate } from '../validate.js' import { generate } from '../generate.js' import { EntityFieldSchema, ScaffoldEntityInputSchema } from '../types.js' const INDEX = resolve(dirname(fileURLToPath(import.meta.url)), '../index.ts') /** Defaults-only spec: no tenantMode, no schemaTarget, no relations, no `required`. */ const minimal = (projectPath: string) => ({ name: 'Employee', module: 'hrm', appCode: 'MyApp', applicationCode: 'hr', domainPrefix: 'hrm', fields: [{ name: 'FirstName', type: 'string', maxLength: 100 }], projectPath, }) let tmp: string beforeAll(() => { tmp = mkdtempSync(join(tmpdir(), 'ss-entity-entry-')) }) afterAll(() => { rmSync(tmp, { recursive: true, force: true }) }) describe('scaffold-entity — the CLI entry runs the PARSED spec', () => { it('writes a tenant-scoped entity from a defaults-only spec (the raw spec crashed on `relations.filter`)', () => { const r = runCli(INDEX, ['--spec', JSON.stringify(minimal(tmp))]) expect(r.envelope, r.stderr).not.toBeNull() expect(r.envelope.success, JSON.stringify(r.envelope.errors)).toBe(true) const created: string[] = r.envelope.filesCreated const entityFile = created.find(f => f.replace(/\\/g, '/').endsWith('/Employee.cs')) const configFile = created.find(f => f.endsWith('Configuration.cs')) expect(entityFile, created.join('\n')).toBeDefined() expect(configFile, created.join('\n')).toBeDefined() // tenantMode default 'strict' → ITenantEntity on the class expect(readFileSync(entityFile!, 'utf-8')).toContain(', ITenantEntity') // fields[].required default true → IsRequired() on the string column expect(readFileSync(configFile!, 'utf-8')).toContain('.IsRequired()') // No ExtensionsDbContext in the temp project: the STRICT filter is named in the warning expect(r.envelope.warnings.join('\n')).toContain('ApplyNamedStrictTenantFilter') }) it('validate() returns the parsed spec with the documented defaults applied', () => { const v = validate(minimal('/path')) expect(v.valid).toBe(true) expect(v.data?.tenantMode).toBe('strict') expect(v.data?.schemaTarget).toBe('extensions') expect(v.data?.relations).toEqual([]) expect(v.data?.fields[0]?.required).toBe(true) }) it('validate() carries no data when the spec is refused', () => { const v = validate({ ...minimal('/path'), domainPrefix: 'ext' }) expect(v.valid).toBe(false) expect(v.data).toBeUndefined() }) }) describe('scaffold-entity — parity RAW ↔ PARSED (real properties, not schema-vs-schema)', () => { const RAW = minimal('/path') const parsed = () => validate(RAW).data! it('(a) no-strip: every key path of the defaults-only spec survives the parse with its value', () => { const report = specParity(RAW, parsed(), ScaffoldEntityInputSchema) expect(report.rawPaths).toContain('fields[0].maxLength') // the walk reaches the nested keys expect(report.stripped).toEqual([]) expect(report.changed).toEqual([]) }) it('(b) defaults-only: every key the parse adds is a default DECLARED at that path, with that value', () => { const report = specParity(RAW, parsed(), ScaffoldEntityInputSchema) expect(report.added.map(a => a.path)).toEqual(expect.arrayContaining(['tenantMode', 'schemaTarget', 'relations', 'fields[0].required'])) expect(report.undeclared).toEqual([]) }) it('secondary: generate() reads nothing a JSON round-trip of the parsed spec would lose', () => { const p = parsed() expect(generate(p)).toEqual(generate(JSON.parse(JSON.stringify(p)))) }) it('FIRES (a): a strip-mode copy of the schema without `fields[].maxLength` is reported at that path', () => { const stripping = z.object({ ...ScaffoldEntityInputSchema.shape, fields: z.array(EntityFieldSchema.omit({ maxLength: true })), }) const report = specParity(RAW, stripping.parse(RAW), stripping) expect(report.stripped).toEqual(['fields[0].maxLength']) expect(report.changed).toEqual([]) }) it('FIRES (b): a key injected after the parse, or a default applied with another value, is reported', () => { expect(specParity(RAW, { ...parsed(), tableName: 'ext_Employee' }, ScaffoldEntityInputSchema).undeclared).toEqual(['tableName']) expect(specParity(RAW, { ...parsed(), tenantMode: 'none' }, ScaffoldEntityInputSchema).undeclared).toEqual(['tenantMode']) }) })