feat: platform backbone and field capture complete (checkpoint)
Platform backbone (plan 2026-10-13-plattform-rueckgrat), all tasks reviewed: - webhooks with delivery table, retries and stable delivery id - calendar handlers; a finished task removes its Outlook event - tasks, automations, inspections, varroa, treatments, feedings, harvests, colonies, wintering, stock and bottling record domain events in the same transaction; legacy post-commit calls removed - row locks through one helper (lockRowForUpdate, lockRowsForShare, lockRowsForUpdate) - job framework with JobRun, cli exit codes, cleanup, task templates, daily evaluation, hourly app-backup and the rekey job - system API and page "Verwaltung > System" with retry - @nestjs/schedule removed, guard tests against in-process schedulers Field capture T3 (plan 2026-10-13-t3-erfassung-am-stand), all tasks reviewed: - inspection photo offline without metadata - bulk capture for treatments, feedings and varroa (backend + frontend) - offline stand mode with service worker, offline pages and packs - offline session, cold start and logout on shared devices Docs: ARCHITECTURE.md assumptions and sections updated, TASKS.md. Gates: backend lint, typecheck, 1770 unit, 420 e2e, build, no schema drift; frontend lint, typecheck, 913 tests, build. Not rolled out. Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
This commit is contained in:
1 parent
c01a38835a
commit
cdd30cf3ee
288 files changed
+39165
-2853
No files matched your search
@@ -2,9 +2,11 @@ import { INestApplication } from '@nestjs/common';
|
||||
import * as bcrypt from 'bcryptjs';
|
||||
import request from 'supertest';
|
||||
import { App } from 'supertest/types';
|
||||
import { Prisma } from '../generated/prisma';
|
||||
import { PrismaService } from '../src/prisma/prisma.service';
|
||||
import { createTestApp } from './utils/create-test-app';
|
||||
import { login } from './utils/api';
|
||||
import { drainDomainEvents } from './utils/domain-events';
|
||||
|
||||
interface StockBody {
|
||||
summary: {
|
||||
@@ -13,7 +15,12 @@ interface StockBody {
|
||||
consumed: number;
|
||||
remaining: number;
|
||||
};
|
||||
movements: { type: string; quantity: number; notes: string | null }[];
|
||||
movements: {
|
||||
id: string;
|
||||
type: string;
|
||||
quantity: number;
|
||||
notes: string | null;
|
||||
}[];
|
||||
}
|
||||
|
||||
interface LotBody {
|
||||
@@ -27,6 +34,13 @@ interface BatchBody {
|
||||
inputs: { quantity: number; rawMaterialLot: { id: string } }[];
|
||||
}
|
||||
|
||||
/** A shared rule's config as it was before a test changed it. */
|
||||
function restoredConfig(rule: {
|
||||
config: Prisma.JsonValue;
|
||||
}): Prisma.InputJsonValue | typeof Prisma.DbNull {
|
||||
return rule.config === null ? Prisma.DbNull : rule.config;
|
||||
}
|
||||
|
||||
/**
|
||||
* Raw material lots (wax, propolis) and their consumption in production:
|
||||
* creating a production batch books the inputs off the raw lots in the same
|
||||
@@ -37,17 +51,29 @@ describe('Raw materials and production (e2e)', () => {
|
||||
let app: INestApplication<App>;
|
||||
let prisma: PrismaService;
|
||||
const suffix = `${Date.now()}-${Math.round(Math.random() * 1e6)}`;
|
||||
const startedAt = new Date();
|
||||
const password = 'SuperSecret123!';
|
||||
let token: string;
|
||||
let userId: string;
|
||||
let waxId: string;
|
||||
let propolisId: string;
|
||||
let productId: string;
|
||||
let batchId: string;
|
||||
// Tasks the RAW_MATERIAL_LOW handler created for this file.
|
||||
const ruleTaskIds: string[] = [];
|
||||
|
||||
const server = () => app.getHttpServer();
|
||||
const auth = (req: request.Test) =>
|
||||
req.set('Authorization', `Bearer ${token}`);
|
||||
|
||||
/** The StockChanged events recorded for one movement or batch. */
|
||||
function stockChangedOf(aggregateId: string) {
|
||||
return prisma.domainEvent.findMany({
|
||||
where: { type: 'StockChanged', aggregateId },
|
||||
select: { aggregateType: true, payload: true, actorUserId: true },
|
||||
});
|
||||
}
|
||||
|
||||
async function stockOf(lotId: string): Promise<StockBody> {
|
||||
const response = await auth(
|
||||
request(server()).get(`/api/v1/raw-material-lots/${lotId}/stock`),
|
||||
@@ -58,7 +84,7 @@ describe('Raw materials and production (e2e)', () => {
|
||||
beforeAll(async () => {
|
||||
app = await createTestApp();
|
||||
prisma = app.get(PrismaService);
|
||||
await prisma.user.create({
|
||||
const user = await prisma.user.create({
|
||||
data: {
|
||||
email: `raw-beekeeper-${suffix}@example.com`,
|
||||
passwordHash: await bcrypt.hash(password, 12),
|
||||
@@ -66,6 +92,7 @@ describe('Raw materials and production (e2e)', () => {
|
||||
role: { connect: { key: 'BEEKEEPER' } },
|
||||
},
|
||||
});
|
||||
userId = user.id;
|
||||
token = (await login(app, `raw-beekeeper-${suffix}@example.com`, password))
|
||||
.accessToken;
|
||||
|
||||
@@ -96,6 +123,53 @@ describe('Raw materials and production (e2e)', () => {
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
// Only this file's rows, by its own ids - other e2e files share the
|
||||
// database. An undefined id would drop a filter, hence the guards.
|
||||
const lotIds = [waxId, propolisId].filter(Boolean);
|
||||
const movementIds = (
|
||||
await prisma.batchMovement.findMany({
|
||||
where: { rawMaterialLotId: { in: lotIds } },
|
||||
select: { id: true },
|
||||
})
|
||||
).map((movement) => movement.id);
|
||||
const batchIds = [batchId].filter(Boolean);
|
||||
// Rule tasks are written without a user: their TaskCreated, audit
|
||||
// entries, warning and queued deliveries go by task id.
|
||||
for (const taskId of ruleTaskIds) {
|
||||
await prisma.auditLog.deleteMany({
|
||||
where: {
|
||||
entityType: 'AutomationRule',
|
||||
afterData: { path: ['taskId'], equals: taskId },
|
||||
},
|
||||
});
|
||||
await prisma.webhookDelivery.deleteMany({
|
||||
where: { event: 'task.created', body: { contains: taskId } },
|
||||
});
|
||||
}
|
||||
await prisma.calendarSyncOutboxEntry.deleteMany({
|
||||
where: { sourceId: { in: ruleTaskIds } },
|
||||
});
|
||||
// Safe to scope by kind and time: RAW_MATERIAL_LOW is the only writer
|
||||
// of GENERAL/PROPOLIS warnings, and only this file switches the rule on
|
||||
// (default: off, no minimum) - so such a warning since this file
|
||||
// started belongs to the task above.
|
||||
if (ruleTaskIds.length > 0) {
|
||||
await prisma.notification.deleteMany({
|
||||
where: {
|
||||
relatedType: 'GENERAL',
|
||||
relatedId: 'PROPOLIS',
|
||||
createdAt: { gte: startedAt },
|
||||
},
|
||||
});
|
||||
}
|
||||
const aggregateIds = [...batchIds, ...movementIds, ...ruleTaskIds];
|
||||
await prisma.domainEvent.deleteMany({
|
||||
where: { aggregateId: { in: aggregateIds } },
|
||||
});
|
||||
await prisma.auditLog.deleteMany({
|
||||
where: { entityId: { in: [...lotIds, ...aggregateIds] } },
|
||||
});
|
||||
await prisma.task.deleteMany({ where: { id: { in: ruleTaskIds } } });
|
||||
await app.close();
|
||||
});
|
||||
|
||||
@@ -130,6 +204,26 @@ describe('Raw materials and production (e2e)', () => {
|
||||
expect(
|
||||
(wax.body as LotBody).usedIn.map((u) => u.productionBatch.id),
|
||||
).toEqual([batchId]);
|
||||
|
||||
// The article for the shop, the consumed kinds for the minimum rule.
|
||||
const events = await stockChangedOf(batchId);
|
||||
expect(events).toEqual([
|
||||
{
|
||||
aggregateType: 'ProductionBatch',
|
||||
payload: {
|
||||
bottlingBatchIds: [],
|
||||
productIds: [productId],
|
||||
rawMaterialKinds: expect.arrayContaining([
|
||||
'BEESWAX',
|
||||
'PROPOLIS',
|
||||
]) as unknown,
|
||||
},
|
||||
actorUserId: userId,
|
||||
},
|
||||
]);
|
||||
expect(
|
||||
(events[0].payload as { rawMaterialKinds: string[] }).rawMaterialKinds,
|
||||
).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('refuses a production batch that needs more raw material than is left - and creates nothing', async () => {
|
||||
@@ -163,6 +257,24 @@ describe('Raw materials and production (e2e)', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('a raw material withdrawal records StockChanged with the lot kind and no shop ids', async () => {
|
||||
const [withdrawal] = await prisma.batchMovement.findMany({
|
||||
where: { rawMaterialLotId: waxId, type: 'WITHDRAWAL' },
|
||||
select: { id: true },
|
||||
});
|
||||
expect(await stockChangedOf(withdrawal.id)).toEqual([
|
||||
{
|
||||
aggregateType: 'BatchMovement',
|
||||
payload: {
|
||||
bottlingBatchIds: [],
|
||||
productIds: [],
|
||||
rawMaterialKinds: ['BEESWAX'],
|
||||
},
|
||||
actorUserId: userId,
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('gives the raw material back when a mistaken production batch is deleted', async () => {
|
||||
await auth(
|
||||
request(server()).delete(`/api/v1/production-batches/${batchId}`),
|
||||
@@ -174,6 +286,20 @@ describe('Raw materials and production (e2e)', () => {
|
||||
true,
|
||||
);
|
||||
expect((await stockOf(propolisId)).summary.remaining).toBe(200);
|
||||
|
||||
// The give-back is a stock change of the article and both kinds too.
|
||||
const events = await stockChangedOf(batchId);
|
||||
expect(events).toHaveLength(2);
|
||||
for (const event of events) {
|
||||
expect(event.payload).toEqual({
|
||||
bottlingBatchIds: [],
|
||||
productIds: [productId],
|
||||
rawMaterialKinds: expect.arrayContaining([
|
||||
'BEESWAX',
|
||||
'PROPOLIS',
|
||||
]) as unknown,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
it('refuses to correct a raw lot below what was already used or withdrawn', async () => {
|
||||
@@ -236,4 +362,97 @@ describe('Raw materials and production (e2e)', () => {
|
||||
request(server()).delete(`/api/v1/raw-material-lots/${waxId}`),
|
||||
).expect(409);
|
||||
});
|
||||
|
||||
it('creates the RAW_MATERIAL_LOW task only once its StockChanged event is dispatched', async () => {
|
||||
// The rule is shared by every e2e file: switch it on with a minimum no
|
||||
// test database reaches, and put it back right after.
|
||||
const before = await prisma.automationRule.findUnique({
|
||||
where: { triggerType: 'RAW_MATERIAL_LOW' },
|
||||
});
|
||||
const config = { minGrams: { PROPOLIS: 1_000_000_000_000 } };
|
||||
// One open task per kind: a leftover from an aborted earlier run would
|
||||
// keep the handler from creating a new one - close it first.
|
||||
await prisma.task.updateMany({
|
||||
where: {
|
||||
sourceAutomationTrigger: 'RAW_MATERIAL_LOW',
|
||||
relatedId: 'PROPOLIS',
|
||||
status: { in: ['OPEN', 'IN_PROGRESS'] },
|
||||
},
|
||||
data: { status: 'DONE', completedAt: new Date() },
|
||||
});
|
||||
await prisma.automationRule.upsert({
|
||||
where: { triggerType: 'RAW_MATERIAL_LOW' },
|
||||
create: {
|
||||
triggerType: 'RAW_MATERIAL_LOW',
|
||||
active: true,
|
||||
config,
|
||||
actionTemplate: 'Rohstoff unter Mindestmenge: Nachschub einplanen.',
|
||||
},
|
||||
update: { active: true, config },
|
||||
});
|
||||
try {
|
||||
const response = await auth(
|
||||
request(server()).post(
|
||||
`/api/v1/raw-material-lots/${propolisId}/withdrawals`,
|
||||
),
|
||||
)
|
||||
.send({ quantity: 10, reason: 'PROBE' })
|
||||
.expect(201);
|
||||
// Newest first: the withdrawal just booked.
|
||||
const [movement] = (response.body as StockBody).movements;
|
||||
const [event] = await prisma.domainEvent.findMany({
|
||||
where: { type: 'StockChanged', aggregateId: movement.id },
|
||||
select: { correlationId: true },
|
||||
});
|
||||
const ruleTasksOfThisChange = async () =>
|
||||
(
|
||||
await prisma.domainEvent.findMany({
|
||||
where: {
|
||||
type: 'TaskCreated',
|
||||
correlationId: event.correlationId,
|
||||
},
|
||||
select: { aggregateId: true },
|
||||
})
|
||||
).map((created) => created.aggregateId);
|
||||
|
||||
// The request itself creates no task - the handler does.
|
||||
expect(await ruleTasksOfThisChange()).toEqual([]);
|
||||
expect(
|
||||
await prisma.task.count({
|
||||
where: {
|
||||
sourceAutomationTrigger: 'RAW_MATERIAL_LOW',
|
||||
relatedId: 'PROPOLIS',
|
||||
createdAt: { gte: startedAt },
|
||||
},
|
||||
}),
|
||||
).toBe(0);
|
||||
|
||||
await drainDomainEvents(app, [movement.id]);
|
||||
|
||||
const taskIds = await ruleTasksOfThisChange();
|
||||
ruleTaskIds.push(...taskIds);
|
||||
expect(taskIds).toHaveLength(1);
|
||||
expect(
|
||||
await prisma.task.findUniqueOrThrow({ where: { id: taskIds[0] } }),
|
||||
).toMatchObject({
|
||||
sourceAutomationTrigger: 'RAW_MATERIAL_LOW',
|
||||
relatedType: 'GENERAL',
|
||||
relatedId: 'PROPOLIS',
|
||||
status: 'OPEN',
|
||||
});
|
||||
} finally {
|
||||
// Leave the rule as found: restored, or gone again if this test
|
||||
// created it (ensureDefaults then recreates it with its defaults).
|
||||
if (before === null) {
|
||||
await prisma.automationRule.delete({
|
||||
where: { triggerType: 'RAW_MATERIAL_LOW' },
|
||||
});
|
||||
} else {
|
||||
await prisma.automationRule.update({
|
||||
where: { triggerType: 'RAW_MATERIAL_LOW' },
|
||||
data: { active: before.active, config: restoredConfig(before) },
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user