Files
ApiCore/ApiCore-Backend/test/post-commit-side-effects.e2e-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

391 lines
14 KiB
TypeScript

import { INestApplication, Logger } from '@nestjs/common';
import * as bcrypt from 'bcryptjs';
import request from 'supertest';
import { App } from 'supertest/types';
import { PrismaService } from '../src/prisma/prisma.service';
import { NotificationsService } from '../src/notifications/notifications.service';
import { CalendarSyncSettingsService } from '../src/calendar-sync/calendar-sync-settings.service';
import { AutomationService } from '../src/automation/automation.service';
import { AutomationTriggerType, Prisma } from '../generated/prisma';
import { createTestApp } from './utils/create-test-app';
import { login } from './utils/api';
import { drainDomainEvents } from './utils/domain-events';
/**
* Side effects of a committed write (automation rules, calendar sync,
* webhooks) must never turn that write into an error response: the offline
* queue would send the request again and store the record twice - for
* treatments that is a duplicate entry in the Bestandsbuch. Producers that
* record a domain event run none of them in the request; the handlers do,
* and a failed handler is retried without repeating what already worked.
*/
describe('Post-commit side effects (e2e)', () => {
let app: INestApplication<App>;
let prisma: PrismaService;
const suffix = `${Date.now()}-${Math.round(Math.random() * 1e6)}`;
const email = `post-commit-e2e-${suffix}@example.com`;
const password = 'SuperSecret123!';
let token: string;
let userId: string;
let apiaryId: string;
const colonyIds: string[] = [];
const stockItemIds: string[] = [];
const server = () => app.getHttpServer();
const auth = () => ({ Authorization: `Bearer ${token}` });
function post(path: string, body: object, idempotencyKey?: string) {
const call = request(server()).post(path).set(auth());
return (
idempotencyKey === undefined
? call
: call.set('Idempotency-Key', idempotencyKey)
).send(body);
}
async function createColony(code: string) {
const colony = await prisma.colony.create({
data: {
code: `${code}-${suffix}`,
apiaryId,
colonyType: 'PRODUCTION_COLONY',
},
});
colonyIds.push(colony.id);
return colony;
}
/** Open or not: a rule task that exists at all. */
const taskCount = (trigger: AutomationTriggerType, relatedId: string) =>
prisma.task.count({
where: { sourceAutomationTrigger: trigger, relatedId },
});
const openTaskCount = (trigger: AutomationTriggerType, relatedId: string) =>
prisma.task.count({
where: {
sourceAutomationTrigger: trigger,
relatedId,
status: { in: ['OPEN', 'IN_PROGRESS'] },
},
});
/** Everything this file wrote, scoped by its own ids. */
async function cleanUp(): Promise<void> {
const byColony = {
where: { colonyId: { in: colonyIds } },
select: { id: true },
};
const recordIds = (
await Promise.all([
prisma.inspection.findMany(byColony),
prisma.treatment.findMany(byColony),
prisma.varroaMeasurement.findMany(byColony),
])
)
.flat()
.map((record) => record.id);
const relatedIds = [...colonyIds, ...stockItemIds];
const taskIds = (
await prisma.task.findMany({
where: { relatedId: { in: relatedIds } },
select: { id: true },
})
).map((task) => task.id);
const aggregateIds = [...recordIds, ...taskIds];
// Deliveries go with their event (ON DELETE CASCADE).
await prisma.domainEvent.deleteMany({
where: { aggregateId: { in: aggregateIds } },
});
await prisma.calendarSyncOutboxEntry.deleteMany({
where: { sourceId: { in: aggregateIds } },
});
// Rule tasks and the rules' own entries are written without a user.
await prisma.auditLog.deleteMany({
where: {
OR: [
{ userId },
{ entityType: 'Task', entityId: { in: taskIds } },
...relatedIds.map((relatedId) => ({
entityType: 'AutomationRule',
afterData: { path: ['relatedId'], equals: relatedId },
})),
],
},
});
await prisma.notification.deleteMany({
where: { relatedId: { in: relatedIds } },
});
await prisma.task.deleteMany({ where: { id: { in: taskIds } } });
await prisma.inspection.deleteMany({ where: byColony.where });
await prisma.treatment.deleteMany({ where: byColony.where });
await prisma.varroaMeasurement.deleteMany({ where: byColony.where });
await prisma.stockItem.deleteMany({ where: { id: { in: stockItemIds } } });
await prisma.colony.deleteMany({ where: { id: { in: colonyIds } } });
await prisma.apiary.deleteMany({ where: { id: apiaryId } });
await prisma.idempotencyRecord.deleteMany({ where: { userId } });
await prisma.refreshToken.deleteMany({ where: { userId } });
await prisma.user.deleteMany({ where: { id: userId } });
}
beforeAll(async () => {
app = await createTestApp();
prisma = app.get(PrismaService);
userId = (
await prisma.user.create({
data: {
email,
passwordHash: await bcrypt.hash(password, 12),
name: 'Post Commit',
role: { connect: { key: 'ADMIN' } },
},
})
).id;
token = (await login(app, email, password)).accessToken;
apiaryId = (
await prisma.apiary.create({ data: { name: `Post Commit ${suffix}` } })
).id;
});
afterEach(() => {
jest.restoreAllMocks();
});
afterAll(async () => {
await cleanUp();
await app.close();
});
describe('when a side effect fails', () => {
let errorLog: jest.SpyInstance;
/** The error lines the automation's legacy path logged for one record. */
const automationFailuresFor = (entityType: string, entityId: string) =>
errorLog.mock.calls
.map(([message]) => String(message))
.filter((message) =>
message.startsWith(
`Automation rule evaluation failed for ${entityType} ${entityId}:`,
),
);
/**
* Every transaction still runs for real, but inside it the seeding of
* the automation rules (ensureDefaults → tx.automationRule.createMany)
* fails: the first write of every rule evaluation. A rule evaluated
* inside the request's own transaction would turn it into a 500.
*/
function failRuleSeedingInTransactions(): void {
type Work = (tx: Prisma.TransactionClient) => Promise<unknown>;
const runTransaction = prisma.$transaction.bind(prisma) as (
work: Work,
options?: unknown,
) => Promise<unknown>;
const failingRules = (rules: object) =>
new Proxy(rules, {
get: (target, property): unknown =>
property === 'createMany'
? () => Promise.reject(new Error('automation rules down'))
: Reflect.get(target, property),
});
const withFailingRuleSeeding = (tx: Prisma.TransactionClient) =>
new Proxy(tx, {
get: (target, property): unknown =>
property === 'automationRule'
? failingRules(target.automationRule)
: Reflect.get(target, property),
});
jest
.spyOn(prisma, '$transaction')
.mockImplementation((work: unknown, options?: unknown) =>
typeof work === 'function'
? runTransaction(
(tx) => (work as Work)(withFailingRuleSeeding(tx)),
options,
)
: runTransaction(work as Work, options),
);
}
beforeEach(() => {
errorLog = jest
.spyOn(Logger.prototype, 'error')
.mockImplementation(() => undefined);
// Automation: the warning cannot be written.
jest
.spyOn(app.get(NotificationsService), 'recordForPermission')
.mockRejectedValue(new Error('notifications down'));
// Calendar sync: enqueue cannot read its settings.
jest
.spyOn(app.get(CalendarSyncSettingsService), 'get')
.mockRejectedValue(new Error('calendar settings down'));
// Webhooks: the subscriptions cannot be loaded.
jest
.spyOn(prisma.webhookSubscription, 'findMany')
.mockRejectedValue(new Error('webhook subscriptions down'));
});
it('still answers 201 and replays on retry with the same Idempotency-Key (no side effect in the request)', async () => {
const colony = await createColony('PC-INSP');
const key = `post-commit-${suffix}-inspection`;
const body = {
colonyId: colony.id,
date: '2026-07-01T00:00:00.000Z',
queenSeen: false,
eggsPresent: false,
};
const first = await post('/api/v1/inspections', body, key).expect(201);
const retry = await post('/api/v1/inspections', body, key).expect(201);
const inspectionId = (first.body as { id: string }).id;
expect((retry.body as { id: string }).id).toBe(inspectionId);
expect(
await prisma.inspection.count({ where: { colonyId: colony.id } }),
).toBe(1);
// The request only recorded its event - the replay not even that.
const events = await prisma.domainEvent.findMany({
where: { aggregateId: inspectionId },
});
expect(events).toEqual([
expect.objectContaining({
type: 'InspectionRecorded',
status: 'PENDING',
attempts: 0,
}),
]);
// No rule ran in the request, not even one that failed: the task
// follows when the handler runs.
expect(automationFailuresFor('Inspection', inspectionId)).toEqual([]);
expect(await taskCount('QUEENLESS_NO_EGGS', colony.id)).toBe(0);
expect(
await prisma.notification.count({ where: { relatedId: colony.id } }),
).toBe(0);
});
it('still answers 201 for a treatment when the rule evaluation would fail, and replays it', async () => {
failRuleSeedingInTransactions();
const colony = await createColony('PC-TREAT');
const key = `post-commit-${suffix}-treatment`;
const body = {
colonyId: colony.id,
date: '2026-08-01T00:00:00.000Z',
product: 'Oxalsäure',
};
const first = await post('/api/v1/treatments', body, key).expect(201);
const retry = await post('/api/v1/treatments', body, key).expect(201);
expect((retry.body as { id: string }).id).toBe(
(first.body as { id: string }).id,
);
expect(
await prisma.treatment.count({ where: { colonyId: colony.id } }),
).toBe(1);
});
});
describe('when an automation handler fails', () => {
it('creates exactly one automation task when the handler fails once and is retried', async () => {
jest.spyOn(Logger.prototype, 'warn').mockImplementation(() => undefined);
const colonyId = (await createColony('PC-RETRY')).id;
jest
.spyOn(app.get(NotificationsService), 'recordForPermission')
.mockRejectedValueOnce(new Error('notifications down'));
const { body } = (await post('/api/v1/inspections', {
colonyId,
date: '2026-07-01T00:00:00.000Z',
queenSeen: false,
eggsPresent: false,
}).expect(201)) as { body: { id: string } };
await drainDomainEvents(app, [body.id]);
// The warning failed, so the whole evaluation rolled back.
expect(await taskCount('QUEENLESS_NO_EGGS', colonyId)).toBe(0);
expect(
await prisma.notification.count({ where: { relatedId: colonyId } }),
).toBe(0);
// Due again now instead of after the 30 s backoff.
await prisma.$executeRaw`UPDATE "DomainEvent" SET "availableAt" = now() WHERE "aggregateId" = ${body.id}`;
await drainDomainEvents(app, [body.id]);
expect(await taskCount('QUEENLESS_NO_EGGS', colonyId)).toBe(1);
// One copy per recipient (everybody who may read tasks), written once.
const warnings = await prisma.notification.findMany({
where: { relatedId: colonyId },
select: { userId: true },
});
expect(warnings.length).toBeGreaterThan(0);
expect(new Set(warnings.map((w) => w.userId)).size).toBe(warnings.length);
const deliveries = await prisma.domainEventDelivery.findMany({
where: { event: { aggregateId: body.id } },
});
expect(
deliveries.find((d) => d.handler === 'automation.inspection-rules'),
).toMatchObject({ status: 'DONE', attempts: 2 });
// What already worked did not run again.
expect(
deliveries.find((d) => d.handler === 'webhooks.forward'),
).toMatchObject({ status: 'DONE', attempts: 1 });
await expect(
prisma.domainEvent.findFirstOrThrow({
where: { aggregateId: body.id },
}),
).resolves.toMatchObject({ status: 'DONE', attempts: 1 });
});
});
describe('automation tasks', () => {
it('leaves one open reorder task when several triggers for the same stock item arrive at once', async () => {
const automation = app.get(AutomationService);
// The same object crossing the threshold on several pods/requests at
// once: every evaluation sees "no open task yet" unless checking and
// creating are serialised.
const item = await prisma.stockItem.create({
data: {
name: `Race ${suffix}`,
category: 'CONSUMABLE',
unit: 'Stück',
quantity: 1,
minThreshold: 5,
},
});
stockItemIds.push(item.id);
await Promise.all(
Array.from({ length: 8 }, () => automation.evaluateStockItem(item)),
);
expect(await openTaskCount('STOCK_ITEM_LOW', item.id)).toBe(1);
});
it('leaves one open task when several triggers for the same colony arrive at once', async () => {
const colony = await createColony('PC-RACE');
const measurement = {
colonyId: colony.id,
date: '2026-08-15T00:00:00.000Z',
method: 'NATURAL_MITE_FALL',
miteCount: 20,
// One day on the board: 20 mites per day, above the default 10.
durationMinutes: 24 * 60,
};
const responses = await Promise.all(
Array.from({ length: 6 }, () =>
post('/api/v1/varroa-measurements', measurement).expect(201),
),
);
// One worker each: the handlers evaluate the rule at the same moment.
await Promise.all(
responses.map(({ body }) =>
drainDomainEvents(app, [(body as { id: string }).id]),
),
);
expect(await openTaskCount('VARROA_THRESHOLD', colony.id)).toBe(1);
});
});
});