- 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]>
298 lines
9.9 KiB
TypeScript
298 lines
9.9 KiB
TypeScript
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);
|
|
});
|
|
});
|
|
});
|