feat: platform backbone and field capture complete (checkpoint)
Platform backbone (plan 2026-10-13-plattform-rueckgrat), all tasks reviewed: - webhooks with delivery table, retries and stable delivery id - calendar handlers; a finished task removes its Outlook event - tasks, automations, inspections, varroa, treatments, feedings, harvests, colonies, wintering, stock and bottling record domain events in the same transaction; legacy post-commit calls removed - row locks through one helper (lockRowForUpdate, lockRowsForShare, lockRowsForUpdate) - job framework with JobRun, cli exit codes, cleanup, task templates, daily evaluation, hourly app-backup and the rekey job - system API and page "Verwaltung > System" with retry - @nestjs/schedule removed, guard tests against in-process schedulers Field capture T3 (plan 2026-10-13-t3-erfassung-am-stand), all tasks reviewed: - inspection photo offline without metadata - bulk capture for treatments, feedings and varroa (backend + frontend) - offline stand mode with service worker, offline pages and packs - offline session, cold start and logout on shared devices Docs: ARCHITECTURE.md assumptions and sections updated, TASKS.md. Gates: backend lint, typecheck, 1770 unit, 420 e2e, build, no schema drift; frontend lint, typecheck, 913 tests, build. Not rolled out. Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
This commit is contained in:
1 parent
c01a38835a
commit
cdd30cf3ee
288 files changed
+39165
-2853
No files matched your search
@@ -0,0 +1,287 @@
|
||||
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, 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 }]),
|
||||
};
|
||||
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(2) };
|
||||
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');
|
||||
});
|
||||
|
||||
// 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',
|
||||
message: 'Job „Tägliche Auswertung“ fehlgeschlagen',
|
||||
relatedType: 'JobRun',
|
||||
relatedId: 'run-1',
|
||||
},
|
||||
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 UTC 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())`);
|
||||
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();
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user