- 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]>
322 lines
11 KiB
TypeScript
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();
|
|
});
|
|
});
|
|
});
|