import { INestApplication } from '@nestjs/common'; import { PrismaClient } from '@prisma/client'; import { afterAll, beforeAll, describe, expect, it } from 'vitest'; import { createTestApp } from '../testing/test-app'; import { createTestPrisma, hasTestDb, uniqueSuffix } from '../testing/test-db'; import { JobDefinition, SchedulerService } from './scheduler.service'; describe.skipIf(!hasTestDb)('SchedulerService (db, issue #31)', () => { let app: INestApplication; let prisma: PrismaClient; let scheduler: SchedulerService; const jobName = `test-job-${uniqueSuffix()}`; beforeAll(async () => { prisma = createTestPrisma(); app = await createTestApp(); scheduler = app.get(SchedulerService); }); afterAll(async () => { await prisma.job.deleteMany({ where: { name: { startsWith: 'test-job-' } } }); await prisma.$disconnect(); await app.close(); }); it('never runs the same job twice concurrently (locking)', async () => { let runs = 0; const job: JobDefinition = { name: jobName, cadenceSeconds: 3600, run: async () => { runs += 1; await new Promise((resolve) => setTimeout(resolve, 150)); }, }; // Two overlapping ticks racing the same due job — only one may win. await Promise.all([scheduler.runIfDue(job), scheduler.runIfDue(job)]); expect(runs).toBe(1); const row = await prisma.job.findUniqueOrThrow({ where: { name: jobName } }); expect(row.status).toBe('IDLE'); }); it('persists lastRunAt so a fresh scheduler instance respects cadence (restart survival)', async () => { const job: JobDefinition = { name: jobName, cadenceSeconds: 3600, run: async () => {} }; const before = await prisma.job.findUniqueOrThrow({ where: { name: jobName } }); // A brand-new NestJS application = a brand-new SchedulerService with // empty in-memory state, exactly like a real process restart. It must // still treat the job as "not due yet" because that lives in the DB. const restarted = await createTestApp(); try { const freshScheduler = restarted.get(SchedulerService); await freshScheduler.runIfDue(job); const after = await prisma.job.findUniqueOrThrow({ where: { name: jobName } }); expect(after.lastRunAt?.getTime()).toBe(before.lastRunAt?.getTime()); } finally { await restarted.close(); } }); it('recovers a stale RUNNING lock instead of blocking forever', async () => { const staleName = `test-job-stale-${uniqueSuffix()}`; await prisma.job.create({ data: { name: staleName, cadenceSeconds: 60, status: 'RUNNING', lockedAt: new Date(Date.now() - 2 * 60 * 60 * 1000), // 2h ago lastRunAt: new Date(Date.now() - 2 * 60 * 60 * 1000), }, }); let ran = false; await scheduler.runIfDue({ name: staleName, cadenceSeconds: 60, run: async () => { ran = true; }, }); expect(ran).toBe(true); }); });