fix: Paket 0 – data integrity, security hardening and field usability
Backend Image / build-and-push (push) Successful in 3m15s
Frontend Image / build-and-push (push) Successful in 6m2s

Backend
- Backup covers every Prisma model (bundle v3); a guard test fails for any
  new model that is neither backed up nor explicitly excluded. Unsealed
  bundles may only add rows to 27 legacy master/field tables and never
  overwrite; counters, ledgers, settings, roles etc. need a sealed file.
  Shared import limit BACKUP_MAX_IMPORT_BYTES (default 50 MB, max 256 MB)
  with warnings in log and UI; replaced document files are removed after
  commit.
- Post-commit side effects (automation, calendar sync, webhooks) run via
  runAfterCommit and never turn a committed write into a 500, so retries
  with the same Idempotency-Key no longer create duplicates.
- Calendar outbox is multi-replica safe: per-entry claims with stale
  takeover, revision-based settle, new Graph event only on 404/410,
  transactionId on create, 30 s Graph timeout, URL-encoded ids, GUID
  validation for groupId, "run now" in background and resets FAILED.
- Automation tasks are deduplicated under a row lock on the rule.
- Update DTOs follow one null rule: omitted = unchanged, null clears a
  nullable field, null on a required field is a 400.
- Varroa mites/day is recomputed on correction; withdrawal status is
  checked against the harvest date (?at=), bounded colonyIds; inspection
  create is audited; updates re-enqueue calendar entries.
- Dashboard, statistics and apiary/colony guards treat WEAK, CONSPICUOUS
  and QUEENLESS colonies as living.
- Prisma errors and body-parser errors are logged without query values or
  request bodies.
- Migration 20261012100000_calendar_sync_claims; schema index name aligned
  with the existing database object.

Frontend
- Sticky save bar sticks again (no scroll container ancestors, card uses
  overflow-clip), safe-area insets, scroll-padding, FAB hidden on forms,
  sticky desktop sidebar.
- Inspections record yes / no / not checked instead of silent defaults.
- All living colonies are selectable; shared RecordSelect, EnumSelect,
  ChoiceGroup and TriStateField replace per-page copies; selects show
  names instead of ids; labels point at real controls.
- Emptied optional fields are sent as null (emptyToNull) so every field
  can be cleared; dates default to the local day and are sent via
  toApiDate.
- Harvest withdrawal warning uses the harvest date; backup page warns when
  a backup outgrows the import limit and lists skipped tables.

Build and docs
- Docker builds fail unless lint, typecheck and unit tests pass.
- ARCHITECTURE.md updated (assumptions 9-12, 19-21, section 8) and spec
  docs/superpowers/specs/2026-10-12-plattform-rueckgrat-und-fahrplan-design.md
  for the backbone (api/worker/job roles, domain event outbox) and the
  feature roadmap T3-T7.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
This commit is contained in:
Nick SzaunigandClaude Opus 5.5 committed 2026-10-12 16:38:55 +02:00
1 parent f5b1964223
commit 10cbe9bf2d
310 files changed
+12177 -3746

No files matched your search

