feat: T4b wave 7 - schema for phenology, honey movement ledger, shelf life
- Phenology reports, apiary phenology state and weather days with permissions for system roles - Honey movement ledger: BOTTLING movements and initialAmount backfilled from existing bottlings with the same gram rounding; the migration reports counts and aborts if the ledger invariant fails - Shelf life and event catalog columns; backup export/import of the new tables, old bundles keep importing, invariant check after imports - e2e: test timeout 20 s (app start in hooks exceeded 5 s under load) Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
This commit is contained in:
1 parent
326b3d9cdd
commit
22382d53a2
17 files changed
+1213
-39
No files matched your search
@@ -34,6 +34,7 @@ const BUSINESS_ENTITY_KEYS = [
|
||||
'varroaMeasurement',
|
||||
'swarmControlCheck',
|
||||
'winterCheck',
|
||||
'phenologyReport',
|
||||
'treatment',
|
||||
'feeding',
|
||||
'harvest',
|
||||
@@ -118,6 +119,9 @@ describe('BackupService', () => {
|
||||
upsert: jest.fn().mockResolvedValue({ id: 'notification-upserted' }),
|
||||
findUnique: jest.fn().mockResolvedValue(null),
|
||||
} as never;
|
||||
(tx as unknown as { $queryRaw: jest.Mock }).$queryRaw = jest
|
||||
.fn()
|
||||
.mockResolvedValue([]);
|
||||
tx.queen.update = jest.fn().mockResolvedValue({});
|
||||
tx.queen.findMany = jest.fn().mockResolvedValue([]);
|
||||
tx.document.findUnique = jest.fn().mockResolvedValue(null);
|
||||
@@ -1451,6 +1455,169 @@ describe('BackupService', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('phenology and honey ledger', () => {
|
||||
const importData = (data: Record<string, Row[]>, trusted = true) =>
|
||||
service.importAll(
|
||||
{ version: 3, exportedAt: '2026-01-01T00:00:00.000Z', data },
|
||||
'admin-1',
|
||||
{ trusted },
|
||||
);
|
||||
const created = (key: string): Row[] =>
|
||||
(tx[key].upsert.mock.calls as [{ create: Row }][]).map(
|
||||
([arg]) => arg.create,
|
||||
);
|
||||
|
||||
it('backs up PhenologyReport and excludes ApiaryPhenology and ApiaryWeatherDay with reasons', async () => {
|
||||
prisma.phenologyReport.findMany.mockResolvedValue([
|
||||
{ id: 'p1', phase: 'KIRSCHE', userId: 'known-user' },
|
||||
]);
|
||||
|
||||
const bundle = await service.exportAll();
|
||||
|
||||
expect(bundle.data.phenologyReport).toHaveLength(1);
|
||||
expect(bundle.data).not.toHaveProperty('apiaryPhenology');
|
||||
expect(bundle.data).not.toHaveProperty('apiaryWeatherDay');
|
||||
expect(MODELS_EXCLUDED_FROM_BACKUP.ApiaryPhenology).toMatch(
|
||||
/recomputed by daily-evaluation/,
|
||||
);
|
||||
expect(MODELS_EXCLUDED_FROM_BACKUP.ApiaryWeatherDay).toMatch(
|
||||
/refetched by the worker/,
|
||||
);
|
||||
expect(MODELS_EXCLUDED_FROM_BACKUP).not.toHaveProperty('PhenologyReport');
|
||||
});
|
||||
|
||||
it('restores phenology reports after the apiaries, remapping a user that does not exist', async () => {
|
||||
await importData({
|
||||
apiary: [{ id: 'apiary-1' }],
|
||||
phenologyReport: [
|
||||
{ id: 'p1', apiaryId: 'apiary-1', userId: 'known-user' },
|
||||
{ id: 'p2', apiaryId: null, userId: 'ghost' },
|
||||
],
|
||||
});
|
||||
|
||||
expect(created('phenologyReport').map((row) => row.userId)).toEqual([
|
||||
'known-user',
|
||||
'admin-1',
|
||||
]);
|
||||
expect(tx.apiary.upsert.mock.invocationCallOrder[0]).toBeLessThan(
|
||||
tx.phenologyReport.upsert.mock.invocationCallOrder[0],
|
||||
);
|
||||
});
|
||||
|
||||
it('an old bundle without initialAmount is recalculated like migration 6', async () => {
|
||||
await importData({
|
||||
honeyBatch: [
|
||||
{ id: 'hb-1', amount: 20 },
|
||||
{ id: 'hb-2', amount: null },
|
||||
{ id: 'hb-3', amount: 5 },
|
||||
],
|
||||
bottlingBatch: [
|
||||
{
|
||||
id: 'bt-1',
|
||||
honeyBatchId: 'hb-1',
|
||||
totalAmount: 7.5,
|
||||
createdAt: '2026-08-01T10:00:00.000Z',
|
||||
},
|
||||
{
|
||||
id: 'bt-2',
|
||||
honeyBatchId: 'hb-1',
|
||||
totalAmount: 2.0004,
|
||||
createdAt: '2026-08-02T10:00:00.000Z',
|
||||
},
|
||||
{ id: 'bt-3', honeyBatchId: 'hb-2', totalAmount: 1 },
|
||||
],
|
||||
});
|
||||
|
||||
expect(
|
||||
created('honeyBatch').map((row) => [row.id, row.initialAmount]),
|
||||
).toEqual([
|
||||
['hb-1', 29.5],
|
||||
['hb-2', undefined],
|
||||
['hb-3', 5],
|
||||
]);
|
||||
expect(created('batchMovement')).toEqual([
|
||||
expect.objectContaining({
|
||||
honeyBatchId: 'hb-1',
|
||||
sourceBottlingBatchId: 'bt-1',
|
||||
type: 'BOTTLING',
|
||||
quantity: -7500,
|
||||
createdAt: '2026-08-01T10:00:00.000Z',
|
||||
notes: 'Übernahme aus Abfüllung (T4)',
|
||||
}),
|
||||
expect.objectContaining({
|
||||
honeyBatchId: 'hb-1',
|
||||
sourceBottlingBatchId: 'bt-2',
|
||||
type: 'BOTTLING',
|
||||
quantity: -2000,
|
||||
}),
|
||||
]);
|
||||
// After the batch and the bottling it points at.
|
||||
expect(tx.bottlingBatch.upsert.mock.invocationCallOrder[0]).toBeLessThan(
|
||||
tx.batchMovement.upsert.mock.invocationCallOrder[0],
|
||||
);
|
||||
});
|
||||
|
||||
it('leaves a bundle that carries initialAmount untouched', async () => {
|
||||
await importData({
|
||||
honeyBatch: [{ id: 'hb-1', amount: 20, initialAmount: 30 }],
|
||||
bottlingBatch: [{ id: 'bt-1', honeyBatchId: 'hb-1', totalAmount: 10 }],
|
||||
batchMovement: [
|
||||
{
|
||||
id: 'm1',
|
||||
honeyBatchId: 'hb-1',
|
||||
type: 'BOTTLING',
|
||||
quantity: -10000,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
expect(created('honeyBatch')[0].initialAmount).toBe(30);
|
||||
expect(created('batchMovement').map((row) => row.id)).toEqual(['m1']);
|
||||
});
|
||||
|
||||
it('sets initialAmount from an unsealed file but books no movements', async () => {
|
||||
await importData(
|
||||
{
|
||||
honeyBatch: [{ id: 'hb-new', amount: 20 }],
|
||||
bottlingBatch: [
|
||||
{ id: 'bt-2', honeyBatchId: 'hb-new', totalAmount: 5 },
|
||||
],
|
||||
},
|
||||
false,
|
||||
);
|
||||
|
||||
expect(created('honeyBatch')[0].initialAmount).toBe(25);
|
||||
expect(tx.batchMovement.upsert).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('reports honey batches that break the invariant after the import, without failing it', async () => {
|
||||
const warn = jest.spyOn(Logger.prototype, 'warn').mockImplementation();
|
||||
(tx as unknown as { $queryRaw: jest.Mock }).$queryRaw = jest
|
||||
.fn()
|
||||
.mockResolvedValue([{ id: 'hb-1' }]);
|
||||
|
||||
const summary = await importData({
|
||||
honeyBatch: [{ id: 'hb-1', amount: 20, initialAmount: 30 }],
|
||||
});
|
||||
|
||||
expect(summary.imported.honeyLedgerViolations).toBe(1);
|
||||
expect(warn).toHaveBeenCalledWith(expect.stringContaining('hb-1'));
|
||||
warn.mockRestore();
|
||||
});
|
||||
|
||||
it('adds no violation count when the ledger is consistent', async () => {
|
||||
(tx as unknown as { $queryRaw: jest.Mock }).$queryRaw = jest
|
||||
.fn()
|
||||
.mockResolvedValue([]);
|
||||
|
||||
const summary = await importData({
|
||||
honeyBatch: [{ id: 'hb-1', amount: 20, initialAmount: 20 }],
|
||||
});
|
||||
|
||||
expect(summary.imported).not.toHaveProperty('honeyLedgerViolations');
|
||||
});
|
||||
});
|
||||
|
||||
// Drift guard: a model added to the schema has to be backed up or listed
|
||||
// in MODELS_EXCLUDED_FROM_BACKUP with its reason - never silently missing.
|
||||
describe('model coverage', () => {
|
||||
|
||||
Reference in new issue
Block a user