Merge remote-tracking branch 'origin/main' into feat/erp-inventar-export
# Conflicts: # ARCHITECTURE.md
This commit is contained in:
commit
9c332d4716
310 files changed
+12177
-3746
No files matched your search
@@ -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 } });
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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']),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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`)
|
||||
|
||||
@@ -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`)
|
||||
|
||||
@@ -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),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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}`)
|
||||
|
||||
Reference in new issue
Block a user