Data-integrity fixes found in a systematic review of ApiCore: - Bottling booked the honey batch amount from a value read outside the transaction and wrote an absolute result back, so two concurrent bottlings (double tap, two devices) could both pass the check and one deduction was lost. Bookings now use a conditional UPDATE inside the transaction; corrections and deletions give amounts back atomically. - Outgoing stock movements could drive stock below zero; the removal is now a conditional decrement that refuses when stock is insufficient. - Weak, conspicuous and queenless colonies were treated as "not active" and could not be moved, split or merged - uniting a queenless colony is the textbook merge. Only terminal statuses lock a colony now; merging a colony into itself and dissolving twice are rejected. - Moving a queen to another colony failed on the unique currentQueenId because the old colony kept pointing at her. The old colony now releases her (and becomes QUEENLESS); a queenless colony receiving a queen becomes ACTIVE; dissolved colonies cannot receive a queen. - Completing a task twice moved completedAt and re-sent the webhook; reopening a task kept completedAt. Recurring templates created tasks without a due date and caught up missed weeks one stale task at a time. - Automation rules created a new task and warning on every trigger (every stock removal below minimum). Tasks now record their source trigger and at most one open task per rule and object exists. The varroa threshold (mites per day) is only compared with natural-mite-fall rates, not with sample-based counts in another unit. - New opt-in Idempotency-Key support for record-creating endpoints, so the offline queue can retry after a lost response without duplicates. - Permission catalog sync crashed replicas booting concurrently on a fresh database (unique violation); it now uses createMany/skipDuplicates. - Server-side validation for inspection rating scales (1-5), unique colony/harvest lists and net weight <= gross weight. - Stale e2e expectations updated (permission count, trigger list, real PNG fixture for the file-signature check). Migration is additive only (nullable Task column, new table). Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
246 lines
7.7 KiB
TypeScript
246 lines
7.7 KiB
TypeScript
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 { AutomationTriggerType } from '../generated/prisma';
|
|
import { createTestApp } from './utils/create-test-app';
|
|
import { login } from './utils/api';
|
|
|
|
interface AutomationRuleBody {
|
|
id: string;
|
|
triggerType: string;
|
|
active: boolean;
|
|
config: Record<string, unknown> | null;
|
|
actionTemplate: string | null;
|
|
}
|
|
|
|
describe('Automation rules (e2e)', () => {
|
|
let app: INestApplication<App>;
|
|
let prisma: PrismaService;
|
|
const adminEmail = `automation-e2e-admin-${Date.now()}@example.com`;
|
|
const staffEmail = `automation-e2e-staff-${Date.now()}@example.com`;
|
|
const password = 'SuperSecret123!';
|
|
let adminId: string;
|
|
let staffId: string;
|
|
let adminToken: string;
|
|
let staffToken: string;
|
|
let apiaryId: string;
|
|
let colonyId: string;
|
|
let varroaRuleId: string;
|
|
|
|
beforeAll(async () => {
|
|
app = await createTestApp();
|
|
prisma = app.get(PrismaService);
|
|
|
|
const passwordHash = await bcrypt.hash(password, 12);
|
|
const admin = await prisma.user.create({
|
|
data: {
|
|
email: adminEmail,
|
|
passwordHash,
|
|
name: 'Automation E2E Admin',
|
|
role: { connect: { key: 'ADMIN' } },
|
|
},
|
|
});
|
|
adminId = admin.id;
|
|
const staff = await prisma.user.create({
|
|
data: {
|
|
email: staffEmail,
|
|
passwordHash,
|
|
name: 'Automation E2E Staff',
|
|
role: { connect: { key: 'STAFF' } },
|
|
},
|
|
});
|
|
staffId = staff.id;
|
|
adminToken = (await login(app, adminEmail, password)).accessToken;
|
|
staffToken = (await login(app, staffEmail, password)).accessToken;
|
|
|
|
const apiary = await prisma.apiary.create({
|
|
data: { name: `Automation Stand ${Date.now()}` },
|
|
});
|
|
apiaryId = apiary.id;
|
|
const colony = await prisma.colony.create({
|
|
data: {
|
|
code: `AUTO-${Date.now()}`,
|
|
apiaryId,
|
|
colonyType: 'PRODUCTION_COLONY',
|
|
},
|
|
});
|
|
colonyId = colony.id;
|
|
});
|
|
|
|
afterAll(async () => {
|
|
await prisma.automationRule.updateMany({
|
|
where: { triggerType: 'VARROA_THRESHOLD' },
|
|
data: { active: true },
|
|
});
|
|
await prisma.task.deleteMany({ where: { relatedId: colonyId } });
|
|
await prisma.notification.deleteMany({ where: { relatedId: colonyId } });
|
|
await prisma.varroaMeasurement.deleteMany({ where: { colonyId } });
|
|
await prisma.inspection.deleteMany({ where: { colonyId } });
|
|
await prisma.colony.deleteMany({ where: { id: colonyId } });
|
|
await prisma.apiary.deleteMany({ where: { id: apiaryId } });
|
|
await prisma.refreshToken.deleteMany({
|
|
where: { userId: { in: [adminId, staffId] } },
|
|
});
|
|
await prisma.auditLog.deleteMany({
|
|
where: { userId: { in: [adminId, staffId] } },
|
|
});
|
|
await prisma.user.deleteMany({ where: { id: { in: [adminId, staffId] } } });
|
|
await app.close();
|
|
});
|
|
|
|
it('rejects a non-admin listing automation rules', () => {
|
|
return request(app.getHttpServer())
|
|
.get('/api/v1/automation-rules')
|
|
.set('Authorization', `Bearer ${staffToken}`)
|
|
.expect(403);
|
|
});
|
|
|
|
it('seeds every fixed trigger for an admin', async () => {
|
|
const response = await request(app.getHttpServer())
|
|
.get('/api/v1/automation-rules')
|
|
.set('Authorization', `Bearer ${adminToken}`)
|
|
.expect(200);
|
|
const rules = response.body as AutomationRuleBody[];
|
|
const triggerTypes = rules.map((rule) => rule.triggerType).sort();
|
|
expect(triggerTypes).toEqual(
|
|
[...Object.values(AutomationTriggerType)].sort(),
|
|
);
|
|
const varroaRule = rules.find(
|
|
(rule) => rule.triggerType === 'VARROA_THRESHOLD',
|
|
);
|
|
expect(varroaRule).toBeDefined();
|
|
varroaRuleId = varroaRule!.id;
|
|
});
|
|
|
|
it('creates a task and notification when queen not seen and no eggs present', async () => {
|
|
await request(app.getHttpServer())
|
|
.post('/api/v1/inspections')
|
|
.set('Authorization', `Bearer ${staffToken}`)
|
|
.send({
|
|
colonyId,
|
|
date: new Date().toISOString(),
|
|
queenSeen: false,
|
|
eggsPresent: false,
|
|
})
|
|
.expect(201);
|
|
|
|
const tasks = await prisma.task.findMany({
|
|
where: { relatedId: colonyId },
|
|
});
|
|
expect(tasks).toHaveLength(1);
|
|
expect(tasks[0].title).toContain('Kontrolle');
|
|
|
|
const notifications = await prisma.notification.findMany({
|
|
where: { relatedId: colonyId },
|
|
});
|
|
expect(notifications).toHaveLength(1);
|
|
});
|
|
|
|
it('does not trigger when the queen was seen', async () => {
|
|
await request(app.getHttpServer())
|
|
.post('/api/v1/inspections')
|
|
.set('Authorization', `Bearer ${staffToken}`)
|
|
.send({
|
|
colonyId,
|
|
date: new Date().toISOString(),
|
|
queenSeen: true,
|
|
eggsPresent: false,
|
|
})
|
|
.expect(201);
|
|
|
|
const tasks = await prisma.task.findMany({
|
|
where: { relatedId: colonyId },
|
|
});
|
|
expect(tasks).toHaveLength(1);
|
|
});
|
|
|
|
it('triggers a feeding task when feed status is kritisch', async () => {
|
|
await request(app.getHttpServer())
|
|
.post('/api/v1/inspections')
|
|
.set('Authorization', `Bearer ${staffToken}`)
|
|
.send({
|
|
colonyId,
|
|
date: new Date().toISOString(),
|
|
feedStatus: 'kritisch',
|
|
})
|
|
.expect(201);
|
|
|
|
const tasks = await prisma.task.findMany({
|
|
where: { relatedId: colonyId },
|
|
});
|
|
expect(tasks).toHaveLength(2);
|
|
expect(tasks.some((task) => task.title.includes('Fütterung'))).toBe(true);
|
|
});
|
|
|
|
it('triggers a treatment check when the varroa threshold is exceeded', async () => {
|
|
await request(app.getHttpServer())
|
|
.post('/api/v1/varroa-measurements')
|
|
.set('Authorization', `Bearer ${adminToken}`)
|
|
.send({
|
|
colonyId,
|
|
date: new Date().toISOString(),
|
|
method: 'NATURAL_MITE_FALL',
|
|
miteCount: 20,
|
|
// One day on the board: 20 mites per day, above the default 10.
|
|
durationMinutes: 24 * 60,
|
|
})
|
|
.expect(201);
|
|
|
|
const tasks = await prisma.task.findMany({
|
|
where: { relatedId: colonyId },
|
|
});
|
|
expect(tasks).toHaveLength(3);
|
|
expect(tasks.some((task) => task.title.includes('Behandlung'))).toBe(true);
|
|
});
|
|
|
|
it('does not compare a sample-based count against the per-day threshold', async () => {
|
|
const before = await prisma.task.count({ where: { relatedId: colonyId } });
|
|
await request(app.getHttpServer())
|
|
.post('/api/v1/varroa-measurements')
|
|
.set('Authorization', `Bearer ${adminToken}`)
|
|
.send({
|
|
colonyId,
|
|
date: new Date().toISOString(),
|
|
method: 'POWDERED_SUGAR',
|
|
miteCount: 50,
|
|
})
|
|
.expect(201);
|
|
expect(await prisma.task.count({ where: { relatedId: colonyId } })).toBe(
|
|
before,
|
|
);
|
|
});
|
|
|
|
it('lets an admin deactivate a rule, preventing further triggers', async () => {
|
|
await request(app.getHttpServer())
|
|
.patch(`/api/v1/automation-rules/${varroaRuleId}`)
|
|
.set('Authorization', `Bearer ${adminToken}`)
|
|
.send({ active: false })
|
|
.expect(200);
|
|
// Close the earlier tasks so the one-open-task-per-rule guard cannot be
|
|
// what keeps a new task from appearing - only the deactivation may.
|
|
await prisma.task.updateMany({
|
|
where: { relatedId: colonyId },
|
|
data: { status: 'DONE' },
|
|
});
|
|
|
|
await request(app.getHttpServer())
|
|
.post('/api/v1/varroa-measurements')
|
|
.set('Authorization', `Bearer ${adminToken}`)
|
|
.send({
|
|
colonyId,
|
|
date: new Date().toISOString(),
|
|
method: 'NATURAL_MITE_FALL',
|
|
miteCount: 30,
|
|
durationMinutes: 24 * 60,
|
|
})
|
|
.expect(201);
|
|
|
|
const tasks = await prisma.task.findMany({
|
|
where: { relatedId: colonyId },
|
|
});
|
|
expect(tasks).toHaveLength(3);
|
|
});
|
|
});
|