Files
ApiCore/ApiCore-Backend/src/system/job-run.service.spec.ts
T
Nick SzaunigandClaude Opus 5.5 f468dc5f75 feat: T4 wave 3 - notifications per user, suggestions API, varroa bands, split/queen events
- Schema III: per-user notifications (broadcast fan-out migration,
  category, link, requiredPermissions filter), notification preferences
  and settings endpoints, calendar withdrawal switch, occupiedFrames
- Suggestions API: list/summary with permission visibility and paging,
  accept/dismiss/reopen incl. bulk, SuggestionView on all responses
- Varroa traffic light by season and method, young colonies, treatment
  effect window, sample-based percentage; shared withdrawal-window helper
  on Berlin calendar days; GET /varroa-measurements/thresholds
- ColonySplit and QueenAssigned domain events with calendar and webhook
  handlers
- Outbox claims compare against now() rounded to timestamp(3); fixes the
  flaky e2e claims of rows written with SQL now()

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-14 14:25:24 +02:00

322 lines
11 KiB
TypeScript

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> = {}): 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<Promise<number>, RawCall>;
$queryRaw: jest.Mock<Promise<unknown>, RawCall>;
};
let prisma: {
jobRun: typeof jobRun;
$transaction: jest.Mock;
$queryRaw: jest.Mock<Promise<unknown>, 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<Promise<number>, RawCall>().mockResolvedValue(1),
$queryRaw: jest
.fn<Promise<unknown>, RawCall>()
.mockResolvedValue([{ notified: false, day: '2026-10-13' }]),
};
prisma = {
jobRun,
$transaction: jest.fn((work: (client: typeof tx) => Promise<unknown>) =>
work(tx),
),
// Finishing a run: UPDATE … RETURNING *, the status is the first value.
$queryRaw: jest.fn<Promise<unknown>, 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<Record<string, number>>) => {
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();
});
});
});