feat: T4 wave 5 - colony end cleanup, withdrawal chain, next steps, inspection takeover UI
- Colony end releases the queen in the same transaction; open suggestions become obsolete and a CLEANUP suggestion lists open tasks and the queen; apply-cleanup validates evidence and blocks reactivated colonies - Withdrawal chain: plausibility warnings on harvest and bottling, conflict notifications also when a treatment is entered or corrected after the harvest (naming bottled lots), withdrawal spans in the calendar and M365 sync, Berlin-day end events - Next 3 steps per colony and the "Heute" endpoint with ranking and an explicit merge table by intent - Inspection-triggered rule suggestions follow the colony's latest inspection (resolve when the finding is gone, refresh when it stays) - Frontend: colony card takeover in the inspection form, feed status as condition, occupied frames 0-40; legacy offline entries keep syncing - Notifications and preferences are deleted with their user Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
This commit is contained in:
1 parent
02eb16924e
commit
ef33e5549b
122 files changed
+10707
-504
No files matched your search
@@ -0,0 +1,644 @@
|
||||
import { INestApplication } from '@nestjs/common';
|
||||
import * as bcrypt from 'bcryptjs';
|
||||
import request from 'supertest';
|
||||
import { App } from 'supertest/types';
|
||||
import { CalendarSyncService } from '../src/calendar-sync/calendar-sync.service';
|
||||
import { CalendarSyncSettingsService } from '../src/calendar-sync/calendar-sync-settings.service';
|
||||
import { DomainEventsService } from '../src/domain-events/domain-events.service';
|
||||
import { PrismaService } from '../src/prisma/prisma.service';
|
||||
import { createTestApp } from './utils/create-test-app';
|
||||
import { login } from './utils/api';
|
||||
import { drainDomainEvents } from './utils/domain-events';
|
||||
|
||||
interface Warning {
|
||||
code: string;
|
||||
severity: string;
|
||||
message: string;
|
||||
refs: { type: string; id: string }[];
|
||||
}
|
||||
|
||||
interface WithWarningsBody {
|
||||
id: string;
|
||||
warnings: Warning[];
|
||||
}
|
||||
|
||||
/**
|
||||
* The withdrawal (Wartezeit) chain against a real PostgreSQL: a harvest or
|
||||
* a bottling inside a treatment's window is written and answered with a
|
||||
* warning, leaves one notification per harvest and colony, the end of a
|
||||
* window tells that the colony may be harvested again, and the window
|
||||
* reaches the M365 calendar as a span. The notifications fan out to every
|
||||
* user holding the permissions, so the assertions only look at the reader
|
||||
* created here and every block removes the rows it caused.
|
||||
*/
|
||||
describe('Withdrawal chain (e2e)', () => {
|
||||
let app: INestApplication<App>;
|
||||
let prisma: PrismaService;
|
||||
let events: DomainEventsService;
|
||||
let calendarSync: CalendarSyncService;
|
||||
const suffix = `${Date.now()}-${Math.round(Math.random() * 1e6)}`;
|
||||
const password = 'SuperSecret123!';
|
||||
let apiaryId: string;
|
||||
let adminId: string;
|
||||
let readerId: string;
|
||||
let roleId: string;
|
||||
|
||||
const colonyIds: string[] = [];
|
||||
const harvestIds: string[] = [];
|
||||
const treatmentIds: string[] = [];
|
||||
const bottlingIds: string[] = [];
|
||||
const extractionIds: string[] = [];
|
||||
const honeyBatchIds: string[] = [];
|
||||
const rootIds: string[] = [];
|
||||
|
||||
const server = () => app.getHttpServer();
|
||||
const asAdmin = { Authorization: '' };
|
||||
|
||||
async function colonyWith(
|
||||
label: string,
|
||||
): Promise<{ id: string; code: string }> {
|
||||
const colony = await prisma.colony.create({
|
||||
data: {
|
||||
code: `WD-${label}-${suffix}`,
|
||||
apiaryId,
|
||||
colonyType: 'PRODUCTION_COLONY',
|
||||
},
|
||||
});
|
||||
colonyIds.push(colony.id);
|
||||
return { id: colony.id, code: colony.code };
|
||||
}
|
||||
|
||||
async function treat(
|
||||
colonyId: string,
|
||||
day: string,
|
||||
withdrawalPeriodDays: number | null,
|
||||
): Promise<string> {
|
||||
const treatment = await prisma.treatment.create({
|
||||
data: {
|
||||
colonyId,
|
||||
userId: adminId,
|
||||
date: new Date(day.includes('T') ? day : `${day}T00:00:00.000Z`),
|
||||
product: 'Oxuvar',
|
||||
withdrawalPeriodDays,
|
||||
},
|
||||
});
|
||||
treatmentIds.push(treatment.id);
|
||||
return treatment.id;
|
||||
}
|
||||
|
||||
async function postHarvest(colonyId: string | string[], day: string) {
|
||||
const response = await request(server())
|
||||
.post('/api/v1/harvests')
|
||||
.set(asAdmin)
|
||||
.send({
|
||||
apiaryId,
|
||||
date: `${day}T00:00:00.000Z`,
|
||||
colonyIds: Array.isArray(colonyId) ? colonyId : [colonyId],
|
||||
})
|
||||
.expect(201);
|
||||
const body = response.body as WithWarningsBody;
|
||||
harvestIds.push(body.id);
|
||||
rootIds.push(body.id);
|
||||
return body;
|
||||
}
|
||||
|
||||
const conflictRowsOf = (harvestId: string, colonyId: string) =>
|
||||
prisma.notification.findMany({
|
||||
where: {
|
||||
userId: readerId,
|
||||
dedupeKey: {
|
||||
startsWith: `withdrawal-conflict:${harvestId}:${colonyId}:`,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
/** Removes what this file's chain wrote to anybody, so other files' users stay clean. */
|
||||
async function purgeNotifications(): Promise<void> {
|
||||
await prisma.notification.deleteMany({
|
||||
where: {
|
||||
category: 'WITHDRAWAL',
|
||||
OR: [
|
||||
{ relatedId: { in: [...harvestIds, ...colonyIds] } },
|
||||
...treatmentIds.map((id) => ({
|
||||
dedupeKey: { startsWith: `withdrawal-ended:${id}:` },
|
||||
})),
|
||||
],
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
beforeAll(async () => {
|
||||
app = await createTestApp();
|
||||
prisma = app.get(PrismaService);
|
||||
events = app.get(DomainEventsService);
|
||||
calendarSync = app.get(CalendarSyncService);
|
||||
const passwordHash = await bcrypt.hash(password, 12);
|
||||
const admin = await prisma.user.create({
|
||||
data: {
|
||||
email: `withdrawal-e2e-admin-${suffix}@example.com`,
|
||||
passwordHash,
|
||||
name: 'Withdrawal admin',
|
||||
role: { connect: { key: 'ADMIN' } },
|
||||
},
|
||||
});
|
||||
adminId = admin.id;
|
||||
asAdmin.Authorization = `Bearer ${(await login(app, admin.email, password)).accessToken}`;
|
||||
const role = await prisma.role.create({
|
||||
data: {
|
||||
key: `WD_READER_${suffix}`,
|
||||
name: 'Wartezeit Leser',
|
||||
permissions: {
|
||||
create: ['harvests:read', 'harvests:create', 'treatments:read'].map(
|
||||
(key) => ({ permission: { connect: { key } } }),
|
||||
),
|
||||
},
|
||||
},
|
||||
});
|
||||
roleId = role.id;
|
||||
const reader = await prisma.user.create({
|
||||
data: {
|
||||
email: `withdrawal-e2e-reader-${suffix}@example.com`,
|
||||
passwordHash,
|
||||
name: 'Withdrawal reader',
|
||||
roleId,
|
||||
},
|
||||
});
|
||||
readerId = reader.id;
|
||||
apiaryId = (
|
||||
await prisma.apiary.create({ data: { name: `Wartezeit ${suffix}` } })
|
||||
).id;
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await purgeNotifications();
|
||||
const roots = await prisma.domainEvent.findMany({
|
||||
where: { aggregateId: { in: rootIds } },
|
||||
select: { correlationId: true },
|
||||
});
|
||||
await prisma.domainEvent.deleteMany({
|
||||
where: {
|
||||
OR: [
|
||||
{ aggregateId: { in: rootIds } },
|
||||
{ correlationId: { in: roots.map((root) => root.correlationId) } },
|
||||
],
|
||||
},
|
||||
});
|
||||
await prisma.calendarSyncOutboxEntry.deleteMany({
|
||||
where: {
|
||||
sourceId: { in: [...treatmentIds, ...harvestIds] },
|
||||
},
|
||||
});
|
||||
await prisma.task.deleteMany({
|
||||
where: {
|
||||
OR: [
|
||||
{ relatedId: { in: colonyIds } },
|
||||
{ sourceSuggestion: { colonyId: { in: colonyIds } } },
|
||||
],
|
||||
},
|
||||
});
|
||||
await prisma.suggestion.deleteMany({
|
||||
where: { colonyId: { in: colonyIds } },
|
||||
});
|
||||
await prisma.auditLog.deleteMany({
|
||||
where: {
|
||||
OR: [
|
||||
{ userId: { in: [adminId, readerId] } },
|
||||
{
|
||||
entityId: {
|
||||
in: [...harvestIds, ...bottlingIds, ...treatmentIds],
|
||||
},
|
||||
},
|
||||
...colonyIds.map((colonyId) => ({
|
||||
entityType: 'Suggestion',
|
||||
afterData: { path: ['colonyId'], equals: colonyId },
|
||||
})),
|
||||
],
|
||||
},
|
||||
});
|
||||
await prisma.bottlingBatch.deleteMany({
|
||||
where: { honeyBatchId: { in: honeyBatchIds } },
|
||||
});
|
||||
await prisma.honeyBatch.deleteMany({
|
||||
where: { id: { in: honeyBatchIds } },
|
||||
});
|
||||
await prisma.extractionHarvest.deleteMany({
|
||||
where: { extractionId: { in: extractionIds } },
|
||||
});
|
||||
await prisma.extraction.deleteMany({
|
||||
where: { id: { in: extractionIds } },
|
||||
});
|
||||
await prisma.harvestColony.deleteMany({
|
||||
where: { harvestId: { in: harvestIds } },
|
||||
});
|
||||
await prisma.harvest.deleteMany({ where: { id: { in: harvestIds } } });
|
||||
await prisma.treatment.deleteMany({ where: { id: { in: treatmentIds } } });
|
||||
await prisma.colony.deleteMany({ where: { id: { in: colonyIds } } });
|
||||
await prisma.apiary.deleteMany({ where: { id: apiaryId } });
|
||||
await prisma.refreshToken.deleteMany({
|
||||
where: { userId: { in: [adminId, readerId] } },
|
||||
});
|
||||
await prisma.user.deleteMany({
|
||||
where: { id: { in: [adminId, readerId] } },
|
||||
});
|
||||
await prisma.role.deleteMany({ where: { id: roleId } });
|
||||
await app.close();
|
||||
});
|
||||
|
||||
describe('harvest and bottling inside the window', () => {
|
||||
let colony: { id: string; code: string };
|
||||
let harvestId: string;
|
||||
|
||||
afterAll(purgeNotifications);
|
||||
|
||||
beforeAll(async () => {
|
||||
colony = await colonyWith('A');
|
||||
// 2026-07-10 + 20 days: harvestable again from 2026-07-30.
|
||||
await treat(colony.id, '2026-07-10', 20);
|
||||
});
|
||||
|
||||
it('a harvest inside the window answers 201 with a warning and leaves one notification', async () => {
|
||||
const harvest = await postHarvest(colony.id, '2026-07-20');
|
||||
harvestId = harvest.id;
|
||||
|
||||
expect(harvest.warnings).toEqual([
|
||||
{
|
||||
code: 'WITHDRAWAL_ACTIVE',
|
||||
severity: 'WARNUNG',
|
||||
message: `${colony.code}: Wartezeit nach Oxuvar endet am 30.07.2026.`,
|
||||
refs: [
|
||||
{ type: 'Colony', id: colony.id },
|
||||
{ type: 'Treatment', id: treatmentIds[0] },
|
||||
{ type: 'Harvest', id: harvestId },
|
||||
],
|
||||
},
|
||||
]);
|
||||
// The request wrote the harvest all the same.
|
||||
expect(await prisma.harvest.count({ where: { id: harvestId } })).toBe(1);
|
||||
|
||||
await drainDomainEvents(app, [harvestId]);
|
||||
await drainDomainEvents(app, [harvestId]);
|
||||
|
||||
const rows = await conflictRowsOf(harvestId, colony.id);
|
||||
expect(rows).toHaveLength(1);
|
||||
expect(rows[0]).toMatchObject({
|
||||
priority: 'WARNUNG',
|
||||
category: 'WITHDRAWAL',
|
||||
message: `Wartezeit: ${colony.code} am 20.07. geerntet, Wartezeit nach Oxuvar endet am 30.07.2026.`,
|
||||
relatedType: 'Harvest',
|
||||
relatedId: harvestId,
|
||||
link: `/harvests/${harvestId}`,
|
||||
read: false,
|
||||
});
|
||||
expect(rows[0].requiredPermissions).toEqual(
|
||||
expect.arrayContaining(['harvests:read', 'treatments:read']),
|
||||
);
|
||||
});
|
||||
|
||||
it('a bottling of that harvest warns again but notifies no second time', async () => {
|
||||
const extraction = await prisma.extraction.create({
|
||||
data: { date: new Date('2026-07-21T00:00:00.000Z'), userId: adminId },
|
||||
});
|
||||
extractionIds.push(extraction.id);
|
||||
await prisma.extractionHarvest.create({
|
||||
data: { extractionId: extraction.id, harvestId },
|
||||
});
|
||||
const honeyBatch = await prisma.honeyBatch.create({
|
||||
data: {
|
||||
batchNumber: `WD-HC-${suffix}`,
|
||||
extractionId: extraction.id,
|
||||
amount: 20,
|
||||
},
|
||||
});
|
||||
honeyBatchIds.push(honeyBatch.id);
|
||||
await drainDomainEvents(app, [harvestId]);
|
||||
expect(await conflictRowsOf(harvestId, colony.id)).toHaveLength(1);
|
||||
|
||||
const response = await request(server())
|
||||
.post('/api/v1/bottling-batches')
|
||||
.set(asAdmin)
|
||||
.send({
|
||||
honeyBatchId: honeyBatch.id,
|
||||
date: '2026-07-22T00:00:00.000Z',
|
||||
jarSize: 500,
|
||||
jarCount: 4,
|
||||
bestBeforeDate: '2028-07-22T00:00:00.000Z',
|
||||
lotNumber: `WD-LOT-${suffix}`,
|
||||
})
|
||||
.expect(201);
|
||||
const bottling = response.body as WithWarningsBody;
|
||||
bottlingIds.push(bottling.id);
|
||||
rootIds.push(bottling.id);
|
||||
|
||||
expect(bottling.warnings).toHaveLength(1);
|
||||
expect(bottling.warnings[0]).toMatchObject({
|
||||
code: 'WITHDRAWAL_ACTIVE',
|
||||
message: `${colony.code}: Wartezeit nach Oxuvar endet am 30.07.2026.`,
|
||||
});
|
||||
|
||||
await drainDomainEvents(app, [bottling.id]);
|
||||
|
||||
expect(await conflictRowsOf(harvestId, colony.id)).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('a harvest on the first harvestable day answers an empty warning list', async () => {
|
||||
const harvest = await postHarvest(colony.id, '2026-07-30');
|
||||
|
||||
expect(harvest.warnings).toEqual([]);
|
||||
await drainDomainEvents(app, [harvest.id]);
|
||||
expect(await conflictRowsOf(harvest.id, colony.id)).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('a corrected harvest that moves out of the window answers an empty list; into it, a warning', async () => {
|
||||
const harvest = await postHarvest(colony.id, '2026-09-01');
|
||||
expect(harvest.warnings).toEqual([]);
|
||||
|
||||
const moved = await request(server())
|
||||
.patch(`/api/v1/harvests/${harvest.id}`)
|
||||
.set(asAdmin)
|
||||
.send({ date: '2026-07-12T00:00:00.000Z' })
|
||||
.expect(200);
|
||||
expect((moved.body as WithWarningsBody).warnings).toHaveLength(1);
|
||||
|
||||
const back = await request(server())
|
||||
.patch(`/api/v1/harvests/${harvest.id}`)
|
||||
.set(asAdmin)
|
||||
.send({ date: '2026-09-02T00:00:00.000Z' })
|
||||
.expect(200);
|
||||
expect((back.body as WithWarningsBody).warnings).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('harvest of several colonies', () => {
|
||||
afterAll(purgeNotifications);
|
||||
|
||||
it('warns and notifies only for the colony that is inside a window', async () => {
|
||||
const inside = await colonyWith('D1');
|
||||
const outside = await colonyWith('D2');
|
||||
await treat(inside.id, '2026-07-10', 20);
|
||||
|
||||
const harvest = await postHarvest([inside.id, outside.id], '2026-07-20');
|
||||
|
||||
expect(harvest.warnings).toHaveLength(1);
|
||||
expect(harvest.warnings[0].refs).toContainEqual({
|
||||
type: 'Colony',
|
||||
id: inside.id,
|
||||
});
|
||||
expect(harvest.warnings[0].message.startsWith(`${inside.code}:`)).toBe(
|
||||
true,
|
||||
);
|
||||
await drainDomainEvents(app, [harvest.id]);
|
||||
expect(await conflictRowsOf(harvest.id, inside.id)).toHaveLength(1);
|
||||
expect(await conflictRowsOf(harvest.id, outside.id)).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('treatment entered after the harvest', () => {
|
||||
afterAll(purgeNotifications);
|
||||
|
||||
it('a backdated treatment notifies the harvests it covers, once; a correction that extends the window adds the newly covered ones', async () => {
|
||||
const colony = await colonyWith('E');
|
||||
const early = await postHarvest(colony.id, '2026-07-20');
|
||||
const late = await postHarvest(colony.id, '2026-08-05');
|
||||
expect(early.warnings).toEqual([]);
|
||||
await drainDomainEvents(app, [early.id, late.id]);
|
||||
// The early harvest was bottled before the treatment was entered.
|
||||
const extraction = await prisma.extraction.create({
|
||||
data: { date: new Date('2026-07-21T00:00:00.000Z'), userId: adminId },
|
||||
});
|
||||
extractionIds.push(extraction.id);
|
||||
await prisma.extractionHarvest.create({
|
||||
data: { extractionId: extraction.id, harvestId: early.id },
|
||||
});
|
||||
const honeyBatch = await prisma.honeyBatch.create({
|
||||
data: {
|
||||
batchNumber: `WD-HC2-${suffix}`,
|
||||
extractionId: extraction.id,
|
||||
amount: 5,
|
||||
},
|
||||
});
|
||||
honeyBatchIds.push(honeyBatch.id);
|
||||
await prisma.bottlingBatch.create({
|
||||
data: {
|
||||
honeyBatchId: honeyBatch.id,
|
||||
userId: adminId,
|
||||
date: new Date('2026-07-22T00:00:00.000Z'),
|
||||
jarSize: 500,
|
||||
jarCount: 2,
|
||||
totalAmount: 1,
|
||||
bestBeforeDate: new Date('2028-07-22T00:00:00.000Z'),
|
||||
lotNumber: `WD-LOT2-${suffix}`,
|
||||
},
|
||||
});
|
||||
|
||||
const created = await request(server())
|
||||
.post('/api/v1/treatments')
|
||||
.set(asAdmin)
|
||||
.send({
|
||||
colonyId: colony.id,
|
||||
date: '2026-07-10T00:00:00.000Z',
|
||||
product: 'Oxuvar',
|
||||
withdrawalPeriodDays: 20,
|
||||
})
|
||||
.expect(201);
|
||||
const treatmentId = (created.body as { id: string }).id;
|
||||
treatmentIds.push(treatmentId);
|
||||
rootIds.push(treatmentId);
|
||||
await drainDomainEvents(app, [treatmentId]);
|
||||
|
||||
// 2026-07-10 + 20 = harvestable from 2026-07-30: only the early harvest.
|
||||
const earlyRows = await conflictRowsOf(early.id, colony.id);
|
||||
expect(earlyRows).toHaveLength(1);
|
||||
expect(earlyRows[0].message).toContain(
|
||||
`Bereits abgefüllt: Los WD-LOT2-${suffix}.`,
|
||||
);
|
||||
expect(await conflictRowsOf(late.id, colony.id)).toHaveLength(0);
|
||||
|
||||
await request(server())
|
||||
.patch(`/api/v1/treatments/${treatmentId}`)
|
||||
.set(asAdmin)
|
||||
.send({ withdrawalPeriodDays: 40 })
|
||||
.expect(200);
|
||||
await drainDomainEvents(app, [treatmentId]);
|
||||
|
||||
// Until 2026-08-19: the late harvest is covered now; the early one
|
||||
// is not notified a second time.
|
||||
expect(await conflictRowsOf(early.id, colony.id)).toHaveLength(1);
|
||||
expect(await conflictRowsOf(late.id, colony.id)).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('end of a window', () => {
|
||||
afterAll(purgeNotifications);
|
||||
|
||||
const endedEvent = (
|
||||
treatmentId: string,
|
||||
colonyId: string,
|
||||
endedOn: string,
|
||||
) =>
|
||||
prisma.$transaction((tx) =>
|
||||
events.record(tx, {
|
||||
type: 'WithdrawalEnded',
|
||||
aggregateType: 'Treatment',
|
||||
aggregateId: treatmentId,
|
||||
payload: { colonyId, endedOn },
|
||||
}),
|
||||
);
|
||||
|
||||
const readyRows = (colonyId: string) =>
|
||||
prisma.notification.findMany({
|
||||
where: {
|
||||
userId: readerId,
|
||||
relatedId: colonyId,
|
||||
category: 'WITHDRAWAL',
|
||||
dedupeKey: { startsWith: 'withdrawal-ended:' },
|
||||
},
|
||||
});
|
||||
|
||||
it('WithdrawalEnded yields one INFO only when no other window is running', async () => {
|
||||
const colony = await colonyWith('B');
|
||||
// First window: 2026-06-01 + 10 = 2026-06-11. Second: 2026-06-05 + 20 = 2026-06-25.
|
||||
const first = await treat(colony.id, '2026-06-01', 10);
|
||||
const second = await treat(colony.id, '2026-06-05', 20);
|
||||
rootIds.push(first, second);
|
||||
|
||||
await endedEvent(first, colony.id, '2026-06-11');
|
||||
await drainDomainEvents(app, [first]);
|
||||
expect(await readyRows(colony.id)).toHaveLength(0);
|
||||
|
||||
// Recorded twice: the job could deliver the same end again.
|
||||
await endedEvent(second, colony.id, '2026-06-25');
|
||||
await endedEvent(second, colony.id, '2026-06-25');
|
||||
await drainDomainEvents(app, [second]);
|
||||
|
||||
const rows = await readyRows(colony.id);
|
||||
expect(rows).toHaveLength(1);
|
||||
expect(rows[0]).toMatchObject({
|
||||
priority: 'INFO',
|
||||
category: 'WITHDRAWAL',
|
||||
message: `${colony.code} ist wieder erntefähig. Wartezeit nach Oxuvar endete am 25.06.2026.`,
|
||||
relatedType: 'Colony',
|
||||
link: `/colonies/${colony.id}`,
|
||||
});
|
||||
expect(rows[0].requiredPermissions).toEqual(
|
||||
expect.arrayContaining(['harvests:create', 'treatments:read']),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('end of a window across the Berlin day boundary', () => {
|
||||
afterAll(purgeNotifications);
|
||||
|
||||
it('takes the end day from the Berlin day of a treatment stored with a time part', async () => {
|
||||
const colony = await colonyWith('F');
|
||||
// 2026-06-01T22:30Z is 00:30 on 2026-06-02 in Berlin; one day later
|
||||
// the period ends on 2026-06-03 (the plain UTC day would say 06-02).
|
||||
const id = await treat(colony.id, '2026-06-01T22:30:00.000Z', 1);
|
||||
rootIds.push(id);
|
||||
const record = (endedOn: string) =>
|
||||
prisma.$transaction((tx) =>
|
||||
events.record(tx, {
|
||||
type: 'WithdrawalEnded',
|
||||
aggregateType: 'Treatment',
|
||||
aggregateId: id,
|
||||
payload: { colonyId: colony.id, endedOn },
|
||||
}),
|
||||
);
|
||||
|
||||
await record('2026-06-02');
|
||||
await drainDomainEvents(app, [id]);
|
||||
expect(
|
||||
await prisma.notification.count({
|
||||
where: { userId: readerId, relatedId: colony.id },
|
||||
}),
|
||||
).toBe(0);
|
||||
|
||||
await record('2026-06-03');
|
||||
await drainDomainEvents(app, [id]);
|
||||
const rows = await prisma.notification.findMany({
|
||||
where: { userId: readerId, relatedId: colony.id },
|
||||
});
|
||||
expect(rows).toHaveLength(1);
|
||||
expect(rows[0].message).toContain('endete am 03.06.2026');
|
||||
expect(rows[0].message).toContain(colony.code);
|
||||
});
|
||||
});
|
||||
|
||||
describe('calendar entry', () => {
|
||||
// The settings spy is this file's only mock; the shared singleton row is
|
||||
// never touched.
|
||||
afterEach(() => {
|
||||
jest.restoreAllMocks();
|
||||
});
|
||||
|
||||
it('a treatment with withdrawal days leaves a WITHDRAWAL calendar entry; removing the days unregisters it', async () => {
|
||||
const colony = await colonyWith('C');
|
||||
jest
|
||||
.spyOn(app.get(CalendarSyncSettingsService), 'get')
|
||||
.mockResolvedValue({
|
||||
id: 'singleton',
|
||||
enabled: true,
|
||||
groupId: 'group-e2e',
|
||||
syncTask: true,
|
||||
syncInspection: true,
|
||||
syncTreatment: true,
|
||||
syncFeeding: true,
|
||||
syncHarvest: true,
|
||||
syncQueenAssignment: true,
|
||||
syncWithdrawal: true,
|
||||
updatedAt: new Date(),
|
||||
});
|
||||
|
||||
const created = await request(server())
|
||||
.post('/api/v1/treatments')
|
||||
.set(asAdmin)
|
||||
.send({
|
||||
colonyId: colony.id,
|
||||
date: '2026-08-03T00:00:00.000Z',
|
||||
product: 'Oxuvar',
|
||||
withdrawalPeriodDays: 20,
|
||||
})
|
||||
.expect(201);
|
||||
const treatmentId = (created.body as { id: string }).id;
|
||||
treatmentIds.push(treatmentId);
|
||||
rootIds.push(treatmentId);
|
||||
await drainDomainEvents(app, [treatmentId]);
|
||||
|
||||
const entry = await prisma.calendarSyncOutboxEntry.findUniqueOrThrow({
|
||||
where: {
|
||||
sourceType_sourceId: {
|
||||
sourceType: 'WITHDRAWAL',
|
||||
sourceId: treatmentId,
|
||||
},
|
||||
},
|
||||
});
|
||||
expect(entry.status).toBe('PENDING');
|
||||
const buildEventInput = (id: string) =>
|
||||
(
|
||||
calendarSync as unknown as {
|
||||
buildEventInput(type: string, id: string): Promise<unknown>;
|
||||
}
|
||||
).buildEventInput('WITHDRAWAL', id);
|
||||
// Graph's end day is exclusive: the first harvestable day.
|
||||
expect(await buildEventInput(treatmentId)).toEqual({
|
||||
title: `Wartezeit ${colony.code} · Oxuvar`,
|
||||
date: new Date('2026-08-03T00:00:00.000Z'),
|
||||
endDate: new Date('2026-08-23T00:00:00.000Z'),
|
||||
});
|
||||
|
||||
await request(server())
|
||||
.patch(`/api/v1/treatments/${treatmentId}`)
|
||||
.set(asAdmin)
|
||||
.send({ withdrawalPeriodDays: null })
|
||||
.expect(200);
|
||||
await drainDomainEvents(app, [treatmentId]);
|
||||
|
||||
const after = await prisma.calendarSyncOutboxEntry.findUniqueOrThrow({
|
||||
where: { id: entry.id },
|
||||
});
|
||||
// Enqueued again: the worker finds no span and removes the event.
|
||||
expect(after.revision).toBeGreaterThan(entry.revision);
|
||||
expect(after.status).toBe('PENDING');
|
||||
expect(await buildEventInput(treatmentId)).toBeNull();
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user