import { INestApplication } from "@nestjs/common"; import { getRepositoryToken } from "@nestjs/typeorm"; import { Repository } from "typeorm"; import { buildTestApp } from "./app.harness"; import { ScenarioSchedulerService } from "../src/scenario/scenario-scheduler.service"; import { ScenarioRunEntity } from "../src/scenario/scenario-run.entity"; import { ScenarioRunStepEntity } from "../src/scenario/scenario-run-step.entity"; import { ScenarioRunLogEntity } from "../src/scenario/scenario-run-log.entity"; import { ScenarioEntity } from "../src/scenario/scenario.entity"; import { ScenarioStepEntity } from "../src/scenario/scenario-step.entity"; describe("ScenarioSchedulerService.onModuleInit", () => { let app: INestApplication; let scheduler: ScenarioSchedulerService; let scenarioRepo: Repository; let stepRepo: Repository; let runRepo: Repository; let runStepRepo: Repository; let runLogRepo: Repository; beforeAll(async () => { app = await buildTestApp(); scheduler = app.get(ScenarioSchedulerService); scenarioRepo = app.get>( getRepositoryToken(ScenarioEntity), ); stepRepo = app.get>( getRepositoryToken(ScenarioStepEntity), ); runRepo = app.get>( getRepositoryToken(ScenarioRunEntity), ); runStepRepo = app.get>( getRepositoryToken(ScenarioRunStepEntity), ); runLogRepo = app.get>( getRepositoryToken(ScenarioRunLogEntity), ); }); afterAll(async () => { await app.close(); }); // ── helpers ──────────────────────────────────────────────────────────────── async function seedScenarioWithStep() { const scenario = await scenarioRepo.save( scenarioRepo.create({ name: "test-scenario" }), ); const step = await stepRepo.save( stepRepo.create({ scenarioId: scenario.id, order: 1 }), ); return { scenario, step }; } async function seedRun( scenarioId: string, runStatus: ScenarioRunEntity["status"], ) { return runRepo.save( runRepo.create({ scenarioId, status: runStatus }), ); } async function seedStepRun( runId: string, scenarioStepId: string, status: ScenarioRunStepEntity["status"], ) { return runStepRepo.save( runStepRepo.create({ runId, scenarioStepId, order: 1, status }), ); } // ── tests ────────────────────────────────────────────────────────────────── it("does nothing when no in_progress runs exist", async () => { // All existing runs (if any) should already be clean after app init. // This call should be a no-op and not throw. await expect(scheduler.onModuleInit()).resolves.toBeUndefined(); }); it("fails an in_progress run left from a previous server instance", async () => { const { scenario } = await seedScenarioWithStep(); const run = await seedRun(scenario.id, "in_progress"); await scheduler.onModuleInit(); const updated = await runRepo.findOneByOrFail({ id: run.id }); expect(updated.status).toBe("fail"); }); it("leaves pending and completed runs untouched", async () => { const { scenario } = await seedScenarioWithStep(); const pending = await seedRun(scenario.id, "pending"); const passed = await seedRun(scenario.id, "pass"); const failed = await seedRun(scenario.id, "fail"); await scheduler.onModuleInit(); expect((await runRepo.findOneByOrFail({ id: pending.id })).status).toBe( "pending", ); expect((await runRepo.findOneByOrFail({ id: passed.id })).status).toBe( "pass", ); expect((await runRepo.findOneByOrFail({ id: failed.id })).status).toBe( "fail", ); }); it("fails in_progress step runs belonging to the stale run", async () => { const { scenario, step } = await seedScenarioWithStep(); const run = await seedRun(scenario.id, "in_progress"); const stepRun = await seedStepRun(run.id, step.id, "in_progress"); await scheduler.onModuleInit(); const updated = await runStepRepo.findOneByOrFail({ id: stepRun.id }); expect(updated.status).toBe("fail"); }); it("cancels pending and waiting step runs of a stale run", async () => { const { scenario, step } = await seedScenarioWithStep(); const run = await seedRun(scenario.id, "in_progress"); const pendingStep = await seedStepRun(run.id, step.id, "pending"); const waitingStep = await seedStepRun(run.id, step.id, "waiting"); await scheduler.onModuleInit(); expect( (await runStepRepo.findOneByOrFail({ id: pendingStep.id })).status, ).toBe("cancelled"); expect( (await runStepRepo.findOneByOrFail({ id: waitingStep.id })).status, ).toBe("cancelled"); }); it("does not affect step runs of non-stale runs", async () => { const { scenario, step } = await seedScenarioWithStep(); const staleRun = await seedRun(scenario.id, "in_progress"); const passedRun = await seedRun(scenario.id, "pass"); const staleStep = await seedStepRun(staleRun.id, step.id, "in_progress"); const passedStep = await seedStepRun(passedRun.id, step.id, "pass"); await scheduler.onModuleInit(); // in_progress step of the stale run → fail expect( (await runStepRepo.findOneByOrFail({ id: staleStep.id })).status, ).toBe("fail"); // step of a completed run → untouched expect( (await runStepRepo.findOneByOrFail({ id: passedStep.id })).status, ).toBe("pass"); }); it("persists an error log entry for each failed run", async () => { const { scenario } = await seedScenarioWithStep(); const run = await seedRun(scenario.id, "in_progress"); await scheduler.onModuleInit(); // Allow the void log persist to flush (persistLog uses void save) await new Promise((r) => setTimeout(r, 50)); const logs = await runLogRepo.findBy({ runId: run.id }); expect(logs.length).toBeGreaterThanOrEqual(1); const errorLog = logs.find((l) => l.level === "error"); expect(errorLog).toBeDefined(); expect(errorLog!.message).toMatch(/server restarted/i); }); it("handles multiple stale runs in one pass", async () => { const { scenario } = await seedScenarioWithStep(); const run1 = await seedRun(scenario.id, "in_progress"); const run2 = await seedRun(scenario.id, "in_progress"); await scheduler.onModuleInit(); expect((await runRepo.findOneByOrFail({ id: run1.id })).status).toBe( "fail", ); expect((await runRepo.findOneByOrFail({ id: run2.id })).status).toBe( "fail", ); }); });