/** * scaffold-business — the Program.cs RECURRING-JOBS patch through the REAL * index.ts: one `AddSmartStackRecurringJob<…>` line per scheduled use case, * merged ONCE into the `// <<< RECURRING-JOBS BEGIN/END >>>` marker block * (idempotent on re-run); without scheduled UCs the block is untouched; a * pre-marker Program.cs anchors on AddSmartStackHangfire; no anchor at all * or no Program.cs → a warning carrying the line (audit 2026-09-24, A-WARN-11). */ import { describe, it, expect, beforeEach, afterEach } from 'vitest' import { mkdtempSync, readFileSync, rmSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' import { runCli } from '../../../../../../lib/__tests__/helpers/run-cli.js' import { INDEX, entrySpec, norm, plant } from './fixture.js' const PROGRAM = 'src/MyApp.Api/Program.cs' const RJ_BEGIN = '// <<< RECURRING-JOBS BEGIN >>>' const RJ_END = '// <<< RECURRING-JOBS END >>>' const JOB = { ucCode: 'UC-HRM-007', jobId: 'hrm-employee-reminder', slug: 'reminder', methodName: 'RunReminderAsync', cron: '0 6 * * *' } const JOB_LINE = 'builder.Services.AddSmartStackRecurringJob' + '("hrm-employee-reminder", s => s.RunReminderAsync(default, CancellationToken.None), "0 6 * * *");' /** Top-level-statements Program.cs WITH the marker block (the `ss init` template). */ const WITH_MARKER = `var builder = WebApplication.CreateBuilder(args); builder.Services.AddSmartStackHangfire(builder.Configuration); ${RJ_BEGIN} ${RJ_END} var app = builder.Build(); app.Run(); ` /** Pre-marker template: only the Hangfire registration to anchor on. */ const PRE_MARKER = `var builder = WebApplication.CreateBuilder(args); builder.Services.AddSmartStackHangfire(builder.Configuration); var app = builder.Build(); app.Run(); ` /** Neither marker nor Hangfire — nothing to anchor on. */ const NO_ANCHOR = `var builder = WebApplication.CreateBuilder(args); var app = builder.Build(); app.Run(); ` const count = (source: string, needle: string): number => source.split(needle).length - 1 let root: string beforeEach(() => { root = mkdtempSync(join(tmpdir(), 'ss-business-recurring-')) }) afterEach(() => rmSync(root, { recursive: true, force: true })) function run(overrides: Record = {}) { const r = runCli(INDEX, ['--spec', JSON.stringify(entrySpec(root, overrides))]) expect(r.envelope, r.stderr).not.toBeNull() expect(r.envelope.success, JSON.stringify(r.envelope.errors)).toBe(true) return r } const program = (): string => readFileSync(join(root, PROGRAM), 'utf-8') const modifiedProgram = (r: { envelope: { filesModified: string[] } }): boolean => r.envelope.filesModified.map(norm).some(p => p.endsWith(`/${PROGRAM}`)) describe('scaffold-business / RECURRING-JOBS patch — with a scheduled use case', () => { it('lands the AddSmartStackRecurringJob line ONCE inside the marker block and reports Program.cs modified', () => { plant(root, PROGRAM, WITH_MARKER) const r = run({ scheduledJobs: [JOB] }) const src = program() expect(count(src, JOB_LINE)).toBe(1) const begin = src.indexOf(RJ_BEGIN) const end = src.indexOf(RJ_END) expect(begin).toBeGreaterThanOrEqual(0) expect(src.indexOf(JOB_LINE)).toBeGreaterThan(begin) expect(src.indexOf(JOB_LINE)).toBeLessThan(end) expect(modifiedProgram(r)).toBe(true) expect(r.envelope.warnings.some((w: string) => /RECURRING-JOBS|Program\.cs/.test(w))).toBe(false) }) it('is idempotent: a re-run keeps ONE line and no longer reports Program.cs modified', () => { plant(root, PROGRAM, WITH_MARKER) run({ scheduledJobs: [JOB] }) const after1 = program() const r2 = run({ scheduledJobs: [JOB] }) expect(program()).toBe(after1) expect(count(program(), JOB_LINE)).toBe(1) expect(count(program(), 'AddSmartStackRecurringJob')).toBe(1) expect(modifiedProgram(r2)).toBe(false) }) it('two scheduled use cases land two distinct lines, each once', () => { plant(root, PROGRAM, WITH_MARKER) const other = { ...JOB, ucCode: 'UC-HRM-008', jobId: 'hrm-employee-expiry', slug: 'expiry', methodName: 'RunExpiryAsync', cron: '0 7 * * 1' } run({ scheduledJobs: [JOB, other] }) const src = program() expect(count(src, JOB_LINE)).toBe(1) expect(count(src, '"hrm-employee-expiry", s => s.RunExpiryAsync(default, CancellationToken.None), "0 7 * * 1"')).toBe(1) expect(count(src, 'AddSmartStackRecurringJob')).toBe(2) }) it('a pre-marker Program.cs (no block, no braces) gets the block inserted right after AddSmartStackHangfire — once', () => { plant(root, PROGRAM, PRE_MARKER) const r = run({ scheduledJobs: [JOB] }) const src = program() expect(count(src, RJ_BEGIN)).toBe(1) expect(count(src, RJ_END)).toBe(1) expect(count(src, JOB_LINE)).toBe(1) const hangfire = src.indexOf('AddSmartStackHangfire(') expect(src.indexOf(RJ_BEGIN)).toBeGreaterThan(hangfire) expect(src.indexOf(RJ_BEGIN)).toBeLessThan(src.indexOf('var app = builder.Build()')) expect(modifiedProgram(r)).toBe(true) // Re-run: the block now exists → merge path, still one line. const r2 = run({ scheduledJobs: [JOB] }) expect(count(program(), JOB_LINE)).toBe(1) expect(count(program(), RJ_BEGIN)).toBe(1) expect(modifiedProgram(r2)).toBe(false) }) it('a pre-marker Program.cs WITH an if-block after builder.Build() still anchors on AddSmartStackHangfire — never inside the if', () => { // The DI-host fallback of mergeLineIntoMarkerBlock splices before the file's LAST `}` — // on a top-level-statements Program.cs that is the tail of this `if`, after Build(): // a builder.Services.Add… there throws at boot (found 2026-09-24, fixed in index.ts). const PRE_MARKER_WITH_IF = `var builder = WebApplication.CreateBuilder(args); builder.Services.AddSmartStackHangfire(builder.Configuration); var app = builder.Build(); if (app.Environment.IsDevelopment()) { app.UseSwagger(); } app.Run(); ` plant(root, PROGRAM, PRE_MARKER_WITH_IF) const r = run({ scheduledJobs: [JOB] }) const src = program() expect(count(src, RJ_BEGIN)).toBe(1) expect(count(src, JOB_LINE)).toBe(1) expect(src.indexOf(RJ_BEGIN)).toBeGreaterThan(src.indexOf('AddSmartStackHangfire(')) expect(src.indexOf(RJ_END)).toBeLessThan(src.indexOf('var app = builder.Build()')) expect(src.indexOf('app.UseSwagger();\n}')).toBeGreaterThan(0) // the if-block is untouched expect(modifiedProgram(r)).toBe(true) const r2 = run({ scheduledJobs: [JOB] }) expect(count(program(), JOB_LINE)).toBe(1) expect(modifiedProgram(r2)).toBe(false) }) it('no marker and no Hangfire registration → Program.cs untouched, a warning carries the line', () => { plant(root, PROGRAM, NO_ANCHOR) const r = run({ scheduledJobs: [JOB] }) expect(program()).toBe(NO_ANCHOR) expect(modifiedProgram(r)).toBe(false) const w = r.envelope.warnings.find((x: string) => x.includes('no RECURRING-JOBS marker nor AddSmartStackHangfire')) expect(w).toBeDefined() expect(w).toContain(JOB_LINE) }) it('no Program.cs at all → a warning names the expected path, nothing is created', () => { const r = run({ scheduledJobs: [JOB] }) expect(r.envelope.warnings.some((w: string) => w.includes('Program.cs not found at') && w.includes('RECURRING-JOBS'))).toBe(true) expect(r.envelope.filesModified.map(norm).some((p: string) => p.endsWith('/Program.cs'))).toBe(false) expect(r.envelope.filesCreated.map(norm).some((p: string) => p.endsWith('/Program.cs'))).toBe(false) }) }) describe('scaffold-business / RECURRING-JOBS patch — without a scheduled use case', () => { it('leaves the marker block byte-identical and does not report Program.cs modified', () => { plant(root, PROGRAM, WITH_MARKER) const r = run() expect(program()).toBe(WITH_MARKER) expect(modifiedProgram(r)).toBe(false) expect(r.envelope.warnings.some((w: string) => /RECURRING-JOBS|Program\.cs/.test(w))).toBe(false) }) it('a block already holding another entity\'s job is left as is (no line added, none dropped)', () => { const foreign = 'builder.Services.AddSmartStackRecurringJob("payroll-close", s => s.CloseAsync(default, CancellationToken.None), "0 1 1 * *");' const seeded = WITH_MARKER.replace(` ${RJ_END}`, ` ${foreign}\n ${RJ_END}`) plant(root, PROGRAM, seeded) const r = run() expect(program()).toBe(seeded) expect(count(program(), 'AddSmartStackRecurringJob')).toBe(1) expect(modifiedProgram(r)).toBe(false) }) it('no Program.cs and no scheduled job → no RECURRING-JOBS warning at all', () => { const r = run() expect(r.envelope.warnings.some((w: string) => /RECURRING-JOBS/.test(w))).toBe(false) }) })