feat: T6 wave 1 – compliance schema, permissions, lock order, backup
- Migrations (additive): veterinary medicines with lots and movement ledger, treatment medicine/lot/dose links, label particulars, authority notices, apiary restrictions, compliance settings and acknowledgements; notification category COMPLIANCE, added to non-empty Telegram lists. - Permissions veterinary-medicines:* and compliance:* with role grants. - LOCKABLE_TABLES: Apiary locks before Colony; T6 tables appended. - Backup exports and restores the new tables in FK order; unsealed files skip them and report dropped medicine links; an open TSK draft in the target wins. - describeNullContracts shared helper; Telegram backlog e2e no longer depends on the time of day. Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
This commit is contained in:
1 parent
7a1480138d
commit
8a31c42578
23 files changed
+2028
-84
No files matched your search
@@ -26,6 +26,15 @@ const SECTIONS_CHILD_FIRST = [
|
||||
'storageContainer',
|
||||
'extraction',
|
||||
'harvest',
|
||||
'veterinaryMedicineMovement',
|
||||
'treatment',
|
||||
'veterinaryMedicineLot',
|
||||
'veterinaryMedicine',
|
||||
'complianceAcknowledgement',
|
||||
'authorityNotice',
|
||||
'complianceSettings',
|
||||
'colony',
|
||||
'apiaryRestriction',
|
||||
'apiary',
|
||||
'product',
|
||||
'automationSequence',
|
||||
@@ -228,6 +237,118 @@ describe('Backup round trip (e2e)', () => {
|
||||
]) {
|
||||
remember('batchMovement', await prisma.batchMovement.create({ data }));
|
||||
}
|
||||
// T6: catalogue, pack, a treatment booked from it, the ledger rows, the
|
||||
// authority notice, the restriction and the acknowledgement.
|
||||
const colony = remember(
|
||||
'colony',
|
||||
await prisma.colony.create({
|
||||
data: {
|
||||
code: `B-${suffix}`,
|
||||
apiaryId: apiary.id,
|
||||
colonyType: 'PRODUCTION_COLONY',
|
||||
},
|
||||
}),
|
||||
);
|
||||
const medicine = remember(
|
||||
'veterinaryMedicine',
|
||||
await prisma.veterinaryMedicine.create({
|
||||
data: { name: `Mittel ${suffix}`, unit: 'ML', withdrawalPeriodDays: 0 },
|
||||
}),
|
||||
);
|
||||
const medicineLot = remember(
|
||||
'veterinaryMedicineLot',
|
||||
await prisma.veterinaryMedicineLot.create({
|
||||
data: {
|
||||
medicineId: medicine.id,
|
||||
purchasedOn: day,
|
||||
quantity: 100,
|
||||
supplier: 'Lieferant',
|
||||
userId,
|
||||
},
|
||||
}),
|
||||
);
|
||||
const treatment = remember(
|
||||
'treatment',
|
||||
await prisma.treatment.create({
|
||||
data: {
|
||||
colonyId: colony.id,
|
||||
userId,
|
||||
date: day,
|
||||
product: `Mittel ${suffix}`,
|
||||
medicineId: medicine.id,
|
||||
medicineLotId: medicineLot.id,
|
||||
doseQuantity: 5,
|
||||
},
|
||||
}),
|
||||
);
|
||||
remember(
|
||||
'veterinaryMedicineMovement',
|
||||
await prisma.veterinaryMedicineMovement.create({
|
||||
data: {
|
||||
lotId: medicineLot.id,
|
||||
type: 'TREATMENT',
|
||||
quantity: -5,
|
||||
treatmentId: treatment.id,
|
||||
userId,
|
||||
},
|
||||
}),
|
||||
);
|
||||
remember(
|
||||
'veterinaryMedicineMovement',
|
||||
await prisma.veterinaryMedicineMovement.create({
|
||||
data: {
|
||||
lotId: medicineLot.id,
|
||||
type: 'DISPOSAL',
|
||||
quantity: -2,
|
||||
reason: 'Abgelaufen',
|
||||
userId,
|
||||
},
|
||||
}),
|
||||
);
|
||||
remember(
|
||||
'apiaryRestriction',
|
||||
await prisma.apiaryRestriction.create({
|
||||
data: {
|
||||
apiaryId: apiary.id,
|
||||
disease: 'Test',
|
||||
startsOn: day,
|
||||
createdByUserId: userId,
|
||||
},
|
||||
}),
|
||||
);
|
||||
remember(
|
||||
'authorityNotice',
|
||||
await prisma.authorityNotice.create({
|
||||
data: {
|
||||
kind: 'TSK_REPORT',
|
||||
year: 2098,
|
||||
referenceDate: day,
|
||||
countedAt: day,
|
||||
snapshot: { version: 1, totalLiving: 1 },
|
||||
totalColonies: 1,
|
||||
openKey: `TSK_REPORT:2098:${suffix}`,
|
||||
createdByUserId: userId,
|
||||
},
|
||||
}),
|
||||
);
|
||||
remember(
|
||||
'complianceAcknowledgement',
|
||||
await prisma.complianceAcknowledgement.create({
|
||||
data: {
|
||||
itemKey: 'backup-test',
|
||||
subjectId: suffix,
|
||||
fingerprint: 'abc',
|
||||
note: 'Geprüft und in Ordnung',
|
||||
userId,
|
||||
},
|
||||
}),
|
||||
);
|
||||
remember(
|
||||
'complianceSettings',
|
||||
await prisma.complianceSettings.create({
|
||||
data: { id: `backup-${suffix}`, registrationNumber: 'R-1' },
|
||||
}),
|
||||
);
|
||||
const sequence = await prisma.automationSequence.create({
|
||||
data: {
|
||||
triggerType: 'RAW_MATERIAL_LOT_NUMBER_FORMAT',
|
||||
@@ -250,6 +371,23 @@ describe('Backup round trip (e2e)', () => {
|
||||
}, ROUND_TRIP_TIMEOUT_MS);
|
||||
|
||||
afterAll(async () => {
|
||||
// The T6 rows reference users and colonies of this spec: remove them,
|
||||
// children first, so the shared database does not collect compliance data.
|
||||
for (const section of [
|
||||
'veterinaryMedicineMovement',
|
||||
'treatment',
|
||||
'veterinaryMedicineLot',
|
||||
'veterinaryMedicine',
|
||||
'complianceAcknowledgement',
|
||||
'authorityNotice',
|
||||
'complianceSettings',
|
||||
'apiaryRestriction',
|
||||
] as const) {
|
||||
const delegate = prisma[section] as unknown as {
|
||||
deleteMany(args: { where: { id: { in: string[] } } }): Promise<unknown>;
|
||||
};
|
||||
await delegate.deleteMany({ where: { id: { in: [...own[section]] } } });
|
||||
}
|
||||
await app.close();
|
||||
});
|
||||
|
||||
@@ -328,6 +466,35 @@ describe('Backup round trip (e2e)', () => {
|
||||
calendarSyncOutboxEntry: await prisma.calendarSyncOutboxEntry.count(
|
||||
where('calendarSyncOutboxEntry'),
|
||||
),
|
||||
veterinaryMedicine: await prisma.veterinaryMedicine.count(
|
||||
where('veterinaryMedicine'),
|
||||
),
|
||||
veterinaryMedicineLot: await prisma.veterinaryMedicineLot.count(
|
||||
where('veterinaryMedicineLot'),
|
||||
),
|
||||
treatmentWithPack: await prisma.treatment.count({
|
||||
...where('treatment'),
|
||||
where: {
|
||||
id: { in: [...own.treatment] },
|
||||
medicineLotId: { not: null },
|
||||
},
|
||||
}),
|
||||
veterinaryMedicineMovement:
|
||||
await prisma.veterinaryMedicineMovement.count(
|
||||
where('veterinaryMedicineMovement'),
|
||||
),
|
||||
apiaryRestriction: await prisma.apiaryRestriction.count(
|
||||
where('apiaryRestriction'),
|
||||
),
|
||||
authorityNotice: await prisma.authorityNotice.count(
|
||||
where('authorityNotice'),
|
||||
),
|
||||
complianceAcknowledgement: await prisma.complianceAcknowledgement.count(
|
||||
where('complianceAcknowledgement'),
|
||||
),
|
||||
complianceSettings: await prisma.complianceSettings.count(
|
||||
where('complianceSettings'),
|
||||
),
|
||||
}).toEqual({
|
||||
productionBatch: 1,
|
||||
rawMaterialLot: 1,
|
||||
@@ -337,6 +504,14 @@ describe('Backup round trip (e2e)', () => {
|
||||
honeyBatchContainerAllocation: 1,
|
||||
automationSequence: 1,
|
||||
calendarSyncOutboxEntry: 1,
|
||||
veterinaryMedicine: 1,
|
||||
veterinaryMedicineLot: 1,
|
||||
treatmentWithPack: 1,
|
||||
veterinaryMedicineMovement: 2,
|
||||
apiaryRestriction: 1,
|
||||
authorityNotice: 1,
|
||||
complianceAcknowledgement: 1,
|
||||
complianceSettings: 1,
|
||||
});
|
||||
expect(ownRows(await backup.exportAll())).toEqual(exported);
|
||||
},
|
||||
@@ -387,6 +562,45 @@ describe('Backup round trip (e2e)', () => {
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it('takes no T6 compliance table from an unsealed file and reports them', async () => {
|
||||
const medicineId = `medicine-forged-${suffix}`;
|
||||
const noticeId = `notice-forged-${suffix}`;
|
||||
|
||||
const summary = await backup.importFile(
|
||||
{
|
||||
version: 2,
|
||||
exportedAt: '2026-01-01T00:00:00.000Z',
|
||||
data: {
|
||||
veterinaryMedicine: [
|
||||
{ id: medicineId, name: 'Gefälscht', unit: 'ML' },
|
||||
],
|
||||
authorityNotice: [
|
||||
{
|
||||
id: noticeId,
|
||||
kind: 'REGISTRATION',
|
||||
referenceDate: '2026-01-01T00:00:00.000Z',
|
||||
countedAt: '2026-01-01T00:00:00.000Z',
|
||||
snapshot: {},
|
||||
totalColonies: 1,
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
userId,
|
||||
);
|
||||
|
||||
expect([...(summary.skipped ?? [])].sort()).toEqual([
|
||||
'authorityNotice',
|
||||
'veterinaryMedicine',
|
||||
]);
|
||||
expect(
|
||||
await prisma.veterinaryMedicine.findUnique({ where: { id: medicineId } }),
|
||||
).toBeNull();
|
||||
expect(
|
||||
await prisma.authorityNotice.findUnique({ where: { id: noticeId } }),
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it('removes the file a restored document row replaced', async () => {
|
||||
const directory = path.join(
|
||||
path.resolve(process.env.DOCUMENTS_STORAGE_PATH ?? './uploads'),
|
||||
|
||||
@@ -0,0 +1,116 @@
|
||||
import { PERMISSION_CATALOG } from '../src/auth/permissions/permission-catalog';
|
||||
import { PrismaService } from '../src/prisma/prisma.service';
|
||||
|
||||
const NEW_KEYS = [
|
||||
'veterinary-medicines:read',
|
||||
'veterinary-medicines:create',
|
||||
'veterinary-medicines:update',
|
||||
'compliance:read',
|
||||
'compliance:manage',
|
||||
] as const;
|
||||
|
||||
/**
|
||||
* The migrations grant the T6 permissions to the system roles (the boot-time
|
||||
* catalog sync only grants new permissions to ADMIN). Checked against the
|
||||
* migrated test database.
|
||||
*/
|
||||
describe('T6 permissions (e2e)', () => {
|
||||
let prisma: PrismaService;
|
||||
|
||||
beforeAll(() => {
|
||||
prisma = new PrismaService();
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await prisma.$disconnect();
|
||||
});
|
||||
|
||||
async function newKeysOf(roleKey: string): Promise<string[]> {
|
||||
const grants = await prisma.rolePermission.findMany({
|
||||
where: {
|
||||
role: { key: roleKey },
|
||||
permission: { key: { in: [...NEW_KEYS] } },
|
||||
},
|
||||
include: { permission: { select: { key: true } } },
|
||||
});
|
||||
return grants.map((grant) => grant.permission.key).sort();
|
||||
}
|
||||
|
||||
it('BEEKEEPER holds compliance:manage and veterinary-medicines:update', async () => {
|
||||
const keys = await newKeysOf('BEEKEEPER');
|
||||
|
||||
expect(keys).toEqual([...NEW_KEYS].sort());
|
||||
expect(keys).toContain('compliance:manage');
|
||||
expect(keys).toContain('veterinary-medicines:update');
|
||||
});
|
||||
|
||||
it('ADMIN holds all five', async () => {
|
||||
expect(await newKeysOf('ADMIN')).toEqual([...NEW_KEYS].sort());
|
||||
});
|
||||
|
||||
it('STAFF holds compliance:read and veterinary-medicines:create but not compliance:manage', async () => {
|
||||
const keys = await newKeysOf('STAFF');
|
||||
|
||||
expect(keys).toContain('compliance:read');
|
||||
expect(keys).toContain('veterinary-medicines:create');
|
||||
expect(keys).not.toContain('compliance:manage');
|
||||
expect(keys).toEqual(
|
||||
[
|
||||
'veterinary-medicines:read',
|
||||
'veterinary-medicines:create',
|
||||
'veterinary-medicines:update',
|
||||
'compliance:read',
|
||||
].sort(),
|
||||
);
|
||||
});
|
||||
|
||||
it('READONLY holds the two read permissions only', async () => {
|
||||
expect(await newKeysOf('READONLY')).toEqual(
|
||||
['compliance:read', 'veterinary-medicines:read'].sort(),
|
||||
);
|
||||
});
|
||||
|
||||
it('API_CLIENT holds none of the five new permissions', async () => {
|
||||
expect(await newKeysOf('API_CLIENT')).toEqual([]);
|
||||
});
|
||||
|
||||
it('the database rows carry the catalog labels, groups and sort order', async () => {
|
||||
const rows = await prisma.permission.findMany({
|
||||
where: { key: { in: [...NEW_KEYS] } },
|
||||
});
|
||||
|
||||
expect(rows).toHaveLength(NEW_KEYS.length);
|
||||
for (const row of rows) {
|
||||
const entry = PERMISSION_CATALOG.find((e) => e.key === row.key);
|
||||
expect(entry).toBeDefined();
|
||||
expect({
|
||||
resource: row.resource,
|
||||
action: row.action,
|
||||
labelDe: row.labelDe,
|
||||
groupDe: row.groupDe,
|
||||
sortOrder: row.sortOrder,
|
||||
}).toEqual({
|
||||
resource: entry?.resource,
|
||||
action: entry?.action,
|
||||
labelDe: entry?.labelDe,
|
||||
groupDe: entry?.groupDe,
|
||||
sortOrder: entry?.sortOrder,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
it('the catalog groups the new permissions at 3700 and 3800', () => {
|
||||
const order = (key: string) =>
|
||||
PERMISSION_CATALOG.find((e) => e.key === key)?.sortOrder;
|
||||
const group = (key: string) =>
|
||||
PERMISSION_CATALOG.find((e) => e.key === key)?.groupDe;
|
||||
|
||||
expect(order('veterinary-medicines:read')).toBe(3700);
|
||||
expect(order('veterinary-medicines:create')).toBe(3701);
|
||||
expect(order('veterinary-medicines:update')).toBe(3702);
|
||||
expect(order('compliance:read')).toBe(3800);
|
||||
expect(order('compliance:manage')).toBe(3801);
|
||||
expect(group('veterinary-medicines:read')).toBe('Tierarzneimittel');
|
||||
expect(group('compliance:manage')).toBe('Prüfbereitschaft & Behörden');
|
||||
});
|
||||
});
|
||||
@@ -357,7 +357,7 @@ describe('Notifications (e2e)', () => {
|
||||
digestTime: '06:30',
|
||||
quietFrom: '21:00',
|
||||
quietUntil: '06:30',
|
||||
telegramCategories: ['TASK_DUE', 'DIGEST'],
|
||||
telegramCategories: ['TASK_DUE', 'DIGEST', 'COMPLIANCE'],
|
||||
telegramDetail: true,
|
||||
});
|
||||
// configured / botUsername follow the environment of the process.
|
||||
@@ -399,6 +399,24 @@ describe('Notifications (e2e)', () => {
|
||||
expect(stored.digestTime).toBe('07:15');
|
||||
});
|
||||
|
||||
it('accepts COMPLIANCE as a Telegram category and rejects an unknown one', async () => {
|
||||
const response = await request(server())
|
||||
.patch('/api/v1/profile/notification-settings')
|
||||
.set(as('staff'))
|
||||
.send({ telegramCategories: ['COMPLIANCE', 'DIGEST'] })
|
||||
.expect(200);
|
||||
expect((response.body as SettingsBody).telegramCategories).toEqual([
|
||||
'COMPLIANCE',
|
||||
'DIGEST',
|
||||
]);
|
||||
|
||||
await request(server())
|
||||
.patch('/api/v1/profile/notification-settings')
|
||||
.set(as('staff'))
|
||||
.send({ telegramCategories: ['AUTHORITY'] })
|
||||
.expect(400);
|
||||
});
|
||||
|
||||
it('rejects a digest time outside 05:00 to 12:00 and a lone quiet-hour value', async () => {
|
||||
const early = await request(server())
|
||||
.patch('/api/v1/profile/notification-settings')
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
import {
|
||||
ScratchDatabase,
|
||||
createScratchDatabase,
|
||||
} from './utils/migration-harness';
|
||||
|
||||
const BEFORE_COMPLIANCE_CATEGORY =
|
||||
'20261016100300_t6_notification_category_compliance';
|
||||
const COMPLIANCE_DEFAULT = '20261016100400_t6_notification_compliance_default';
|
||||
|
||||
const TIMEOUT_MS = 240_000;
|
||||
|
||||
async function seedUsers(db: ScratchDatabase): Promise<void> {
|
||||
await db.query(
|
||||
`INSERT INTO "Role" ("id", "key", "name", "updatedAt")
|
||||
VALUES ('role-1', 'MIG_TEST', 'Migration test', now())`,
|
||||
);
|
||||
for (const id of ['user-a', 'user-b', 'user-c', 'user-d']) {
|
||||
await db.query(
|
||||
`INSERT INTO "User" ("id", "email", "passwordHash", "name", "roleId", "updatedAt")
|
||||
VALUES ($1, $2, 'x', $1, 'role-1', now())`,
|
||||
[id, `${id}@example.com`],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
describe('T6 migrations (e2e)', () => {
|
||||
let db: ScratchDatabase | undefined;
|
||||
|
||||
afterEach(async () => {
|
||||
await db?.drop();
|
||||
db = undefined;
|
||||
});
|
||||
|
||||
it(
|
||||
'adds COMPLIANCE to the Telegram categories of every existing preference and keeps the rest, an empty list (opt-out) stays empty',
|
||||
async () => {
|
||||
db = await createScratchDatabase(BEFORE_COMPLIANCE_CATEGORY);
|
||||
await seedUsers(db);
|
||||
await db.query(
|
||||
`INSERT INTO "NotificationPreference" ("userId", "telegramCategories", "updatedAt") VALUES
|
||||
('user-a', DEFAULT, now()),
|
||||
('user-b', '{SYSTEM}', now()),
|
||||
('user-c', '{}', now()),
|
||||
('user-d', '{DIGEST,TASK_DUE,SEASON}', now())`,
|
||||
);
|
||||
|
||||
await db.migrateThrough(COMPLIANCE_DEFAULT);
|
||||
|
||||
expect(
|
||||
await db.query(
|
||||
`SELECT "userId", "telegramCategories"::text AS "categories"
|
||||
FROM "NotificationPreference" ORDER BY "userId"`,
|
||||
),
|
||||
).toEqual([
|
||||
{ userId: 'user-a', categories: '{TASK_DUE,DIGEST,COMPLIANCE}' },
|
||||
{ userId: 'user-b', categories: '{SYSTEM,COMPLIANCE}' },
|
||||
{ userId: 'user-c', categories: '{}' },
|
||||
{ userId: 'user-d', categories: '{DIGEST,TASK_DUE,SEASON,COMPLIANCE}' },
|
||||
]);
|
||||
},
|
||||
TIMEOUT_MS,
|
||||
);
|
||||
|
||||
it(
|
||||
'gives a preference created afterwards the new default and lists COMPLIANCE once',
|
||||
async () => {
|
||||
db = await createScratchDatabase(BEFORE_COMPLIANCE_CATEGORY);
|
||||
await seedUsers(db);
|
||||
|
||||
await db.migrateThrough(COMPLIANCE_DEFAULT);
|
||||
await db.query(
|
||||
`INSERT INTO "NotificationPreference" ("userId", "updatedAt") VALUES ('user-a', now())`,
|
||||
);
|
||||
|
||||
expect(
|
||||
await db.query(
|
||||
`SELECT "telegramCategories"::text AS "categories" FROM "NotificationPreference"`,
|
||||
),
|
||||
).toEqual([{ categories: '{TASK_DUE,DIGEST,COMPLIANCE}' }]);
|
||||
expect(
|
||||
await db.query(
|
||||
`SELECT enum_range(NULL::"NotificationCategory")::text AS "values"`,
|
||||
),
|
||||
).toEqual([
|
||||
{
|
||||
values:
|
||||
'{TASK_DUE,DIGEST,SUGGESTION,WITHDRAWAL,SEASON,SYSTEM,COMPLIANCE}',
|
||||
},
|
||||
]);
|
||||
},
|
||||
TIMEOUT_MS,
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,297 @@
|
||||
import { PrismaService } from '../src/prisma/prisma.service';
|
||||
|
||||
/**
|
||||
* Schema-level guarantees of the T6 migrations that the Prisma schema alone
|
||||
* cannot express (CHECK constraints, the JSON default, the nullable unique
|
||||
* key, RESTRICT) against a real PostgreSQL. The test database is shared and
|
||||
* not empty: every row here is created by this file and removed again, and
|
||||
* the singleton tables are exercised through rows with their own id.
|
||||
*/
|
||||
describe('T6 schema (e2e)', () => {
|
||||
let prisma: PrismaService;
|
||||
const suffix = `${Date.now()}-${Math.round(Math.random() * 1e6)}`;
|
||||
const CHECK_VIOLATION = /check constraint|violates/i;
|
||||
|
||||
let userId: string;
|
||||
let apiaryId: string;
|
||||
let colonyId: string;
|
||||
let treatmentId: string;
|
||||
const medicineIds: string[] = [];
|
||||
const lotIds: string[] = [];
|
||||
const noticeIds: string[] = [];
|
||||
const restrictionIds: string[] = [];
|
||||
const templateIds: string[] = [];
|
||||
const settingsIds: string[] = [];
|
||||
|
||||
async function createMedicine(label: string): Promise<string> {
|
||||
const medicine = await prisma.veterinaryMedicine.create({
|
||||
data: { name: `T6 ${label} ${suffix}`, unit: 'ML' },
|
||||
});
|
||||
medicineIds.push(medicine.id);
|
||||
return medicine.id;
|
||||
}
|
||||
|
||||
async function createLot(
|
||||
medicineId: string,
|
||||
quantity = 100,
|
||||
): Promise<string> {
|
||||
const lot = await prisma.veterinaryMedicineLot.create({
|
||||
data: { medicineId, purchasedOn: new Date('2026-08-01'), quantity },
|
||||
});
|
||||
lotIds.push(lot.id);
|
||||
return lot.id;
|
||||
}
|
||||
|
||||
beforeAll(async () => {
|
||||
prisma = new PrismaService();
|
||||
const role = await prisma.role.findUniqueOrThrow({
|
||||
where: { key: 'READONLY' },
|
||||
});
|
||||
const user = await prisma.user.create({
|
||||
data: {
|
||||
email: `t6-schema-${suffix}@example.com`,
|
||||
passwordHash: 'x',
|
||||
name: 'T6 schema',
|
||||
roleId: role.id,
|
||||
},
|
||||
});
|
||||
userId = user.id;
|
||||
const apiary = await prisma.apiary.create({
|
||||
data: { name: `T6 Stand ${suffix}` },
|
||||
});
|
||||
apiaryId = apiary.id;
|
||||
const colony = await prisma.colony.create({
|
||||
data: { code: `T6-${suffix}`, apiaryId, colonyType: 'PRODUCTION_COLONY' },
|
||||
});
|
||||
colonyId = colony.id;
|
||||
const treatment = await prisma.treatment.create({
|
||||
data: { colonyId, userId, date: new Date('2026-08-02'), product: 'T6' },
|
||||
});
|
||||
treatmentId = treatment.id;
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await prisma.veterinaryMedicineMovement.deleteMany({
|
||||
where: { lotId: { in: lotIds } },
|
||||
});
|
||||
await prisma.treatment.deleteMany({ where: { id: treatmentId } });
|
||||
await prisma.veterinaryMedicineLot.deleteMany({
|
||||
where: { id: { in: lotIds } },
|
||||
});
|
||||
await prisma.veterinaryMedicine.deleteMany({
|
||||
where: { id: { in: medicineIds } },
|
||||
});
|
||||
await prisma.apiaryRestriction.deleteMany({
|
||||
where: { id: { in: restrictionIds } },
|
||||
});
|
||||
await prisma.authorityNotice.deleteMany({
|
||||
where: { id: { in: noticeIds } },
|
||||
});
|
||||
await prisma.labelTemplate.deleteMany({
|
||||
where: { id: { in: templateIds } },
|
||||
});
|
||||
await prisma.complianceSettings.deleteMany({
|
||||
where: { id: { in: settingsIds } },
|
||||
});
|
||||
await prisma.colony.deleteMany({ where: { id: colonyId } });
|
||||
await prisma.apiary.deleteMany({ where: { id: apiaryId } });
|
||||
await prisma.user.deleteMany({ where: { id: userId } });
|
||||
await prisma.$disconnect();
|
||||
});
|
||||
|
||||
describe('veterinary medicines', () => {
|
||||
it('the CHECK rejects a lot with quantity 0', async () => {
|
||||
const medicineId = await createMedicine('lot0');
|
||||
|
||||
await expect(createLot(medicineId, 0)).rejects.toThrow(CHECK_VIOLATION);
|
||||
await expect(createLot(medicineId, -1)).rejects.toThrow(CHECK_VIOLATION);
|
||||
});
|
||||
|
||||
it('the CHECK rejects a movement with quantity 0', async () => {
|
||||
const lotId = await createLot(await createMedicine('mov0'));
|
||||
|
||||
await expect(
|
||||
prisma.veterinaryMedicineMovement.create({
|
||||
data: { lotId, type: 'CORRECTION', quantity: 0 },
|
||||
}),
|
||||
).rejects.toThrow(CHECK_VIOLATION);
|
||||
});
|
||||
|
||||
it('the CHECK rejects a TREATMENT movement without treatmentId and a CORRECTION movement with one', async () => {
|
||||
const lotId = await createLot(await createMedicine('type'));
|
||||
|
||||
await expect(
|
||||
prisma.veterinaryMedicineMovement.create({
|
||||
data: { lotId, type: 'TREATMENT', quantity: -5 },
|
||||
}),
|
||||
).rejects.toThrow(CHECK_VIOLATION);
|
||||
await expect(
|
||||
prisma.veterinaryMedicineMovement.create({
|
||||
data: { lotId, type: 'CORRECTION', quantity: -5, treatmentId },
|
||||
}),
|
||||
).rejects.toThrow(CHECK_VIOLATION);
|
||||
await expect(
|
||||
prisma.veterinaryMedicineMovement.create({
|
||||
data: { lotId, type: 'DISPOSAL', quantity: -5, treatmentId },
|
||||
}),
|
||||
).rejects.toThrow(CHECK_VIOLATION);
|
||||
});
|
||||
|
||||
it('accepts the valid combinations', async () => {
|
||||
const lotId = await createLot(await createMedicine('valid'));
|
||||
|
||||
const booked = await prisma.veterinaryMedicineMovement.create({
|
||||
data: { lotId, type: 'TREATMENT', quantity: -5, treatmentId },
|
||||
});
|
||||
const corrected = await prisma.veterinaryMedicineMovement.create({
|
||||
data: { lotId, type: 'CORRECTION', quantity: 2.5, reason: 'Inventur' },
|
||||
});
|
||||
|
||||
expect(booked.treatmentId).toBe(treatmentId);
|
||||
expect(corrected.treatmentId).toBeNull();
|
||||
});
|
||||
|
||||
it('a medicine with lots cannot be deleted (RESTRICT)', async () => {
|
||||
const medicineId = await createMedicine('restrict');
|
||||
await createLot(medicineId);
|
||||
|
||||
await expect(
|
||||
prisma.veterinaryMedicine.delete({ where: { id: medicineId } }),
|
||||
).rejects.toThrow();
|
||||
expect(
|
||||
await prisma.veterinaryMedicine.count({ where: { id: medicineId } }),
|
||||
).toBe(1);
|
||||
});
|
||||
|
||||
it('a lot with movements cannot be deleted (RESTRICT)', async () => {
|
||||
const lotId = await createLot(await createMedicine('restrict2'));
|
||||
await prisma.veterinaryMedicineMovement.create({
|
||||
data: { lotId, type: 'CORRECTION', quantity: 1 },
|
||||
});
|
||||
|
||||
await expect(
|
||||
prisma.veterinaryMedicineLot.delete({ where: { id: lotId } }),
|
||||
).rejects.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe('label particulars', () => {
|
||||
it('rejects minXHeightMm below 0.5 and above 5', async () => {
|
||||
const create = async (minXHeightMm: number) => {
|
||||
const template = await prisma.labelTemplate.create({
|
||||
data: { name: `T6 ${suffix}`, minXHeightMm },
|
||||
});
|
||||
templateIds.push(template.id);
|
||||
return template;
|
||||
};
|
||||
|
||||
await expect(create(0.49)).rejects.toThrow(CHECK_VIOLATION);
|
||||
await expect(create(5.01)).rejects.toThrow(CHECK_VIOLATION);
|
||||
await expect(create(0.5)).resolves.toMatchObject({ minXHeightMm: 0.5 });
|
||||
await expect(create(5)).resolves.toMatchObject({ minXHeightMm: 5 });
|
||||
});
|
||||
|
||||
it('defaults minXHeightMm to 1.2', async () => {
|
||||
const template = await prisma.labelTemplate.create({
|
||||
data: { name: `T6 default ${suffix}` },
|
||||
});
|
||||
templateIds.push(template.id);
|
||||
|
||||
expect(template.minXHeightMm).toBe(1.2);
|
||||
});
|
||||
});
|
||||
|
||||
describe('authority compliance', () => {
|
||||
it('rejects an ApiaryRestriction that ends before it starts', async () => {
|
||||
const create = async (endsOn: Date | null) => {
|
||||
const restriction = await prisma.apiaryRestriction.create({
|
||||
data: {
|
||||
apiaryId,
|
||||
disease: 'T6',
|
||||
startsOn: new Date('2026-09-10'),
|
||||
endsOn,
|
||||
},
|
||||
});
|
||||
restrictionIds.push(restriction.id);
|
||||
return restriction;
|
||||
};
|
||||
|
||||
await expect(create(new Date('2026-09-09'))).rejects.toThrow(
|
||||
CHECK_VIOLATION,
|
||||
);
|
||||
await expect(create(new Date('2026-09-10'))).resolves.toBeDefined();
|
||||
await expect(create(null)).resolves.toBeDefined();
|
||||
});
|
||||
|
||||
it('an apiary with restrictions cannot be deleted (RESTRICT)', async () => {
|
||||
const lonely = await prisma.apiary.create({
|
||||
data: { name: `T6 Sperrstand ${suffix}` },
|
||||
});
|
||||
const restriction = await prisma.apiaryRestriction.create({
|
||||
data: {
|
||||
apiaryId: lonely.id,
|
||||
disease: 'T6',
|
||||
startsOn: new Date('2026-09-10'),
|
||||
},
|
||||
});
|
||||
restrictionIds.push(restriction.id);
|
||||
|
||||
await expect(
|
||||
prisma.apiary.delete({ where: { id: lonely.id } }),
|
||||
).rejects.toThrow();
|
||||
|
||||
await prisma.apiaryRestriction.delete({ where: { id: restriction.id } });
|
||||
await prisma.apiary.delete({ where: { id: lonely.id } });
|
||||
});
|
||||
|
||||
it('ComplianceSettings default netQuantityDigitRules equals the four rules of the spec', async () => {
|
||||
const settings = await prisma.complianceSettings.create({
|
||||
data: { id: `t6-${suffix}` },
|
||||
});
|
||||
settingsIds.push(settings.id);
|
||||
|
||||
expect(settings.netQuantityDigitRules).toEqual([
|
||||
{ maxGrams: 50, minMm: 2 },
|
||||
{ maxGrams: 200, minMm: 3 },
|
||||
{ maxGrams: 1000, minMm: 4 },
|
||||
{ maxGrams: null, minMm: 6 },
|
||||
]);
|
||||
expect(settings).toMatchObject({
|
||||
noticeOnColonyCountChange: true,
|
||||
noticeOnApiaryChange: true,
|
||||
tskReferenceDay: '01-01',
|
||||
tskReportDueDay: null,
|
||||
tskExcludedColonyTypes: [],
|
||||
});
|
||||
});
|
||||
|
||||
it('openKey is unique, several rows with openKey NULL are allowed', async () => {
|
||||
const notice = (openKey: string | null) =>
|
||||
prisma.authorityNotice.create({
|
||||
data: {
|
||||
kind: 'TSK_REPORT',
|
||||
year: 2099,
|
||||
referenceDate: new Date('2099-01-01'),
|
||||
countedAt: new Date('2099-01-01'),
|
||||
snapshot: { version: 1 },
|
||||
totalColonies: 3,
|
||||
openKey,
|
||||
},
|
||||
});
|
||||
const key = `TSK_REPORT:2099:${suffix}`;
|
||||
|
||||
const first = await notice(key);
|
||||
noticeIds.push(first.id);
|
||||
await expect(notice(key)).rejects.toThrow();
|
||||
const sentA = await notice(null);
|
||||
const sentB = await notice(null);
|
||||
noticeIds.push(sentA.id, sentB.id);
|
||||
|
||||
expect(
|
||||
await prisma.authorityNotice.count({
|
||||
where: { id: { in: [first.id, sentA.id, sentB.id] } },
|
||||
}),
|
||||
).toBe(3);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -998,13 +998,20 @@ describe('Telegram delivery (e2e)', () => {
|
||||
|
||||
it('an old backlog never reaches a chat linked later', async () => {
|
||||
const user = await createUnlinkedUser('backlog');
|
||||
const waiting = await queue(user.id, { message: 'alt' });
|
||||
const backlogText = `Alter Rückstand ${suffix}`;
|
||||
const waiting = await queue(user.id, { message: backlogText });
|
||||
await prisma.telegramLink.create({
|
||||
data: { userId: user.id, chatId: user.chatId, telegramFirstName: 'x' },
|
||||
});
|
||||
// Quiet hours all day keep the "Telegram verbunden" notice (SYSTEM)
|
||||
// back whatever the time of day; the check below does not depend on it.
|
||||
await prisma.notificationPreference.update({
|
||||
where: { userId: user.id },
|
||||
data: { telegramEnabled: true },
|
||||
data: {
|
||||
telegramEnabled: true,
|
||||
quietFrom: '00:00',
|
||||
quietUntil: '23:59',
|
||||
},
|
||||
});
|
||||
const token = await newLinkToken(user.id);
|
||||
const newChat = chatBase + 9_400n;
|
||||
@@ -1014,8 +1021,14 @@ describe('Telegram delivery (e2e)', () => {
|
||||
|
||||
expect((await deliveryOf(waiting.deliveryId)).status).toBe('SKIPPED');
|
||||
await drainFor(user.id);
|
||||
expect(telegram.sentTo(newChat)).toHaveLength(0);
|
||||
expect(telegram.sentTo(user.chatId)).toHaveLength(0);
|
||||
// The backlog text reaches neither chat; the link notice may.
|
||||
for (const chatId of [newChat, user.chatId]) {
|
||||
expect(
|
||||
telegram
|
||||
.sentTo(chatId)
|
||||
.filter((sent) => sent.text.includes(backlogText)),
|
||||
).toEqual([]);
|
||||
}
|
||||
});
|
||||
|
||||
// Last on purpose: it uses up the 10-per-minute budget of the route.
|
||||
|
||||
Reference in new issue
Block a user