+19
View File
@@ -127,4 +127,23 @@ describe('Apiaries (e2e)', () => {
.set('Authorization', `Bearer ${beekeeperToken}`)
.expect(409);
});
it('refuses to deactivate an apiary that still holds a weak colony', async () => {
const weakColony = await prisma.colony.create({
data: {
code: `W-${Date.now()}`,
apiaryId,
colonyType: 'PRODUCTION_COLONY',
status: 'WEAK',
},
});
try {
await request(app.getHttpServer())
.post(`/api/v1/apiaries/${apiaryId}/deactivate`)
.set('Authorization', `Bearer ${beekeeperToken}`)
.expect(409);
} finally {
await prisma.colony.delete({ where: { id: weakColony.id } });
}
});
});
+32 -1
View File
@@ -3,7 +3,7 @@ import * as bcrypt from 'bcryptjs';
import request from 'supertest';
import { App } from 'supertest/types';
import { PrismaService } from '../src/prisma/prisma.service';
import { AutomationTriggerType } from '../generated/prisma';
import { AutomationTriggerType, Prisma } from '../generated/prisma';
import { createTestApp } from './utils/create-test-app';
import { login } from './utils/api';
@@ -212,6 +212,37 @@ describe('Automation rules (e2e)', () => {
);
});
it('lets an admin clear a rule config, which falls back to its defaults', async () => {
const before = await prisma.automationRule.findUniqueOrThrow({
where: { id: varroaRuleId },
});
try {
const response = await request(app.getHttpServer())
.patch(`/api/v1/automation-rules/${varroaRuleId}`)
.set('Authorization', `Bearer ${adminToken}`)
.send({ config: null })
.expect(200);
expect((response.body as { config: unknown }).config).toBeNull();
await request(app.getHttpServer())
.patch(`/api/v1/automation-rules/${varroaRuleId}`)
.set('Authorization', `Bearer ${adminToken}`)
.send({ active: null })
.expect(400);
} finally {
// The rule is shared by every suite - put its config back.
await prisma.automationRule.update({
where: { id: varroaRuleId },
data: {
config:
before.config === null
? Prisma.DbNull
: (before.config as Prisma.InputJsonValue),
},
});
}
});
it('lets an admin deactivate a rule, preventing further triggers', async () => {
await request(app.getHttpServer())
.patch(`/api/v1/automation-rules/${varroaRuleId}`)
@@ -0,0 +1,75 @@
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 ScheduleBody {
importLimitBytes: number;
lastBackupExceedsImportLimit: boolean;
}
const IMPORT_LIMIT_BYTES = 2048;
/**
* BACKUP_MAX_IMPORT_BYTES is the one limit for both sides: the upload of
* POST /backup/import is cut off at it, and GET /backup/schedule reports it
* so the UI can tell when a backup has outgrown it.
*/
describe('Backup import limit (e2e)', () => {
let app: INestApplication<App>;
const originalLimit = process.env.BACKUP_MAX_IMPORT_BYTES;
const suffix = `${Date.now()}-${Math.round(Math.random() * 1e6)}`;
const password = 'SuperSecret123!';
let token: string;
beforeAll(async () => {
process.env.BACKUP_MAX_IMPORT_BYTES = String(IMPORT_LIMIT_BYTES);
app = await createTestApp();
await app.get(PrismaService).user.create({
data: {
email: `backup-limit-${suffix}@example.com`,
passwordHash: await bcrypt.hash(password, 12),
name: 'Backup Admin',
role: { connect: { key: 'ADMIN' } },
},
});
token = (await login(app, `backup-limit-${suffix}@example.com`, password))
.accessToken;
});
afterAll(async () => {
await app.close();
if (originalLimit === undefined) {
delete process.env.BACKUP_MAX_IMPORT_BYTES;
} else {
process.env.BACKUP_MAX_IMPORT_BYTES = originalLimit;
}
});
it('reports the configured import limit in the schedule status', async () => {
const response = await request(app.getHttpServer())
.get('/api/v1/backup/schedule')
.set('Authorization', `Bearer ${token}`)
.expect(200);
expect((response.body as ScheduleBody).importLimitBytes).toBe(
IMPORT_LIMIT_BYTES,
);
});
it('refuses an upload larger than the import limit before reading it in', async () => {
const tooLarge = Buffer.from(
JSON.stringify({ padding: 'x'.repeat(IMPORT_LIMIT_BYTES) }),
);
await request(app.getHttpServer())
.post('/api/v1/backup/import')
.set('Authorization', `Bearer ${token}`)
.field('password', password)
.attach('file', tooLarge, 'apicore-backup.json')
.expect(413);
});
});
+441
View File
@@ -0,0 +1,441 @@
import { INestApplication } from '@nestjs/common';
import * as fs from 'node:fs/promises';
import * as path from 'node:path';
import { App } from 'supertest/types';
import { PrismaService } from '../src/prisma/prisma.service';
import { AutomationService } from '../src/automation/automation.service';
import { BackupBundle, BackupService } from '../src/backup/backup.service';
import { createTestApp } from './utils/create-test-app';
type Row = Record<string, unknown>;
// Fixtures plus whole-database exports of the shared test database - well
// beyond the 5 s default when other specs run alongside.
const ROUND_TRIP_TIMEOUT_MS = 60_000;
// Wipe order: every row before the rows it references.
const SECTIONS_CHILD_FIRST = [
'batchMovement',
'productionInput',
'shopStockRequest',
'productionBatch',
'rawMaterialLot',
'bottlingBatch',
'honeyBatchContainerAllocation',
'honeyBatch',
'storageContainer',
'extraction',
'harvest',
'apiary',
'product',
'automationSequence',
'calendarSyncOutboxEntry',
] as const;
type Section = (typeof SECTIONS_CHILD_FIRST)[number];
// AutomationSequence has no id of its own.
function identity(row: Row): string {
return typeof row.id === 'string'
? row.id
: `${String(row.triggerType)}|${String(row.scope)}`;
}
/**
* Export -> loss -> import against the real database: production, raw
* material, stock ledger, number sequence and calendar-sync rows come back
* exactly as exported, which also proves the import writes every parent
* before the rows that reference it. Only this spec's own rows are wiped and
* re-imported, so specs running alongside never see their data rewritten.
*/
describe('Backup round trip (e2e)', () => {
let app: INestApplication<App>;
let prisma: PrismaService;
let backup: BackupService;
const suffix = `${Date.now()}-${Math.round(Math.random() * 1e6)}`;
const own: Record<Section, Set<string>> = Object.fromEntries(
SECTIONS_CHILD_FIRST.map((section) => [section, new Set<string>()]),
) as Record<Section, Set<string>>;
let userId: string;
let exported: Record<Section, Row[]>;
const remember = <T extends { id: string }>(section: Section, row: T): T => {
own[section].add(row.id);
return row;
};
function ownRows(bundle: BackupBundle): Record<Section, Row[]> {
return Object.fromEntries(
SECTIONS_CHILD_FIRST.map((section) => [
section,
((bundle.data[section] ?? []) as Row[]).filter((row) =>
own[section].has(identity(row)),
),
]),
) as Record<Section, Row[]>;
}
beforeAll(async () => {
app = await createTestApp();
prisma = app.get(PrismaService);
backup = app.get(BackupService);
// Makes sure the number-format rules exist (the sequence references one).
await app.get(AutomationService).findAll();
const user = await prisma.user.create({
data: {
email: `backup-${suffix}@example.com`,
passwordHash: 'not-used-for-login',
name: 'Backup',
role: { connect: { key: 'BEEKEEPER' } },
},
});
userId = user.id;
const day = new Date('2026-07-01T00:00:00.000Z');
const apiary = remember(
'apiary',
await prisma.apiary.create({ data: { name: `Stand ${suffix}` } }),
);
const harvest = remember(
'harvest',
await prisma.harvest.create({
data: { apiaryId: apiary.id, userId, date: day },
}),
);
const extraction = remember(
'extraction',
await prisma.extraction.create({ data: { date: day, userId } }),
);
const container = remember(
'storageContainer',
await prisma.storageContainer.create({ data: { code: `E-${suffix}` } }),
);
const honeyBatch = remember(
'honeyBatch',
await prisma.honeyBatch.create({
data: { batchNumber: `H-${suffix}`, extractionId: extraction.id },
}),
);
remember(
'honeyBatchContainerAllocation',
await prisma.honeyBatchContainerAllocation.create({
data: {
honeyBatchId: honeyBatch.id,
storageContainerId: container.id,
amount: 25,
},
}),
);
const honey = remember(
'product',
await prisma.product.create({ data: { name: `Honig ${suffix}` } }),
);
const candle = remember(
'product',
await prisma.product.create({
data: {
name: `Kerze ${suffix}`,
kind: 'MANUFACTURED',
shopCategory: 'KERZEN',
},
}),
);
const bottling = remember(
'bottlingBatch',
await prisma.bottlingBatch.create({
data: {
honeyBatchId: honeyBatch.id,
productId: honey.id,
date: day,
jarSize: 500,
jarCount: 10,
totalAmount: 5,
bestBeforeDate: new Date('2028-07-01T00:00:00.000Z'),
lotNumber: `L-${suffix}`,
userId,
},
}),
);
const wax = remember(
'rawMaterialLot',
await prisma.rawMaterialLot.create({
data: {
kind: 'BEESWAX',
lotNumber: `W-${suffix}`,
date: day,
quantity: 1000,
apiaryId: apiary.id,
harvestId: harvest.id,
userId,
},
}),
);
const production = remember(
'productionBatch',
await prisma.productionBatch.create({
data: {
productId: candle.id,
lotNumber: `K-${suffix}`,
date: day,
quantity: 12,
userId,
},
}),
);
remember(
'productionInput',
await prisma.productionInput.create({
data: {
productionBatchId: production.id,
rawMaterialLotId: wax.id,
quantity: 300,
},
}),
);
const shopRequest = remember(
'shopStockRequest',
await prisma.shopStockRequest.create({
data: {
externalId: `order-${suffix}`,
kind: 'SALE',
bottlingBatchId: bottling.id,
quantity: 2,
apiKeyId: 'key-not-in-backup',
},
}),
);
for (const data of [
{
bottlingBatchId: bottling.id,
type: 'SHOP_SALE' as const,
quantity: -2,
shopRequestId: shopRequest.id,
apiKeyId: 'key-not-in-backup',
},
{
rawMaterialLotId: wax.id,
type: 'CONSUMPTION' as const,
quantity: -300,
userId,
},
{
productionBatchId: production.id,
type: 'WITHDRAWAL' as const,
quantity: -1,
reason: 'EIGENBEDARF' as const,
userId,
},
]) {
remember('batchMovement', await prisma.batchMovement.create({ data }));
}
const sequence = await prisma.automationSequence.create({
data: {
triggerType: 'RAW_MATERIAL_LOT_NUMBER_FORMAT',
scope: `backup-${suffix}`,
value: 41,
},
});
own.automationSequence.add(identity(sequence));
remember(
'calendarSyncOutboxEntry',
await prisma.calendarSyncOutboxEntry.create({
data: {
sourceType: 'TASK',
sourceId: `backup-${suffix}`,
status: 'SYNCED',
graphEventId: `graph-${suffix}`,
},
}),
);
}, ROUND_TRIP_TIMEOUT_MS);
afterAll(async () => {
await app.close();
});
it(
'exports the production, raw material and stock ledger tables',
async () => {
exported = ownRows(await backup.exportAll());
for (const section of SECTIONS_CHILD_FIRST) {
expect({ section, count: exported[section].length }).toEqual({
section,
count: own[section].size,
});
}
},
ROUND_TRIP_TIMEOUT_MS,
);
it(
'restores every one of those rows exactly after they were lost',
async () => {
for (const section of SECTIONS_CHILD_FIRST) {
if (section === 'automationSequence') {
await prisma.automationSequence.deleteMany({
where: { scope: `backup-${suffix}` },
});
continue;
}
const delegate = prisma[section] as unknown as {
deleteMany(args: {
where: { id: { in: string[] } };
}): Promise<unknown>;
};
await delegate.deleteMany({ where: { id: { in: [...own[section]] } } });
}
expect(
await prisma.batchMovement.count({
where: { id: { in: [...own.batchMovement] } },
}),
).toBe(0);
await backup.importAll(
{
version: 3,
exportedAt: new Date().toISOString(),
data: exported,
},
userId,
{ trusted: true },
);
const where = (section: Section) => ({
where: { id: { in: [...own[section]] } },
});
expect({
productionBatch: await prisma.productionBatch.count(
where('productionBatch'),
),
rawMaterialLot: await prisma.rawMaterialLot.count(
where('rawMaterialLot'),
),
productionInput: await prisma.productionInput.count(
where('productionInput'),
),
shopStockRequest: await prisma.shopStockRequest.count(
where('shopStockRequest'),
),
batchMovement: await prisma.batchMovement.count(where('batchMovement')),
honeyBatchContainerAllocation:
await prisma.honeyBatchContainerAllocation.count(
where('honeyBatchContainerAllocation'),
),
automationSequence: await prisma.automationSequence.count({
where: { scope: `backup-${suffix}` },
}),
calendarSyncOutboxEntry: await prisma.calendarSyncOutboxEntry.count(
where('calendarSyncOutboxEntry'),
),
}).toEqual({
productionBatch: 1,
rawMaterialLot: 1,
productionInput: 1,
shopStockRequest: 1,
batchMovement: 3,
honeyBatchContainerAllocation: 1,
automationSequence: 1,
calendarSyncOutboxEntry: 1,
});
expect(ownRows(await backup.exportAll())).toEqual(exported);
},
ROUND_TRIP_TIMEOUT_MS,
);
it('adds rows from an unsealed file but never rewrites existing ones or takes ledger entries', async () => {
const existing = await prisma.apiary.create({
data: { name: `Bestand ${suffix}` },
});
const addedId = `apiary-added-${suffix}`;
const forgedId = `forged-movement-${suffix}`;
const summary = await backup.importFile(
{
version: 2,
exportedAt: '2026-01-01T00:00:00.000Z',
data: {
apiary: [
{ id: existing.id, name: 'Überschrieben' },
{ id: addedId, name: `Ergänzt ${suffix}` },
],
batchMovement: [
{
id: forgedId,
productionBatchId: [...own.productionBatch][0],
type: 'CORRECTION',
quantity: 1000,
},
],
},
},
userId,
);
expect(summary).toEqual(
expect.objectContaining({ sealed: false, skipped: ['batchMovement'] }),
);
expect(
(await prisma.apiary.findUniqueOrThrow({ where: { id: existing.id } }))
.name,
).toBe(`Bestand ${suffix}`);
expect(
(await prisma.apiary.findUniqueOrThrow({ where: { id: addedId } })).name,
).toBe(`Ergänzt ${suffix}`);
expect(
await prisma.batchMovement.findUnique({ where: { id: forgedId } }),
).toBeNull();
});
it('removes the file a restored document row replaced', async () => {
const directory = path.join(
path.resolve(process.env.DOCUMENTS_STORAGE_PATH ?? './uploads'),
'colony',
);
await fs.mkdir(directory, { recursive: true });
const oldFile = path.join(directory, `backup-e2e-${suffix}.pdf`);
await fs.writeFile(oldFile, '%PDF-1.4 old');
const document = await prisma.document.create({
data: {
entityType: 'COLONY',
entityId: `colony-${suffix}`,
fileName: 'alt.pdf',
mimeType: 'application/pdf',
sizeBytes: 12,
storagePath: oldFile,
},
});
await backup.importAll(
{
version: 3,
exportedAt: new Date().toISOString(),
data: {
document: [
{
id: document.id,
entityType: 'COLONY',
entityId: `colony-${suffix}`,
fileName: 'neu.pdf',
mimeType: 'application/pdf',
sizeBytes: 12,
contentBase64: Buffer.from('%PDF-1.4 new').toString('base64'),
},
],
},
},
userId,
{ trusted: true },
);
const restored = await prisma.document.findUniqueOrThrow({
where: { id: document.id },
});
expect(restored.storagePath).not.toBe(oldFile);
await expect(fs.readFile(restored.storagePath, 'utf8')).resolves.toBe(
'%PDF-1.4 new',
);
await expect(fs.access(oldFile)).rejects.toThrow();
await fs.unlink(restored.storagePath);
});
});
@@ -0,0 +1,291 @@
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 { CalendarSyncService } from '../src/calendar-sync/calendar-sync.service';
import { CalendarSyncSettingsService } from '../src/calendar-sync/calendar-sync-settings.service';
import { GraphClientService } from '../src/calendar-sync/graph-client.service';
import { createTestApp } from './utils/create-test-app';
import { login } from './utils/api';
/**
* The M365 outbox against the real database, with Microsoft Graph replaced
* by a recorder: several workers (pods, the cron and "Jetzt
* synchronisieren") must never write the same entry twice, and an edit
* that arrives while an entry is being synced must not get lost.
*/
describe('Calendar sync outbox (e2e)', () => {
let app: INestApplication<App>;
let prisma: PrismaService;
let calendarSync: CalendarSyncService;
let graph: GraphClientService;
const suffix = `${Date.now()}-${Math.round(Math.random() * 1e6)}`;
const taskIds: string[] = [];
let createdEvents: string[];
let updateEvent: jest.SpyInstance;
let eventCounter = 0;
const adminEmail = `calendar-sync-e2e-${suffix}@example.com`;
const password = 'SuperSecret123!';
let token: string;
const settings = {
id: 'singleton',
enabled: true,
groupId: 'group-e2e',
syncTask: true,
syncInspection: true,
syncTreatment: true,
syncFeeding: true,
syncHarvest: true,
syncQueenAssignment: true,
updatedAt: new Date(),
};
const wait = (ms: number) => new Promise((done) => setTimeout(done, ms));
/** Polls until the condition holds - for work that runs in the background. */
async function eventually(condition: () => Promise<boolean> | boolean) {
for (let i = 0; i < 100; i++) {
if (await condition()) return;
await wait(50);
}
throw new Error('condition not met in time');
}
async function queueTask(title: string) {
const task = await prisma.task.create({
data: { title: `${title} ${suffix}`, dueDate: new Date('2027-05-01') },
});
taskIds.push(task.id);
await prisma.calendarSyncOutboxEntry.create({
data: { sourceType: 'TASK', sourceId: task.id },
});
return task;
}
const entryFor = (taskId: string) =>
prisma.calendarSyncOutboxEntry.findUniqueOrThrow({
where: { sourceType_sourceId: { sourceType: 'TASK', sourceId: taskId } },
});
/** Graph calls made for the tasks of this spec (not other leftovers). */
const createsFor = (title: string) =>
createdEvents.filter((created) => created === `${title} ${suffix}`);
beforeAll(async () => {
app = await createTestApp();
prisma = app.get(PrismaService);
calendarSync = app.get(CalendarSyncService);
graph = app.get(GraphClientService);
await prisma.user.create({
data: {
email: adminEmail,
passwordHash: await bcrypt.hash(password, 12),
name: 'Calendar Sync Admin',
role: { connect: { key: 'ADMIN' } },
},
});
token = (await login(app, adminEmail, password)).accessToken;
});
beforeEach(() => {
createdEvents = [];
jest
.spyOn(app.get(CalendarSyncSettingsService), 'get')
.mockResolvedValue(settings);
jest.spyOn(graph, 'isConfigured').mockReturnValue(true);
// A real Graph round trip takes a moment - long enough for a second
// worker to read the same row in the meantime.
jest.spyOn(graph, 'createEvent').mockImplementation(async (_g, input) => {
createdEvents.push(input.title);
await wait(50);
eventCounter += 1;
return `event-${suffix}-${eventCounter}`;
});
updateEvent = jest.spyOn(graph, 'updateEvent').mockResolvedValue(undefined);
jest.spyOn(graph, 'deleteEvent').mockResolvedValue(undefined);
});
afterEach(() => {
jest.restoreAllMocks();
});
afterAll(async () => {
await prisma.calendarSyncOutboxEntry.deleteMany({
where: { sourceType: 'TASK', sourceId: { in: taskIds } },
});
await prisma.task.deleteMany({ where: { id: { in: taskIds } } });
const admin = await prisma.user.findUnique({
where: { email: adminEmail },
});
if (admin) {
await prisma.refreshToken.deleteMany({ where: { userId: admin.id } });
await prisma.auditLog.deleteMany({ where: { userId: admin.id } });
await prisma.user.delete({ where: { id: admin.id } });
}
await app.close();
});
it('creates each event once when several workers run at the same time', async () => {
const titles = ['Parallel A', 'Parallel B', 'Parallel C'];
const tasks: { id: string }[] = [];
for (const title of titles) {
tasks.push(await queueTask(title));
}
// Two pods' cron ticks plus a click on "Jetzt synchronisieren".
await Promise.all([
calendarSync.processOutbox(),
calendarSync.processOutbox(),
calendarSync.runNow(),
]);
for (const title of titles) {
expect(createsFor(title)).toHaveLength(1);
}
for (const task of tasks) {
const entry = await entryFor(task.id);
expect(entry.status).toBe('SYNCED');
expect(entry.graphEventId).toMatch(/^event-/);
expect(entry.claimedAt).toBeNull();
}
});
it('syncs an edit that arrives while the entry is being written to M365', async () => {
const task = await queueTask('Edited during sync');
jest
.spyOn(graph, 'createEvent')
.mockImplementationOnce(async (_g, input) => {
createdEvents.push(input.title);
// The task's due date is changed right now, on another request.
await calendarSync.enqueue('TASK', task.id);
return `event-${suffix}-edited`;
});
await calendarSync.processOutbox();
const afterFirstPass = await entryFor(task.id);
// Not SYNCED: the newer state still has to go out - onto the same event.
expect(afterFirstPass.status).toBe('PENDING');
expect(afterFirstPass.graphEventId).toBe(`event-${suffix}-edited`);
expect(afterFirstPass.claimedAt).toBeNull();
await calendarSync.processOutbox();
expect(createsFor('Edited during sync')).toHaveLength(1);
expect(updateEvent).toHaveBeenCalledWith(
'group-e2e',
`event-${suffix}-edited`,
expect.objectContaining({ title: `Edited during sync ${suffix}` }),
);
expect((await entryFor(task.id)).status).toBe('SYNCED');
});
it('takes over an entry whose worker died, but not one still being worked on', async () => {
const abandoned = await queueTask('Abandoned claim');
const busy = await queueTask('Busy claim');
await prisma.calendarSyncOutboxEntry.update({
where: {
sourceType_sourceId: { sourceType: 'TASK', sourceId: abandoned.id },
},
data: { claimedAt: new Date(Date.now() - 60 * 60 * 1000) },
});
await prisma.calendarSyncOutboxEntry.update({
where: { sourceType_sourceId: { sourceType: 'TASK', sourceId: busy.id } },
data: { claimedAt: new Date() },
});
await calendarSync.processOutbox();
expect(createsFor('Abandoned claim')).toHaveLength(1);
expect((await entryFor(abandoned.id)).status).toBe('SYNCED');
expect(createsFor('Busy claim')).toHaveLength(0);
expect((await entryFor(busy.id)).status).toBe('PENDING');
// The busy worker finishes - its live claim would otherwise keep
// getStatus().running true for the other tests.
await prisma.calendarSyncOutboxEntry.update({
where: { sourceType_sourceId: { sourceType: 'TASK', sourceId: busy.id } },
data: { claimedAt: null },
});
});
it('retries a FAILED entry on "Jetzt synchronisieren", not on the cron tick', async () => {
const task = await queueTask('Failed before');
await prisma.calendarSyncOutboxEntry.update({
where: { sourceType_sourceId: { sourceType: 'TASK', sourceId: task.id } },
data: { status: 'FAILED', attempts: 5, lastError: 'Graph down' },
});
await calendarSync.processOutbox();
expect(createsFor('Failed before')).toHaveLength(0);
expect((await entryFor(task.id)).status).toBe('FAILED');
await calendarSync.runNow();
expect(createsFor('Failed before')).toHaveLength(1);
const entry = await entryFor(task.id);
expect(entry.status).toBe('SYNCED');
expect(entry.attempts).toBe(0);
expect(entry.lastError).toBeNull();
});
describe('API', () => {
const auth = () => ({ Authorization: `Bearer ${token}` });
it('answers "Jetzt synchronisieren" with 202 while the pass still runs', async () => {
const task = await queueTask('Started via API');
const title = `Started via API ${suffix}`;
let releaseCreate: () => void = () => undefined;
jest.spyOn(graph, 'createEvent').mockImplementation(async (_g, input) => {
createdEvents.push(input.title);
if (input.title === title) {
await new Promise<void>((resolve) => {
releaseCreate = resolve;
});
}
eventCounter += 1;
return `event-${suffix}-${eventCounter}`;
});
const response = await request(app.getHttpServer())
.post('/api/v1/calendar/sync/run-now')
.set(auth())
.expect(202);
expect((response.body as { running: boolean }).running).toBe(true);
await eventually(() => createsFor('Started via API').length === 1);
expect((await entryFor(task.id)).status).toBe('PENDING');
const whileRunning = await request(app.getHttpServer())
.get('/api/v1/calendar/sync/status')
.set(auth())
.expect(200);
expect((whileRunning.body as { running: boolean }).running).toBe(true);
releaseCreate();
await eventually(async () => !(await calendarSync.getStatus()).running);
expect((await entryFor(task.id)).status).toBe('SYNCED');
}, 15_000);
it('rejects a group id that is not an Entra object id with 400', async () => {
const response = await request(app.getHttpServer())
.patch('/api/v1/calendar/sync/settings')
.set(auth())
.send({ groupId: '../users/[email protected]' })
.expect(400);
expect(JSON.stringify(response.body)).toContain('Gruppen-ID');
});
// PATCH null rule: the switches cannot hold null - 400, not a 500.
it('rejects null on a switch with 400', async () => {
await request(app.getHttpServer())
.patch('/api/v1/calendar/sync/settings')
.set(auth())
.send({ enabled: null })
.expect(400);
});
});
});
+17
View File
@@ -213,4 +213,21 @@ describe('Colonies (e2e)', () => {
.set('Authorization', `Bearer ${beekeeperToken}`)
.expect(409);
});
it('rejects reactivating a weak colony, which never ended', async () => {
await prisma.colony.update({
where: { id: colonyId },
data: { status: 'WEAK' },
});
await request(app.getHttpServer())
.post(`/api/v1/colonies/${colonyId}/reactivate`)
.set('Authorization', `Bearer ${beekeeperToken}`)
.expect(409);
const colony = await prisma.colony.findUniqueOrThrow({
where: { id: colonyId },
});
expect(colony.status).toBe('WEAK');
});
});
@@ -26,6 +26,7 @@ describe('Dashboard (e2e)', () => {
let readonlyToken: string;
let apiaryId: string;
let colonyId: string;
let queenId: string;
beforeAll(async () => {
app = await createTestApp();
@@ -56,10 +57,34 @@ describe('Dashboard (e2e)', () => {
},
});
colonyId = colony.id;
// The WEAK colony is still a living colony: its overdue swarm check and
// its old queen have to reach the alerts like those of an ACTIVE one.
const queen = await prisma.queen.create({
data: {
code: `Q-${Date.now()}`,
hatchYear: new Date().getFullYear() - 4,
},
});
queenId = queen.id;
await prisma.colony.update({
where: { id: colonyId },
data: { currentQueenId: queenId },
});
await prisma.swarmControlCheck.create({
data: {
colonyId,
userId: readonlyId,
date: new Date('2026-01-01'),
nextCheckDate: new Date('2026-01-08'),
},
});
});
afterAll(async () => {
await prisma.swarmControlCheck.deleteMany({ where: { colonyId } });
await prisma.colony.deleteMany({ where: { id: colonyId } });
await prisma.queen.deleteMany({ where: { id: queenId } });
await prisma.apiary.deleteMany({ where: { id: apiaryId } });
await prisma.refreshToken.deleteMany({ where: { userId: readonlyId } });
await prisma.auditLog.deleteMany({ where: { userId: readonlyId } });
@@ -89,4 +114,17 @@ describe('Dashboard (e2e)', () => {
),
).toBe(true);
});
it('raises swarm-check and old-queen alerts for a weak colony', async () => {
const response = await request(app.getHttpServer())
.get('/api/v1/dashboard/alerts')
.set('Authorization', `Bearer ${readonlyToken}`)
.expect(200);
const types = (response.body as AlertBody[])
.filter((alert) => alert.relatedId === colonyId)
.map((alert) => alert.type);
expect(types).toEqual(
expect.arrayContaining(['OVERDUE_SWARM_CHECK', 'OLD_QUEEN']),
);
});
});
+51 -3
View File
@@ -30,11 +30,15 @@ describe('Honey batches (e2e)', () => {
let prisma: PrismaService;
const beekeeperEmail = `honey-batches-e2e-beekeeper-${Date.now()}@example.com`;
const staffEmail = `honey-batches-e2e-staff-${Date.now()}@example.com`;
const adminEmail = `honey-batches-e2e-admin-${Date.now()}@example.com`;
const password = 'SuperSecret123!';
let beekeeperId: string;
let staffId: string;
let beekeeperToken: string;
let staffToken: string;
// Correcting a batch (PATCH) is an ADMIN permission.
let adminId: string;
let adminToken: string;
let apiaryId: string;
let colonyId: string;
let harvestId: string;
@@ -66,8 +70,18 @@ describe('Honey batches (e2e)', () => {
},
});
staffId = staff.id;
const admin = await prisma.user.create({
data: {
email: adminEmail,
passwordHash,
name: 'Admin',
role: { connect: { key: 'ADMIN' } },
},
});
adminId = admin.id;
beekeeperToken = (await login(app, beekeeperEmail, password)).accessToken;
staffToken = (await login(app, staffEmail, password)).accessToken;
adminToken = (await login(app, adminEmail, password)).accessToken;
const apiary = await prisma.apiary.create({
data: { name: `Stand ${Date.now()}` },
@@ -121,13 +135,13 @@ describe('Honey batches (e2e)', () => {
await prisma.colony.deleteMany({ where: { id: colonyId } });
await prisma.apiary.deleteMany({ where: { id: apiaryId } });
await prisma.refreshToken.deleteMany({
where: { userId: { in: [beekeeperId, staffId] } },
where: { userId: { in: [beekeeperId, staffId, adminId] } },
});
await prisma.auditLog.deleteMany({
where: { userId: { in: [beekeeperId, staffId] } },
where: { userId: { in: [beekeeperId, staffId, adminId] } },
});
await prisma.user.deleteMany({
where: { id: { in: [beekeeperId, staffId] } },
where: { id: { in: [beekeeperId, staffId, adminId] } },
});
await app.close();
});
@@ -207,4 +221,38 @@ describe('Honey batches (e2e)', () => {
expect(relocations[1].toStorageContainerId).toBe(containerAId);
expect(relocations[1].fromStorageContainerId).toBeNull();
});
it('removes the container on null without recording a relocation', async () => {
const response = await request(app.getHttpServer())
.patch(`/api/v1/honey-batches/${honeyBatchId}`)
.set('Authorization', `Bearer ${adminToken}`)
.send({ storageContainerId: null })
.expect(200);
expect((response.body as HoneyBatchBody).storageContainerId).toBeNull();
const relocations = await prisma.honeyBatchRelocation.count({
where: { honeyBatchId },
});
expect(relocations).toBe(2);
const auditEntry = await prisma.auditLog.findFirst({
where: {
entityType: 'HoneyBatch',
entityId: honeyBatchId,
action: 'UPDATE',
userId: adminId,
},
orderBy: { timestamp: 'desc' },
});
expect(auditEntry?.afterData).toEqual(
expect.objectContaining({ storageContainerId: null }),
);
});
it('rejects null for the required batch number', () => {
return request(app.getHttpServer())
.patch(`/api/v1/honey-batches/${honeyBatchId}`)
.set('Authorization', `Bearer ${adminToken}`)
.send({ batchNumber: null })
.expect(400);
});
});
@@ -111,6 +111,16 @@ describe('Inspections (e2e)', () => {
.expect(201);
inspectionId = (response.body as InspectionBody).id;
expect(inspectionId).toEqual(expect.any(String));
const auditEntry = await prisma.auditLog.findFirst({
where: {
entityType: 'Inspection',
entityId: inspectionId,
action: 'CREATE',
userId: staffId,
},
});
expect(auditEntry).not.toBeNull();
});
it('lists inspections for the colony', async () => {
@@ -0,0 +1,218 @@
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 { NotificationsService } from '../src/notifications/notifications.service';
import { CalendarSyncSettingsService } from '../src/calendar-sync/calendar-sync-settings.service';
import { AutomationService } from '../src/automation/automation.service';
import { createTestApp } from './utils/create-test-app';
import { login } from './utils/api';
/**
* Side effects that follow 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.
*/
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 apiaryId: string;
const server = () => app.getHttpServer();
const auth = () => ({ Authorization: `Bearer ${token}` });
async function createColony(code: string) {
return prisma.colony.create({
data: {
code: `${code}-${suffix}`,
apiaryId,
colonyType: 'PRODUCTION_COLONY',
},
});
}
beforeAll(async () => {
app = await createTestApp();
prisma = app.get(PrismaService);
await prisma.user.create({
data: {
email,
passwordHash: await bcrypt.hash(password, 12),
name: 'Post Commit',
role: { connect: { key: 'ADMIN' } },
},
});
token = (await login(app, email, password)).accessToken;
apiaryId = (
await prisma.apiary.create({ data: { name: `Post Commit ${suffix}` } })
).id;
});
afterEach(() => {
jest.restoreAllMocks();
});
afterAll(async () => {
await app.close();
});
describe('when a side effect fails', () => {
beforeEach(() => {
// Automation: the task is created, then the warning fails.
jest
.spyOn(app.get(NotificationsService), 'record')
.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 for an inspection and replays it on a retry', 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 request(server())
.post('/api/v1/inspections')
.set(auth())
.set('Idempotency-Key', key)
.send(body)
.expect(201);
const retry = await request(server())
.post('/api/v1/inspections')
.set(auth())
.set('Idempotency-Key', key)
.send(body)
.expect(201);
expect((retry.body as { id: string }).id).toBe(
(first.body as { id: string }).id,
);
expect(
await prisma.inspection.count({ where: { colonyId: colony.id } }),
).toBe(1);
// The rule still did its main job before the warning failed.
expect(
await prisma.task.count({
where: {
relatedId: colony.id,
sourceAutomationTrigger: 'QUEENLESS_NO_EGGS',
},
}),
).toBe(1);
});
it('still answers 201 for a treatment when the rule evaluation fails', async () => {
jest
.spyOn(prisma.automationRule, 'upsert')
.mockRejectedValue(new Error('automation rules down'));
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 request(server())
.post('/api/v1/treatments')
.set(auth())
.set('Idempotency-Key', key)
.send(body)
.expect(201);
const retry = await request(server())
.post('/api/v1/treatments')
.set(auth())
.set('Idempotency-Key', key)
.send(body)
.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('automation tasks', () => {
it('leaves one open task when several triggers for the same colony 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,
},
});
await Promise.all(
Array.from({ length: 8 }, () => automation.evaluateStockItem(item)),
);
expect(
await prisma.task.count({
where: {
relatedId: item.id,
sourceAutomationTrigger: 'STOCK_ITEM_LOW',
status: { in: ['OPEN', 'IN_PROGRESS'] },
},
}),
).toBe(1);
});
it('leaves one open task when several measurements for the same colony arrive at once', async () => {
const colony = await createColony('PC-RACE-HTTP');
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,
};
await Promise.all(
Array.from({ length: 6 }, () =>
request(server())
.post('/api/v1/varroa-measurements')
.set(auth())
.send(measurement)
.expect(201),
),
);
expect(
await prisma.task.count({
where: {
relatedId: colony.id,
sourceAutomationTrigger: 'VARROA_THRESHOLD',
status: { in: ['OPEN', 'IN_PROGRESS'] },
},
}),
).toBe(1);
});
});
});
@@ -10,6 +10,7 @@ interface SwarmControlCheckBody {
id: string;
colonyId: string;
queenCellsCapped: boolean;
nextCheckDate: string | null;
}
describe('Swarm control checks (e2e)', () => {
@@ -129,4 +130,33 @@ describe('Swarm control checks (e2e)', () => {
true,
);
});
it('clears the next check date on null and audits the edit', async () => {
const response = await request(app.getHttpServer())
.patch(`/api/v1/swarm-control-checks/${checkId}`)
.set('Authorization', `Bearer ${beekeeperToken}`)
.send({ nextCheckDate: null })
.expect(200);
expect((response.body as SwarmControlCheckBody).nextCheckDate).toBeNull();
const auditEntry = await prisma.auditLog.findFirst({
where: {
entityType: 'SwarmControlCheck',
entityId: checkId,
action: 'UPDATE',
userId: beekeeperId,
},
orderBy: { timestamp: 'desc' },
});
expect(auditEntry?.afterData).toEqual(
expect.objectContaining({ nextCheckDate: null }),
);
});
it('rejects null for a field that cannot be empty', () => {
return request(app.getHttpServer())
.patch(`/api/v1/swarm-control-checks/${checkId}`)
.set('Authorization', `Bearer ${beekeeperToken}`)
.send({ swarmMood: null })
.expect(400);
});
});
@@ -10,6 +10,9 @@ interface TaskTemplateBody {
id: string;
nextDueDate: string;
active: boolean;
recurrenceType: string;
recurrenceDayOfMonth: number | null;
intervalDays: number | null;
}
describe('TaskTemplates (e2e)', () => {
@@ -96,6 +99,42 @@ describe('TaskTemplates (e2e)', () => {
expect(template.nextDueDate.getTime()).toBeGreaterThan(yesterday.getTime());
});
it('rejects a recurrence emptied of a field it needs', () => {
return request(app.getHttpServer())
.patch(`/api/v1/task-templates/${templateId}`)
.set('Authorization', `Bearer ${beekeeperToken}`)
.send({ recurrenceType: 'MONTHLY', recurrenceDayOfMonth: null })
.expect(400);
});
it('clears the interval when switching to a monthly recurrence', async () => {
const response = await request(app.getHttpServer())
.patch(`/api/v1/task-templates/${templateId}`)
.set('Authorization', `Bearer ${beekeeperToken}`)
.send({
recurrenceType: 'MONTHLY',
recurrenceDayOfMonth: 5,
intervalDays: null,
})
.expect(200);
const body = response.body as TaskTemplateBody;
expect(body.recurrenceType).toBe('MONTHLY');
expect(body.recurrenceDayOfMonth).toBe(5);
expect(body.intervalDays).toBeNull();
const auditEntry = await prisma.auditLog.findFirst({
where: {
entityType: 'TaskTemplate',
entityId: templateId,
action: 'UPDATE',
userId: beekeeperId,
},
orderBy: { timestamp: 'desc' },
});
expect(auditEntry?.afterData).toEqual(
expect.objectContaining({ intervalDays: null }),
);
});
it('deactivates a template', async () => {
const response = await request(app.getHttpServer())
.post(`/api/v1/task-templates/${templateId}/deactivate`)
+36
View File
@@ -10,6 +10,7 @@ interface TaskBody {
id: string;
status: string;
completedAt: string | null;
dueDate: string | null;
}
describe('Tasks (e2e)', () => {
@@ -95,6 +96,41 @@ describe('Tasks (e2e)', () => {
expect(tasks.some((entry) => entry.id === taskId)).toBe(true);
});
it('clears the due date on null and audits the edit', async () => {
await request(app.getHttpServer())
.patch(`/api/v1/tasks/${taskId}`)
.set('Authorization', `Bearer ${staffToken}`)
.send({ dueDate: '2026-08-01T00:00:00.000Z' })
.expect(200);
const response = await request(app.getHttpServer())
.patch(`/api/v1/tasks/${taskId}`)
.set('Authorization', `Bearer ${staffToken}`)
.send({ dueDate: null })
.expect(200);
expect((response.body as TaskBody).dueDate).toBeNull();
const auditEntry = await prisma.auditLog.findFirst({
where: {
entityType: 'Task',
entityId: taskId,
action: 'UPDATE',
userId: staffId,
},
orderBy: { timestamp: 'desc' },
});
expect(auditEntry?.afterData).toEqual(
expect.objectContaining({ dueDate: null }),
);
});
it('rejects null for the required title', () => {
return request(app.getHttpServer())
.patch(`/api/v1/tasks/${taskId}`)
.set('Authorization', `Bearer ${staffToken}`)
.send({ title: null })
.expect(400);
});
it('completes a task', async () => {
const response = await request(app.getHttpServer())
.post(`/api/v1/tasks/${taskId}/complete`)
+138
View File
@@ -9,6 +9,14 @@ import { login } from './utils/api';
interface TreatmentBody {
id: string;
colonyId: string;
startDate: string | null;
endDate: string | null;
}
interface WithdrawalStatusBody {
colonyId: string;
activeUntil: string;
product: string;
}
describe('Treatments (e2e)', () => {
@@ -116,4 +124,134 @@ describe('Treatments (e2e)', () => {
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 () => {
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 }),
);
});
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',
product: 'Oxalsäure',
},
]);
});
it('accepts a full ISO timestamp', async () => {
await expect(
withdrawalStatusAt('2026-07-14T23:59:59.000Z'),
).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),
);
});
});
+21 -1
View File
@@ -9,6 +9,7 @@ import { login } from './utils/api';
interface VarroaMeasurementBody {
id: string;
colonyId: string;
calculatedValue: number | null;
}
describe('Varroa measurements (e2e)', () => {
@@ -105,8 +106,27 @@ describe('Varroa measurements (e2e)', () => {
durationMinutes: 4320,
})
.expect(201);
measurementId = (response.body as VarroaMeasurementBody).id;
const created = response.body as VarroaMeasurementBody;
measurementId = created.id;
expect(measurementId).toEqual(expect.any(String));
expect(created.calculatedValue).toBe(4);
});
it('recomputes mites/day when the count is corrected', async () => {
// The edit dialog sends the whole form, the stored rate included.
const response = await request(app.getHttpServer())
.patch(`/api/v1/varroa-measurements/${measurementId}`)
.set('Authorization', `Bearer ${beekeeperToken}`)
.send({
colonyId,
date: new Date().toISOString(),
method: 'NATURAL_MITE_FALL',
miteCount: 30,
durationMinutes: 4320,
calculatedValue: 4,
})
.expect(200);
expect((response.body as VarroaMeasurementBody).calculatedValue).toBe(10);
});
it('lists measurements for the colony', async () => {
+10
View File
@@ -177,6 +177,16 @@ describe('Webhooks (e2e)', () => {
expect(receivedRequests).toHaveLength(0);
});
it.each(['url', 'events', 'active'])(
'rejects null for the required %s',
(field) =>
request(app.getHttpServer())
.patch(`/api/v1/webhooks/${subscriptionId}`)
.set('Authorization', `Bearer ${adminAccessToken}`)
.send({ [field]: null })
.expect(400),
);
it('lets an admin delete a webhook subscription', () => {
return request(app.getHttpServer())
.delete(`/api/v1/webhooks/${subscriptionId}`)