import { Logger } from '@nestjs/common'; import { JobRun, Prisma } from '../../generated/prisma'; import { JOB_NAMES } from '../jobs/job-names'; import { NotificationsService } from '../notifications/notifications.service'; import { PrismaService } from '../prisma/prisma.service'; import { JOB_ABORTED_ERROR, JobPartialFailure, JobRunService, } from './job-run.service'; type RawCall = [readonly string[], ...unknown[]]; const STARTED_AT = new Date('2026-10-13T04:00:00.000Z'); function runRow(overrides: Partial = {}): JobRun { return { id: 'run-1', job: 'daily-evaluation', status: 'RUNNING', startedAt: STARTED_AT, finishedAt: null, result: null, error: null, ...overrides, }; } describe('JobRunService', () => { let jobRun: { create: jest.Mock; findMany: jest.Mock; findFirst: jest.Mock; }; let tx: { $executeRaw: jest.Mock, RawCall>; $queryRaw: jest.Mock, RawCall>; }; let prisma: { jobRun: typeof jobRun; $transaction: jest.Mock; $queryRaw: jest.Mock, RawCall>; }; let notifications: { recordForPermission: jest.Mock }; let service: JobRunService; let errorSpy: jest.SpyInstance; const sqlText = (call: RawCall): string => call[0].join('?'); beforeEach(() => { jobRun = { create: jest.fn(({ data }: { data: { job: string } }) => Promise.resolve(runRow({ job: data.job })), ), findMany: jest.fn().mockResolvedValue([]), findFirst: jest.fn().mockResolvedValue(null), }; tx = { $executeRaw: jest.fn, RawCall>().mockResolvedValue(1), $queryRaw: jest .fn, RawCall>() .mockResolvedValue([{ notified: false, day: '2026-10-13' }]), }; prisma = { jobRun, $transaction: jest.fn((work: (client: typeof tx) => Promise) => work(tx), ), // Finishing a run: UPDATE … RETURNING *, the status is the first value. $queryRaw: jest.fn, RawCall>((_sql, status) => Promise.resolve([runRow({ status: status as JobRun['status'] })]), ), }; notifications = { recordForPermission: jest.fn().mockResolvedValue([{ id: 'n1' }]), }; service = new JobRunService( prisma as unknown as PrismaService, notifications as unknown as NotificationsService, ); errorSpy = jest .spyOn(Logger.prototype, 'error') .mockImplementation(() => undefined); jest.spyOn(Logger.prototype, 'log').mockImplementation(() => undefined); }); afterEach(() => jest.restoreAllMocks()); describe('run', () => { it('records RUNNING first, then SUCCEEDED with the result', async () => { const work = jest.fn(() => { expect(jobRun.create).toHaveBeenCalledWith({ data: { job: 'cleanup' }, }); return Promise.resolve({ deleted: 3, dryRun: false }); }); const run = await service.run('cleanup', work); expect(work).toHaveBeenCalledTimes(1); const [finish] = prisma.$queryRaw.mock.calls; expect(finish.slice(1)).toEqual([ 'SUCCEEDED', '{"deleted":3,"dryRun":false}', null, 'run-1', ]); expect(run.status).toBe('SUCCEEDED'); expect(notifications.recordForPermission).not.toHaveBeenCalled(); }); it('records FAILED with the loggable message, never the Prisma message', async () => { const run = await service.run('cleanup', () => Promise.reject( new Prisma.PrismaClientKnownRequestError('title "Max Mustermann"', { code: 'P2002', clientVersion: '7.9.1', meta: { modelName: 'Task' }, }), ), ); expect(run.status).toBe('FAILED'); const [finish] = prisma.$queryRaw.mock.calls; expect(finish.slice(1)).toEqual([ 'FAILED', null, 'PrismaClientKnownRequestError P2002 (Task)', 'run-1', ]); expect(JSON.stringify(errorSpy.mock.calls)).not.toContain('Mustermann'); }); it('stores the counters of a partial failure with the FAILED run', async () => { const run = await service.run('daily-evaluation', () => Promise.reject( new JobPartialFailure({ WithdrawalEnded: 2, TaskDue: 1 }, [ 'time-events', ]), ), ); expect(run.status).toBe('FAILED'); const [finish] = prisma.$queryRaw.mock.calls; expect(finish.slice(1)).toEqual([ 'FAILED', '{"WithdrawalEnded":2,"TaskDue":1}', 'Daily evaluation failed for: time-events', 'run-1', ]); expect(notifications.recordForPermission).toHaveBeenCalledTimes(1); }); // finishedAt of every run - finished, failed or aborted - comes from // the database clock, like the comparisons of cleanup. it.each([ ['succeeds', () => Promise.resolve({ deleted: 1 })], ['fails', () => Promise.reject(new Error('boom'))], ])( 'sets finishedAt to now() when the run %s', async (_case, work: () => Promise>) => { await service.run('cleanup', work); const [finish] = prisma.$queryRaw.mock.calls; expect(sqlText(finish)).toContain('"finishedAt" = now()'); expect(sqlText(finish)).toContain('RETURNING *'); expect(finish.slice(1).some((value) => value instanceof Date)).toBe( false, ); }, ); it('notifies holders of the job permission when a run fails', async () => { await service.run('daily-evaluation', () => Promise.reject(new Error('boom')), ); expect(notifications.recordForPermission).toHaveBeenCalledWith( 'automation:update', { priority: 'WARNUNG', category: 'SYSTEM', message: 'Job „Tägliche Auswertung“ fehlgeschlagen', relatedType: 'JobRun', relatedId: 'run-1', dedupeKeyPrefix: 'job-failed:daily-evaluation:2026-10-13', }, tx, ); }); // The hourly app-backup check would otherwise report one outage every // hour. The check and the write share a transaction behind a lock per // job, so two failing runs at once cannot both pass the check. it('notifies at most once per job and Berlin day, decided by the database clock', async () => { tx.$queryRaw.mockResolvedValue([{ notified: true }]); await service.run('app-backup', () => Promise.reject(new Error('boom'))); expect(notifications.recordForPermission).not.toHaveBeenCalled(); const lock = tx.$executeRaw.mock.calls[0]; expect(sqlText(lock)).toContain('pg_advisory_xact_lock'); expect(lock.slice(1)).toEqual(['job-failure-notification:app-backup']); const check = tx.$queryRaw.mock.calls[0]; expect(sqlText(check)).toContain( `date_trunc('day', now() AT TIME ZONE 'Europe/Berlin')`, ); // The key carries the same day the check uses. expect(sqlText(check)).toContain( `to_char(now() AT TIME ZONE 'Europe/Berlin'`, ); expect(check.slice(1)).toEqual(['JobRun', 'app-backup']); expect(tx.$executeRaw.mock.invocationCallOrder[0]).toBeLessThan( tx.$queryRaw.mock.invocationCallOrder[0], ); }); it('still returns the FAILED run when the notification cannot be written', async () => { prisma.$transaction.mockRejectedValue(new Error('deadlock detected')); const run = await service.run('cleanup', () => Promise.reject(new Error('boom')), ); expect(run.status).toBe('FAILED'); expect(JSON.stringify(errorSpy.mock.calls)).toContain( 'deadlock detected', ); }); }); describe('recent', () => { it('returns the newest runs, optionally of one job', async () => { await service.recent(20, 'cleanup'); await service.recent(5); expect(jobRun.findMany).toHaveBeenNthCalledWith(1, { where: { job: 'cleanup' }, orderBy: [{ startedAt: 'desc' }, { id: 'desc' }], take: 20, }); expect(jobRun.findMany).toHaveBeenNthCalledWith(2, { where: {}, orderBy: [{ startedAt: 'desc' }, { id: 'desc' }], take: 5, }); }); it.each([0, -1, 1.5])('refuses the limit %p', async (limit) => { await expect(service.recent(limit)).rejects.toThrow(RangeError); }); }); describe('latestPerJob', () => { it('returns the newest run of every job, null for a job that never ran', async () => { const cleanupRun = runRow({ job: 'cleanup', status: 'SUCCEEDED' }); jobRun.findFirst.mockImplementation( ({ where }: { where: { job: string } }) => Promise.resolve(where.job === 'cleanup' ? cleanupRun : null), ); const latest = await service.latestPerJob(); expect(Object.keys(latest)).toEqual([...JOB_NAMES]); expect(latest.cleanup).toBe(cleanupRun); expect(latest.rekey).toBeNull(); expect(jobRun.findFirst).toHaveBeenCalledWith({ where: { job: 'cleanup' }, orderBy: [{ startedAt: 'desc' }, { id: 'desc' }], }); }); }); describe('abortStale', () => { it('marks runs RUNNING longer than the window as FAILED, by the database clock', async () => { prisma.$queryRaw.mockResolvedValue([]); await expect(service.abortStale(6)).resolves.toBe(0); const [call] = prisma.$queryRaw.mock.calls; const sql = sqlText(call); expect(sql).toContain(`"status" = 'RUNNING'`); expect(sql).toContain('"startedAt" < now()'); expect(call.slice(1)).toEqual([JOB_ABORTED_ERROR, 6]); expect(call.slice(1).some((value) => value instanceof Date)).toBe(false); }); it('sends the failure notification once per job of the aborted runs', async () => { prisma.$queryRaw.mockResolvedValue([ { id: 'a', job: 'app-backup' }, { id: 'b', job: 'app-backup' }, { id: 'c', job: 'task-templates' }, ]); await expect(service.abortStale(6)).resolves.toBe(3); expect(notifications.recordForPermission).toHaveBeenCalledTimes(2); expect(notifications.recordForPermission).toHaveBeenCalledWith( 'backup:configure', expect.objectContaining({ message: 'Job „App-Backup“ fehlgeschlagen', relatedId: 'a', }), tx, ); expect(notifications.recordForPermission).toHaveBeenCalledWith( 'automation:update', expect.objectContaining({ relatedId: 'c' }), tx, ); }); it('aborts but does not notify for a job name that no longer exists', async () => { prisma.$queryRaw.mockResolvedValue([{ id: 'x', job: 'retired-job' }]); await expect(service.abortStale(6)).resolves.toBe(1); expect(notifications.recordForPermission).not.toHaveBeenCalled(); }); }); });