fix: keep quantities, colony/queen states and tasks consistent under retries

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]>
This commit is contained in:
Nick SzaunigandClaude Opus 5.5 committed 2026-09-27 05:27:45 +02:00
1 parent 786d929df9
commit 5d11756a87
37 files changed
+1420 -164

No files matched your search

+31 -6
View File
@@ -3,6 +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 { createTestApp } from './utils/create-test-app';
import { login } from './utils/api';
@@ -96,18 +97,16 @@ describe('Automation rules (e2e)', () => {
.expect(403);
});
it('seeds the three fixed triggers for an admin', async () => {
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([
'FEED_STATUS_CRITICAL',
'QUEENLESS_NO_EGGS',
'VARROA_THRESHOLD',
]);
expect(triggerTypes).toEqual(
[...Object.values(AutomationTriggerType)].sort(),
);
const varroaRule = rules.find(
(rule) => rule.triggerType === 'VARROA_THRESHOLD',
);
@@ -184,6 +183,8 @@ describe('Automation rules (e2e)', () => {
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);
@@ -194,12 +195,35 @@ describe('Automation rules (e2e)', () => {
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')
@@ -209,6 +233,7 @@ describe('Automation rules (e2e)', () => {
date: new Date().toISOString(),
method: 'NATURAL_MITE_FALL',
miteCount: 30,
durationMinutes: 24 * 60,
})
.expect(201);
@@ -0,0 +1,490 @@
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';
/**
* Regression tests for data-integrity rules that cut across modules:
* repeated requests, concurrent quantity bookings, colony/queen state
* transitions and duplicate automation tasks.
*/
describe('Data integrity (e2e)', () => {
let app: INestApplication<App>;
let prisma: PrismaService;
const suffix = `${Date.now()}-${Math.round(Math.random() * 1e6)}`;
const email = `integrity-e2e-${suffix}@example.com`;
const password = 'SuperSecret123!';
let token: string;
let userId: string;
let apiaryId: string;
const server = () => app.getHttpServer();
const auth = () => ({ Authorization: `Bearer ${token}` });
async function createColony(
code: string,
status: 'ACTIVE' | 'QUEENLESS' | 'WEAK' = 'ACTIVE',
) {
return prisma.colony.create({
data: {
code: `${code}-${suffix}`,
apiaryId,
colonyType: 'PRODUCTION_COLONY',
status,
},
});
}
async function createHoneyBatch(amount: number) {
const extraction = await prisma.extraction.create({
data: { date: new Date(), userId },
});
return prisma.honeyBatch.create({
data: {
batchNumber: `HB-${suffix}-${Math.round(Math.random() * 1e9)}`,
extractionId: extraction.id,
amount,
},
});
}
function bottle(honeyBatchId: string, jarCount: number) {
return request(server())
.post('/api/v1/bottling-batches')
.set(auth())
.send({
honeyBatchId,
date: '2026-08-01T00:00:00.000Z',
bestBeforeDate: '2028-08-01T00:00:00.000Z',
jarSize: 500,
jarCount,
lotNumber: `L-${suffix}-${Math.round(Math.random() * 1e9)}`,
});
}
beforeAll(async () => {
app = await createTestApp();
prisma = app.get(PrismaService);
const user = await prisma.user.create({
data: {
email,
passwordHash: await bcrypt.hash(password, 12),
name: 'Integrity',
role: { connect: { key: 'ADMIN' } },
},
});
userId = user.id;
token = (await login(app, email, password)).accessToken;
apiaryId = (
await prisma.apiary.create({ data: { name: `Integrity ${suffix}` } })
).id;
});
afterAll(async () => {
await app.close();
});
describe('Idempotency-Key', () => {
it('stores a repeated inspection only once and replays the first response', async () => {
const colony = await createColony('IDEM');
const key = `idem-${suffix}-inspection-0001`;
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 second = await request(server())
.post('/api/v1/inspections')
.set(auth())
.set('Idempotency-Key', key)
.send(body)
.expect(201);
expect((second.body as { id: string }).id).toBe(
(first.body as { id: string }).id,
);
expect(
await prisma.inspection.count({ where: { colonyId: colony.id } }),
).toBe(1);
// The automation fired once, not once per delivery attempt.
expect(
await prisma.task.count({
where: {
relatedId: colony.id,
sourceAutomationTrigger: { not: null },
},
}),
).toBe(1);
});
it('rejects a key reused for a different endpoint', async () => {
const colony = await createColony('IDEM2');
const key = `idem-${suffix}-crossuse-0001`;
await request(server())
.post('/api/v1/inspections')
.set(auth())
.set('Idempotency-Key', key)
.send({ colonyId: colony.id, date: '2026-07-01T00:00:00.000Z' })
.expect(201);
await request(server())
.post('/api/v1/feedings')
.set(auth())
.set('Idempotency-Key', key)
.send({
colonyId: colony.id,
date: '2026-07-01T00:00:00.000Z',
feedType: 'SUGAR_WATER',
amount: 2,
unit: 'l',
})
.expect(409);
});
it('ignores the header on endpoints that did not opt in, storing nothing', async () => {
const key = `idem-${suffix}-not-opted-in`;
await request(server())
.post('/api/v1/apiaries')
.set(auth())
.set('Idempotency-Key', key)
.send({ name: `Opt-out ${suffix}` })
.expect(201);
expect(
await prisma.idempotencyRecord.count({ where: { userId, key } }),
).toBe(0);
});
it('rejects a malformed key', () => {
return request(server())
.post('/api/v1/inspections')
.set(auth())
.set('Idempotency-Key', 'short')
.send({})
.expect(400);
});
it('releases the key when the request failed, so a corrected retry works', async () => {
const colony = await createColony('IDEM3');
const key = `idem-${suffix}-retry-00001`;
await request(server())
.post('/api/v1/inspections')
.set(auth())
.set('Idempotency-Key', key)
.send({ colonyId: colony.id })
.expect(400);
await request(server())
.post('/api/v1/inspections')
.set(auth())
.set('Idempotency-Key', key)
.send({ colonyId: colony.id, date: '2026-07-01T00:00:00.000Z' })
.expect(201);
});
});
describe('Bottling quantities', () => {
it('never over-books a honey batch under concurrent bottlings', async () => {
const honeyBatch = await createHoneyBatch(10);
// Each takes 6 kg of 10 kg - only one of them may succeed.
const results = await Promise.all([
bottle(honeyBatch.id, 12),
bottle(honeyBatch.id, 12),
]);
const statuses = results.map((r) => r.status).sort();
expect(statuses).toEqual([201, 400]);
const after = await prisma.honeyBatch.findUniqueOrThrow({
where: { id: honeyBatch.id },
});
expect(after.amount).toBeCloseTo(4, 3);
});
it('gives the amount back when a bottling is removed', async () => {
const honeyBatch = await createHoneyBatch(5);
const created = await bottle(honeyBatch.id, 3).expect(201);
await request(server())
.delete(
`/api/v1/bottling-batches/${(created.body as { id: string }).id}`,
)
.set(auth())
.expect(200);
const after = await prisma.honeyBatch.findUniqueOrThrow({
where: { id: honeyBatch.id },
});
expect(after.amount).toBeCloseTo(5, 3);
});
});
describe('Stock movements', () => {
it('refuses an outgoing movement larger than the stock', async () => {
const item = await prisma.stockItem.create({
data: {
name: `Gläser ${suffix}`,
category: 'CONSUMABLE',
unit: 'Stk',
quantity: 5,
},
});
await request(server())
.post(`/api/v1/stock-items/${item.id}/movements`)
.set(auth())
.send({ type: 'OUT', quantity: 6 })
.expect(400);
const after = await prisma.stockItem.findUniqueOrThrow({
where: { id: item.id },
});
expect(after.quantity).toBe(5);
});
it('creates only one open reorder task while the item stays below minimum', async () => {
const item = await prisma.stockItem.create({
data: {
name: `Deckel ${suffix}`,
category: 'CONSUMABLE',
unit: 'Stk',
quantity: 10,
minThreshold: 8,
},
});
for (const quantity of [3, 1, 1]) {
await request(server())
.post(`/api/v1/stock-items/${item.id}/movements`)
.set(auth())
.send({ type: 'OUT', quantity })
.expect(201);
}
expect(
await prisma.task.count({
where: {
relatedId: item.id,
sourceAutomationTrigger: 'STOCK_ITEM_LOW',
status: { in: ['OPEN', 'IN_PROGRESS'] },
},
}),
).toBe(1);
});
});
describe('Colony transitions', () => {
it('lets a queenless colony be united with another colony', async () => {
const survivor = await createColony('SURV');
const queenless = await createColony('QL', 'QUEENLESS');
await request(server())
.post(`/api/v1/colonies/${survivor.id}/merge`)
.set(auth())
.send({ mergedColonyIds: [queenless.id] })
.expect(201);
const merged = await prisma.colony.findUniqueOrThrow({
where: { id: queenless.id },
});
expect(merged.status).toBe('MERGED');
});
it('lets a weak colony be moved to another apiary', async () => {
const weak = await createColony('WEAK', 'WEAK');
const otherApiary = await prisma.apiary.create({
data: { name: `Other ${suffix}` },
});
await request(server())
.post(`/api/v1/colonies/${weak.id}/move`)
.set(auth())
.send({ apiaryId: otherApiary.id })
.expect(201);
});
it('refuses to merge a colony into itself', async () => {
const colony = await createColony('SELF');
await request(server())
.post(`/api/v1/colonies/${colony.id}/merge`)
.set(auth())
.send({ mergedColonyIds: [colony.id] })
.expect(400);
const after = await prisma.colony.findUniqueOrThrow({
where: { id: colony.id },
});
expect(after.status).toBe('ACTIVE');
});
it('refuses to dissolve a colony that is already dissolved', async () => {
const colony = await createColony('DISS');
await request(server())
.post(`/api/v1/colonies/${colony.id}/dissolve`)
.set(auth())
.expect(201);
await request(server())
.post(`/api/v1/colonies/${colony.id}/dissolve`)
.set(auth())
.expect(409);
});
});
describe('Queen assignment', () => {
it('moves a queen between colonies and leaves the old colony queenless', async () => {
const colonyA = await createColony('QA');
const colonyB = await createColony('QB', 'QUEENLESS');
const queen = await prisma.queen.create({
data: { code: `Q-${suffix}` },
});
await request(server())
.post(`/api/v1/queens/${queen.id}/assign-colony`)
.set(auth())
.send({ colonyId: colonyA.id })
.expect(201);
await request(server())
.post(`/api/v1/queens/${queen.id}/assign-colony`)
.set(auth())
.send({ colonyId: colonyB.id })
.expect(201);
const a = await prisma.colony.findUniqueOrThrow({
where: { id: colonyA.id },
});
const b = await prisma.colony.findUniqueOrThrow({
where: { id: colonyB.id },
});
expect(a.currentQueenId).toBeNull();
expect(a.status).toBe('QUEENLESS');
expect(b.currentQueenId).toBe(queen.id);
expect(b.status).toBe('ACTIVE');
const openHistory = await prisma.queenColonyHistory.findMany({
where: { queenId: queen.id, endDate: null },
});
expect(openHistory.map((h) => h.colonyId)).toEqual([colonyB.id]);
});
it('refuses to assign a queen to a dissolved colony', async () => {
const colony = await createColony('QD');
await prisma.colony.update({
where: { id: colony.id },
data: { status: 'DISSOLVED' },
});
const queen = await prisma.queen.create({
data: { code: `QD-${suffix}` },
});
await request(server())
.post(`/api/v1/queens/${queen.id}/assign-colony`)
.set(auth())
.send({ colonyId: colony.id })
.expect(409);
});
});
describe('Tasks', () => {
it('keeps the first completion when a task is completed twice', async () => {
const created = await request(server())
.post('/api/v1/tasks')
.set(auth())
.send({ title: `Doppelklick ${suffix}` })
.expect(201);
const id = (created.body as { id: string }).id;
const first = await request(server())
.post(`/api/v1/tasks/${id}/complete`)
.set(auth())
.expect(201);
const second = await request(server())
.post(`/api/v1/tasks/${id}/complete`)
.set(auth())
.expect(201);
expect((second.body as { completedAt: string }).completedAt).toBe(
(first.body as { completedAt: string }).completedAt,
);
});
it('clears completedAt when a done task is reopened', async () => {
const created = await request(server())
.post('/api/v1/tasks')
.set(auth())
.send({ title: `Wieder offen ${suffix}` })
.expect(201);
const id = (created.body as { id: string }).id;
await request(server())
.post(`/api/v1/tasks/${id}/complete`)
.set(auth())
.expect(201);
const reopened = await request(server())
.patch(`/api/v1/tasks/${id}`)
.set(auth())
.send({ status: 'OPEN' })
.expect(200);
expect(
(reopened.body as { completedAt: string | null }).completedAt,
).toBeNull();
});
it('gives a task generated from a recurring template its due date', async () => {
const dueDate = new Date('2026-01-15T00:00:00.000Z');
const template = await prisma.taskTemplate.create({
data: {
title: `Wiederkehrend ${suffix}`,
recurrenceType: 'INTERVAL_DAYS',
intervalDays: 7,
nextDueDate: dueDate,
},
});
await request(server()).get('/api/v1/tasks').set(auth()).expect(200);
const generated = await prisma.task.findMany({
where: { sourceTemplateId: template.id },
});
// Months of missed weekly occurrences collapse into one reminder
// instead of a pile of stale duplicates.
expect(generated).toHaveLength(1);
expect(generated[0].dueDate).not.toBeNull();
const refreshed = await prisma.taskTemplate.findUniqueOrThrow({
where: { id: template.id },
});
expect(refreshed.nextDueDate.getTime()).toBeGreaterThan(Date.now());
});
});
describe('Input plausibility', () => {
it('rejects an inspection rating outside the 1–5 scale', async () => {
const colony = await createColony('SCALE');
await request(server())
.post('/api/v1/inspections')
.set(auth())
.send({
colonyId: colony.id,
date: '2026-07-01T00:00:00.000Z',
gentleness: 9,
})
.expect(400);
});
it('rejects a harvest whose net weight exceeds its gross weight', async () => {
const colony = await createColony('HW');
await request(server())
.post('/api/v1/harvests')
.set(auth())
.send({
apiaryId,
colonyIds: [colony.id],
date: '2026-07-01T00:00:00.000Z',
grossWeight: 20,
netWeight: 25,
})
.expect(400);
});
it('rejects a harvest listing the same colony twice', async () => {
const colony = await createColony('HD');
await request(server())
.post('/api/v1/harvests')
.set(auth())
.send({
apiaryId,
colonyIds: [colony.id, colony.id],
date: '2026-07-01T00:00:00.000Z',
})
.expect(400);
});
});
});
+11 -4
View File
@@ -7,6 +7,13 @@ import { PrismaService } from '../src/prisma/prisma.service';
import { createTestApp } from './utils/create-test-app';
import { login } from './utils/api';
// The upload checks the file's magic bytes against its declared type, so
// the fixture must start with a real PNG signature.
const PNG_BYTES = Buffer.concat([
Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]),
Buffer.from('test image payload'),
]);
interface DocumentBody {
id: string;
entityType: string;
@@ -96,7 +103,7 @@ describe('Documents (e2e)', () => {
.set('Authorization', `Bearer ${readonlyToken}`)
.field('entityType', 'COLONY')
.field('entityId', colonyId)
.attach('file', Buffer.from('fake image bytes'), {
.attach('file', PNG_BYTES, {
filename: 'bild.png',
contentType: 'image/png',
})
@@ -122,7 +129,7 @@ describe('Documents (e2e)', () => {
.set('Authorization', `Bearer ${beekeeperToken}`)
.field('entityType', 'COLONY')
.field('entityId', 'does-not-exist')
.attach('file', Buffer.from('fake image bytes'), {
.attach('file', PNG_BYTES, {
filename: 'bild.png',
contentType: 'image/png',
})
@@ -135,7 +142,7 @@ describe('Documents (e2e)', () => {
.set('Authorization', `Bearer ${beekeeperToken}`)
.field('entityType', 'COLONY')
.field('entityId', colonyId)
.attach('file', Buffer.from('fake image bytes'), {
.attach('file', PNG_BYTES, {
filename: 'bild.png',
contentType: 'image/png',
})
@@ -166,7 +173,7 @@ describe('Documents (e2e)', () => {
expect(response.headers['content-type']).toContain('image/png');
expect(response.headers['content-disposition']).toContain('bild.png');
const downloaded = response.body as Buffer;
expect(downloaded.toString('utf-8')).toBe('fake image bytes');
expect(Buffer.compare(downloaded, PNG_BYTES)).toBe(0);
});
it('rejects a delete from a role without write access', () => {
+4 -3
View File
@@ -5,6 +5,7 @@ import { App } from 'supertest/types';
import { PrismaService } from '../src/prisma/prisma.service';
import { createTestApp } from './utils/create-test-app';
import { login } from './utils/api';
import { PERMISSION_CATALOG } from '../src/auth/permissions/permission-catalog';
interface RoleBody {
id: string;
@@ -115,7 +116,7 @@ describe('Roles (e2e)', () => {
);
const admin = roles.find((role) => role.key === 'ADMIN');
expect(admin?.isSystem).toBe(true);
expect(admin?.permissionKeys.length).toBe(83);
expect(admin?.permissionKeys.length).toBe(PERMISSION_CATALOG.length);
});
it('exposes the full permission catalog', async () => {
@@ -123,7 +124,7 @@ describe('Roles (e2e)', () => {
.get('/api/v1/roles/permission-catalog')
.set('Authorization', `Bearer ${adminToken}`)
.expect(200);
expect((response.body as unknown[]).length).toBe(83);
expect((response.body as unknown[]).length).toBe(PERMISSION_CATALOG.length);
});
it('lets an admin create a role with a chosen permission set', async () => {
@@ -221,7 +222,7 @@ describe('Roles (e2e)', () => {
.expect(200);
const adminProfile = adminMe.body as MeBody;
expect(adminProfile.roleKey).toBe('ADMIN');
expect(adminProfile.permissions.length).toBe(83);
expect(adminProfile.permissions.length).toBe(PERMISSION_CATALOG.length);
const staffMe = await request(app.getHttpServer())
.get('/api/v1/auth/me')