Files
ApiCore/ApiCore-Backend/test/treatments.e2e-spec.ts
T
Nick SzaunigandClaude Opus 5.5 c2f71bcb03
Backend Image / build-and-push (push) Failing after 2m45s
Frontend Image / build-and-push (push) Successful in 5m52s
fix: T4a browser check findings
- Withdrawal wording from one backend helper everywhere ("bis
  einschließlich TT.MM. – ab TT.MM. wieder erntefähig", year when it
  crosses New Year); withdrawal-status returns lastBlockedDay and the
  ready text, the frontend no longer computes dates
- Calendar events name colony or apiary, withdrawal events carry
  product and detail; compact withdrawal rows on mobile
- One bottling warning per colony and treatment; pre-save hint
- Offline: online-only actions disabled with a reason, cached data
  shows its timestamp, open sheets block submit offline
- Mobile: no horizontal scroll at 360 px, touch targets >= 44 px,
  toasts and the add button no longer cover content
- Cleanup sheet without destructive preselection; natural sort of
  colony codes; collapsible automation rule cards; varroa list newest
  first with chart axes; text fixes

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

652 lines
20 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { INestApplication } 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 { createTestApp } from './utils/create-test-app';
import { login } from './utils/api';
interface TreatmentBody {
id: string;
colonyId: string;
startDate: string | null;
endDate: string | null;
}
interface WithdrawalStatusBody {
colonyId: string;
activeUntil: string;
lastBlockedDay: string;
withdrawalText: string;
product: string;
}
interface BulkTreatmentBody {
bulkId: string;
records: (TreatmentBody & { apiaryId: string; batchNumber: string })[];
}
describe('Treatments (e2e)', () => {
let app: INestApplication<App>;
let prisma: PrismaService;
const beekeeperEmail = `treatments-e2e-beekeeper-${Date.now()}@example.com`;
const staffEmail = `treatments-e2e-staff-${Date.now()}@example.com`;
const password = 'SuperSecret123!';
let beekeeperId: string;
let staffId: string;
let beekeeperToken: string;
let staffToken: string;
let apiaryId: string;
let colonyId: string;
let treatmentId: string;
beforeAll(async () => {
app = await createTestApp();
prisma = app.get(PrismaService);
const passwordHash = await bcrypt.hash(password, 12);
const beekeeper = await prisma.user.create({
data: {
email: beekeeperEmail,
passwordHash,
name: 'Beekeeper',
role: { connect: { key: 'BEEKEEPER' } },
},
});
beekeeperId = beekeeper.id;
const staff = await prisma.user.create({
data: {
email: staffEmail,
passwordHash,
name: 'Staff',
role: { connect: { key: 'STAFF' } },
},
});
staffId = staff.id;
beekeeperToken = (await login(app, beekeeperEmail, password)).accessToken;
staffToken = (await login(app, staffEmail, password)).accessToken;
const apiary = await prisma.apiary.create({
data: { name: `Stand ${Date.now()}` },
});
apiaryId = apiary.id;
const colony = await prisma.colony.create({
data: {
code: `V-${Date.now()}`,
apiaryId,
colonyType: 'PRODUCTION_COLONY',
},
});
colonyId = colony.id;
});
afterAll(async () => {
const treatmentIds = (
await prisma.treatment.findMany({
where: { colonyId },
select: { id: true },
})
).map((treatment) => treatment.id);
await prisma.domainEvent.deleteMany({
where: { aggregateType: 'Treatment', aggregateId: { in: treatmentIds } },
});
await prisma.treatment.deleteMany({ where: { colonyId } });
await prisma.suggestion.deleteMany({ where: { colonyId } });
await prisma.colony.deleteMany({ where: { id: colonyId } });
await prisma.apiary.deleteMany({ where: { id: apiaryId } });
await prisma.refreshToken.deleteMany({
where: { userId: { in: [beekeeperId, staffId] } },
});
await prisma.auditLog.deleteMany({
where: { userId: { in: [beekeeperId, staffId] } },
});
await prisma.user.deleteMany({
where: { id: { in: [beekeeperId, staffId] } },
});
await app.close();
});
it('rejects create for a STAFF user', () => {
return request(app.getHttpServer())
.post('/api/v1/treatments')
.set('Authorization', `Bearer ${staffToken}`)
.send({
colonyId,
date: new Date().toISOString(),
product: 'Ameisensäure',
})
.expect(403);
});
it('lets a BEEKEEPER user record a treatment', async () => {
const response = await request(app.getHttpServer())
.post('/api/v1/treatments')
.set('Authorization', `Bearer ${beekeeperToken}`)
.send({
colonyId,
date: new Date().toISOString(),
product: 'Ameisensäure',
amount: '15 ml',
})
.expect(201);
treatmentId = (response.body as TreatmentBody).id;
expect(treatmentId).toEqual(expect.any(String));
});
it('wrote the audit entry and TreatmentRecorded together with the treatment', async () => {
expect(
await prisma.auditLog.count({
where: {
entityType: 'Treatment',
entityId: treatmentId,
action: 'CREATE',
userId: beekeeperId,
},
}),
).toBe(1);
const events = await prisma.domainEvent.findMany({
where: { aggregateType: 'Treatment', aggregateId: treatmentId },
});
expect(events).toHaveLength(1);
expect(events[0]).toEqual(
expect.objectContaining({
type: 'TreatmentRecorded',
actorUserId: beekeeperId,
payload: { colonyId },
}),
);
});
it('lists treatments for the colony', async () => {
const response = await request(app.getHttpServer())
.get(`/api/v1/colonies/${colonyId}/treatments`)
.set('Authorization', `Bearer ${staffToken}`)
.expect(200);
const treatments = response.body as TreatmentBody[];
expect(treatments.some((entry) => entry.id === treatmentId)).toBe(true);
});
it('clears start and end date on null and audits the edit', async () => {
const correctionWhere = {
aggregateType: 'Treatment',
aggregateId: treatmentId,
type: 'TreatmentCorrected',
} as const;
const correctionsBefore = await prisma.domainEvent.count({
where: correctionWhere,
});
await request(app.getHttpServer())
.patch(`/api/v1/treatments/${treatmentId}`)
.set('Authorization', `Bearer ${beekeeperToken}`)
.send({ startDate: '2026-07-01', endDate: '2026-07-14' })
.expect(200);
const response = await request(app.getHttpServer())
.patch(`/api/v1/treatments/${treatmentId}`)
.set('Authorization', `Bearer ${beekeeperToken}`)
.send({ startDate: null, endDate: null })
.expect(200);
const treatment = response.body as TreatmentBody;
expect(treatment.startDate).toBeNull();
expect(treatment.endDate).toBeNull();
const auditEntry = await prisma.auditLog.findFirst({
where: {
entityType: 'Treatment',
entityId: treatmentId,
action: 'UPDATE',
userId: beekeeperId,
},
orderBy: { timestamp: 'desc' },
});
expect(auditEntry?.afterData).toEqual(
expect.objectContaining({ startDate: null, endDate: null }),
);
// Two edits in this test, one event each.
const corrections = await prisma.domainEvent.findMany({
where: correctionWhere,
});
expect(corrections).toHaveLength(correctionsBefore + 2);
expect(
corrections.every(
(event) =>
(event.payload as { colonyId: string }).colonyId === colonyId,
),
).toBe(true);
});
it.each(['date', 'product'])('rejects null for the required %s', (field) =>
request(app.getHttpServer())
.patch(`/api/v1/treatments/${treatmentId}`)
.set('Authorization', `Bearer ${beekeeperToken}`)
.send({ [field]: null })
.expect(400),
);
describe('withdrawal status for a harvest date', () => {
async function withdrawalStatusAt(at?: string) {
const response = await request(app.getHttpServer())
.get('/api/v1/treatments/withdrawal-status')
.query(
at === undefined
? { colonyIds: colonyId }
: { colonyIds: colonyId, at },
)
.set('Authorization', `Bearer ${staffToken}`)
.expect(200);
return response.body as WithdrawalStatusBody[];
}
beforeAll(async () => {
// Ended 2026-07-01 with 14 days withdrawal: harvestable from 2026-07-15.
await request(app.getHttpServer())
.post('/api/v1/treatments')
.set('Authorization', `Bearer ${beekeeperToken}`)
.send({
colonyId,
date: '2026-06-20',
endDate: '2026-07-01',
product: 'Oxalsäure',
withdrawalPeriodDays: 14,
})
.expect(201);
});
it('reports the window as active on a harvest date inside it', async () => {
await expect(withdrawalStatusAt('2026-07-10')).resolves.toEqual([
{
colonyId,
activeUntil: '2026-07-15T00:00:00.000Z',
lastBlockedDay: '2026-07-14',
withdrawalText:
'bis einschließlich 14.07. – ab 15.07. wieder erntefähig',
product: 'Oxalsäure',
},
]);
});
it('accepts a full ISO timestamp', async () => {
await expect(
withdrawalStatusAt('2026-07-14T23:59:59+02:00'),
).resolves.toHaveLength(1);
});
it('reports nothing once the window has ended by the harvest date', async () => {
await expect(withdrawalStatusAt('2026-07-15')).resolves.toEqual([]);
});
it('ignores a treatment that starts after the harvest date', async () => {
await expect(withdrawalStatusAt('2026-06-01')).resolves.toEqual([]);
});
it('evaluates against now without a date', async () => {
await expect(withdrawalStatusAt()).resolves.toEqual([]);
});
it('rejects asking for more than 200 colonies at once', async () => {
const ids = Array.from(
{ length: 201 },
(_, index) => `cl${index.toString().padStart(23, '0')}`,
);
const response = await request(app.getHttpServer())
.get('/api/v1/treatments/withdrawal-status')
.query({ colonyIds: ids.join(',') })
.set('Authorization', `Bearer ${staffToken}`)
.expect(400);
expect(JSON.stringify(response.body)).toContain(
'colonyIds darf höchstens 200 Volk-IDs enthalten',
);
});
it.each(["colony'a", 'colony;drop', 'c'.repeat(65)])(
'rejects the invalid colony id %s',
(badId) =>
request(app.getHttpServer())
.get('/api/v1/treatments/withdrawal-status')
.query({ colonyIds: `${colonyId},${badId}` })
.set('Authorization', `Bearer ${staffToken}`)
.expect(400),
);
it.each(['gestern', '2026-02-30', '20260715', '2026-07-15T10Z', '2026'])(
'rejects the invalid date %s',
(at) =>
request(app.getHttpServer())
.get('/api/v1/treatments/withdrawal-status')
.query({ colonyIds: colonyId, at })
.set('Authorization', `Bearer ${staffToken}`)
.expect(400),
);
});
describe('GET /treatments/last-by-product', () => {
const productName = `Oxuvar-${Date.now()}`;
const lastByProduct = (token: string, product?: string) =>
request(app.getHttpServer())
.get('/api/v1/treatments/last-by-product')
.query(product === undefined ? {} : { product })
.set('Authorization', `Bearer ${token}`);
beforeAll(async () => {
await prisma.treatment.createMany({
data: [
{
colonyId,
userId: beekeeperId,
date: new Date('2026-05-01T00:00:00.000Z'),
product: productName,
supplier: 'Alt GmbH',
},
{
colonyId,
userId: beekeeperId,
date: new Date('2026-06-01T00:00:00.000Z'),
product: productName,
activeIngredient: 'Oxalsäure',
withdrawalPeriodDays: 0,
supplier: 'Neu GmbH',
purchaseProof: 'RE-2026-17',
prescribingVet: 'Dr. Imker',
},
{
colonyId,
userId: staffId,
date: new Date('2026-07-01T00:00:00.000Z'),
product: productName,
supplier: 'Fremd GmbH',
},
],
});
});
it("returns the caller's newest treatment with that product, case-insensitive", async () => {
const response = await lastByProduct(
beekeeperToken,
` ${productName.toLowerCase()} `,
).expect(200);
const newest = await prisma.treatment.findFirstOrThrow({
where: {
userId: beekeeperId,
product: productName,
supplier: 'Neu GmbH',
},
select: { id: true },
});
expect(response.body).toEqual({
treatment: {
sourceTreatmentId: newest.id,
sourceDate: '2026-06-01T00:00:00.000Z',
product: productName,
activeIngredient: 'Oxalsäure',
withdrawalPeriodDays: 0,
supplier: 'Neu GmbH',
purchaseProof: 'RE-2026-17',
prescribingVet: 'Dr. Imker',
},
});
});
it("never returns another user's treatment", async () => {
const response = await lastByProduct(beekeeperToken, productName).expect(
200,
);
expect(JSON.stringify(response.body)).not.toContain('Fremd GmbH');
});
it('returns { treatment: null } without a match', async () => {
const response = await lastByProduct(
beekeeperToken,
`Unbekannt-${Date.now()}`,
).expect(200);
expect(response.body).toEqual({ treatment: null });
});
it('rejects a request without product', () =>
lastByProduct(beekeeperToken).expect(400));
it('rejects an empty product', () =>
lastByProduct(beekeeperToken, ' ').expect(400));
it('rejects a role without treatments:create', () =>
lastByProduct(staffToken, productName).expect(403));
});
describe('POST /treatments/bulk', () => {
const suffix = Date.now();
const shared = {
date: '2026-08-15',
product: 'Oxalsäure',
batchNumber: 'CH-1',
withdrawalPeriodDays: 0,
};
let secondApiaryId: string;
// Two colonies at the first apiary, one at the second.
let bulkColonyIds: string[];
let endedColonyId: string;
const postBulk = (token: string, body: object, idempotencyKey?: string) => {
const call = request(app.getHttpServer())
.post('/api/v1/treatments/bulk')
.set('Authorization', `Bearer ${token}`);
return (
idempotencyKey === undefined
? call
: call.set('Idempotency-Key', idempotencyKey)
).send(body);
};
const storedTreatments = () =>
prisma.treatment.count({
where: { colonyId: { in: [...bulkColonyIds, endedColonyId] } },
});
beforeAll(async () => {
const secondApiary = await prisma.apiary.create({
data: { name: `Stand B ${suffix}` },
});
secondApiaryId = secondApiary.id;
const colonyAt = async (
code: string,
atApiaryId: string,
status: 'ACTIVE' | 'WEAK' | 'DISSOLVED' = 'ACTIVE',
) =>
(
await prisma.colony.create({
data: {
code: `${code}-${suffix}`,
apiaryId: atApiaryId,
colonyType: 'PRODUCTION_COLONY',
status,
},
})
).id;
bulkColonyIds = [
await colonyAt('TB-1', apiaryId),
await colonyAt('TB-2', apiaryId, 'WEAK'),
await colonyAt('TB-3', secondApiaryId),
];
endedColonyId = await colonyAt('TB-X', apiaryId, 'DISSOLVED');
});
afterAll(async () => {
const colonyIds = [...bulkColonyIds, endedColonyId];
const treatmentIds = (
await prisma.treatment.findMany({
where: { colonyId: { in: colonyIds } },
select: { id: true },
})
).map((treatment) => treatment.id);
await prisma.domainEvent.deleteMany({
where: {
aggregateType: 'Treatment',
aggregateId: { in: treatmentIds },
},
});
await prisma.treatment.deleteMany({
where: { colonyId: { in: colonyIds } },
});
await prisma.suggestion.deleteMany({
where: { colonyId: { in: colonyIds } },
});
await prisma.colony.deleteMany({ where: { id: { in: colonyIds } } });
await prisma.apiary.deleteMany({ where: { id: secondApiaryId } });
await prisma.idempotencyRecord.deleteMany({
where: { userId: beekeeperId },
});
});
it("bulk creates one treatment per colony with the shared fields and each colony's apiary", async () => {
const response = await postBulk(beekeeperToken, {
...shared,
colonyIds: bulkColonyIds,
}).expect(201);
const body = response.body as BulkTreatmentBody;
expect(body.bulkId).toEqual(expect.any(String));
expect(body.records).toHaveLength(3);
expect(body.records.map((record) => record.colonyId)).toEqual(
bulkColonyIds,
);
expect(
body.records.every((record) => record.batchNumber === 'CH-1'),
).toBe(true);
expect(body.records.map((record) => record.apiaryId)).toEqual([
apiaryId,
apiaryId,
secondApiaryId,
]);
});
it('bulk writes one TreatmentRecorded event and one audit entry per record', async () => {
const response = await postBulk(beekeeperToken, {
...shared,
colonyIds: bulkColonyIds,
}).expect(201);
const { bulkId, records } = response.body as BulkTreatmentBody;
const ofThisBulk = {
type: 'TreatmentRecorded',
payload: { path: ['bulkId'], equals: bulkId },
};
expect(await prisma.domainEvent.count({ where: ofThisBulk })).toBe(3);
const events = await prisma.domainEvent.findMany({ where: ofThisBulk });
expect(
events
.map((event) => ({
aggregateId: event.aggregateId,
actorUserId: event.actorUserId,
payload: event.payload,
}))
.sort((a, b) => a.aggregateId.localeCompare(b.aggregateId)),
).toEqual(
records
.map((record) => ({
aggregateId: record.id,
actorUserId: beekeeperId,
payload: { colonyId: record.colonyId, bulkId },
}))
.sort((a, b) => a.aggregateId.localeCompare(b.aggregateId)),
);
expect(
await prisma.auditLog.count({
where: {
entityType: 'Treatment',
action: 'CREATE',
userId: beekeeperId,
entityId: { in: records.map((record) => record.id) },
},
}),
).toBe(3);
});
it('bulk rejects the whole batch when one colony has ended and names it', async () => {
const before = await storedTreatments();
const response = await postBulk(beekeeperToken, {
...shared,
colonyIds: [...bulkColonyIds, endedColonyId],
}).expect(400);
expect(
(response.body as { invalidColonyIds: string[] }).invalidColonyIds,
).toEqual([endedColonyId]);
expect(await storedTreatments()).toBe(before);
});
it.each([
[
'an unknown id',
() => [bulkColonyIds[0], `unknown-${suffix}`],
'Einige Völker sind nicht (mehr) erfassbar',
],
['no id', () => [], 'colonyIds braucht mindestens ein Volk'],
[
'101 ids',
() => [
...bulkColonyIds,
...Array.from({ length: 98 }, (_, index) => `cl-${suffix}-${index}`),
],
'colonyIds darf höchstens 100 Völker enthalten',
],
[
'a duplicate id',
() => [bulkColonyIds[0], bulkColonyIds[0]],
'colonyIds enthält ein Volk doppelt',
],
[
'an empty id',
() => [bulkColonyIds[0], ''],
'colonyIds enthält eine leere Volk-ID',
],
])(
'bulk rejects unknown ids, 0 or 101 ids and duplicates: %s',
async (_label, colonyIds, message) => {
const before = await storedTreatments();
const response = await postBulk(beekeeperToken, {
...shared,
colonyIds: colonyIds(),
}).expect(400);
expect(JSON.stringify(response.body)).toContain(message);
expect(await storedTreatments()).toBe(before);
},
);
it('bulk names an unknown id like an ended colony', async () => {
const unknownId = `unknown-${suffix}`;
const response = await postBulk(beekeeperToken, {
...shared,
colonyIds: [bulkColonyIds[0], unknownId],
}).expect(400);
expect(
(response.body as { invalidColonyIds: string[] }).invalidColonyIds,
).toEqual([unknownId]);
});
it('bulk replays with the same Idempotency-Key', async () => {
const key = `treatments-bulk-${suffix}`;
const body = { ...shared, colonyIds: bulkColonyIds };
const before = await storedTreatments();
const first = await postBulk(beekeeperToken, body, key).expect(201);
const replay = await postBulk(beekeeperToken, body, key).expect(201);
expect((replay.body as BulkTreatmentBody).bulkId).toBe(
(first.body as BulkTreatmentBody).bulkId,
);
expect(await storedTreatments()).toBe(before + bulkColonyIds.length);
});
it('bulk rejects a role without treatments:create', () =>
postBulk(staffToken, { ...shared, colonyIds: bulkColonyIds }).expect(
403,
));
});
});