feat: T4 wave 3 - notifications per user, suggestions API, varroa bands, split/queen events
- Schema III: per-user notifications (broadcast fan-out migration, category, link, requiredPermissions filter), notification preferences and settings endpoints, calendar withdrawal switch, occupiedFrames - Suggestions API: list/summary with permission visibility and paging, accept/dismiss/reopen incl. bulk, SuggestionView on all responses - Varroa traffic light by season and method, young colonies, treatment effect window, sample-based percentage; shared withdrawal-window helper on Berlin calendar days; GET /varroa-measurements/thresholds - ColonySplit and QueenAssigned domain events with calendar and webhook handlers - Outbox claims compare against now() rounded to timestamp(3); fixes the flaky e2e claims of rows written with SQL now() Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
This commit is contained in:
1 parent
d99a697739
commit
f468dc5f75
87 files changed
+6022
-510
No files matched your search
@@ -0,0 +1,224 @@
|
||||
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 BandBody {
|
||||
yellowFrom: number | null;
|
||||
redAbove: number;
|
||||
reason: string;
|
||||
youngColony: boolean;
|
||||
}
|
||||
|
||||
interface ThresholdsBody {
|
||||
day: string;
|
||||
active: boolean;
|
||||
bands: Record<string, BandBody | undefined>;
|
||||
postTreatmentUntil: string | null;
|
||||
}
|
||||
|
||||
interface RatedMeasurementBody {
|
||||
id: string;
|
||||
rating: {
|
||||
light: string | null;
|
||||
reasonCode: string | null;
|
||||
band: BandBody | null;
|
||||
postTreatmentUntil: string | null;
|
||||
};
|
||||
}
|
||||
|
||||
describe('Varroa seasonal bands (e2e)', () => {
|
||||
let app: INestApplication<App>;
|
||||
let prisma: PrismaService;
|
||||
const suffix = Date.now();
|
||||
const email = `varroa-seasonal-${suffix}@example.com`;
|
||||
const password = 'SuperSecret123!';
|
||||
let userId: string;
|
||||
let token: string;
|
||||
let apiaryId: string;
|
||||
let productionColonyId: string;
|
||||
let youngColonyId: string;
|
||||
|
||||
const get = (path: string) =>
|
||||
request(app.getHttpServer())
|
||||
.get(`/api/v1${path}`)
|
||||
.set('Authorization', `Bearer ${token}`);
|
||||
|
||||
beforeAll(async () => {
|
||||
app = await createTestApp();
|
||||
prisma = app.get(PrismaService);
|
||||
const user = await prisma.user.create({
|
||||
data: {
|
||||
email,
|
||||
passwordHash: await bcrypt.hash(password, 12),
|
||||
name: 'Varroa seasonal',
|
||||
role: { connect: { key: 'BEEKEEPER' } },
|
||||
},
|
||||
});
|
||||
userId = user.id;
|
||||
token = (await login(app, email, password)).accessToken;
|
||||
apiaryId = (
|
||||
await prisma.apiary.create({ data: { name: `Stand ${suffix}` } })
|
||||
).id;
|
||||
productionColonyId = (
|
||||
await prisma.colony.create({
|
||||
data: {
|
||||
code: `VS-P-${suffix}`,
|
||||
apiaryId,
|
||||
colonyType: 'PRODUCTION_COLONY',
|
||||
},
|
||||
})
|
||||
).id;
|
||||
youngColonyId = (
|
||||
await prisma.colony.create({
|
||||
data: { code: `VS-Y-${suffix}`, apiaryId, colonyType: 'YOUNG_COLONY' },
|
||||
})
|
||||
).id;
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
const colonyIds = [productionColonyId, youngColonyId];
|
||||
await prisma.treatment.deleteMany({
|
||||
where: { colonyId: { in: colonyIds } },
|
||||
});
|
||||
await prisma.varroaMeasurement.deleteMany({
|
||||
where: { colonyId: { in: colonyIds } },
|
||||
});
|
||||
await prisma.colony.deleteMany({ where: { id: { in: colonyIds } } });
|
||||
await prisma.apiary.deleteMany({ where: { id: apiaryId } });
|
||||
await prisma.refreshToken.deleteMany({ where: { userId } });
|
||||
await prisma.auditLog.deleteMany({ where: { userId } });
|
||||
await prisma.user.deleteMany({ where: { id: userId } });
|
||||
await app.close();
|
||||
});
|
||||
|
||||
it('thresholds for a young colony are halved', async () => {
|
||||
const full = (
|
||||
await get(
|
||||
`/varroa-measurements/thresholds?colonyId=${productionColonyId}&date=2026-06-10`,
|
||||
).expect(200)
|
||||
).body as ThresholdsBody;
|
||||
const young = (
|
||||
await get(
|
||||
`/varroa-measurements/thresholds?colonyId=${youngColonyId}&date=2026-06-10`,
|
||||
).expect(200)
|
||||
).body as ThresholdsBody;
|
||||
|
||||
expect(full.bands.NATURAL_MITE_FALL).toMatchObject({
|
||||
yellowFrom: 5,
|
||||
redAbove: 10,
|
||||
reason: 'NATURAL_FALL_SEASON',
|
||||
youngColony: false,
|
||||
});
|
||||
expect(young.bands.NATURAL_MITE_FALL).toMatchObject({
|
||||
yellowFrom: 2.5,
|
||||
redAbove: 5,
|
||||
youngColony: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('thresholds return no band for powdered sugar in April', async () => {
|
||||
const body = (
|
||||
await get(
|
||||
`/varroa-measurements/thresholds?colonyId=${productionColonyId}&date=2026-04-10`,
|
||||
).expect(200)
|
||||
).body as ThresholdsBody;
|
||||
|
||||
expect(body.bands.POWDERED_SUGAR).toBeUndefined();
|
||||
expect(body.bands.NATURAL_MITE_FALL).toBeDefined();
|
||||
});
|
||||
|
||||
it('thresholds reject an unknown colony and an invalid date', async () => {
|
||||
await get('/varroa-measurements/thresholds?colonyId=unknown').expect(404);
|
||||
await get('/varroa-measurements/thresholds?date=2026-02-30').expect(400);
|
||||
await get('/varroa-measurements/thresholds?date=10.08.2026').expect(400);
|
||||
});
|
||||
|
||||
it('the old /threshold route is gone (404)', async () => {
|
||||
await get('/varroa-measurements/threshold').expect(404);
|
||||
});
|
||||
|
||||
it('colony measurements carry a rating with band and light', async () => {
|
||||
const create = (body: object) =>
|
||||
request(app.getHttpServer())
|
||||
.post('/api/v1/varroa-measurements')
|
||||
.set('Authorization', `Bearer ${token}`)
|
||||
.send(body)
|
||||
.expect(201);
|
||||
const red = (
|
||||
await create({
|
||||
colonyId: productionColonyId,
|
||||
date: '2026-08-10T00:00:00.000Z',
|
||||
method: 'NATURAL_MITE_FALL',
|
||||
miteCount: 22,
|
||||
durationMinutes: 1440,
|
||||
})
|
||||
).body as { id: string; calculatedValue: number };
|
||||
const percent = (
|
||||
await create({
|
||||
colonyId: productionColonyId,
|
||||
date: '2026-07-10T00:00:00.000Z',
|
||||
method: 'POWDERED_SUGAR',
|
||||
miteCount: 10,
|
||||
sampleBees: 500,
|
||||
})
|
||||
).body as { id: string; calculatedValue: number };
|
||||
expect(percent.calculatedValue).toBe(2);
|
||||
|
||||
const rated = (
|
||||
await get(`/colonies/${productionColonyId}/varroa-measurements`).expect(
|
||||
200,
|
||||
)
|
||||
).body as RatedMeasurementBody[];
|
||||
|
||||
const byId = new Map(rated.map((entry) => [entry.id, entry.rating]));
|
||||
expect(byId.get(red.id)).toMatchObject({
|
||||
light: 'red',
|
||||
reasonCode: 'NATURAL_FALL_SEASON',
|
||||
band: { yellowFrom: 5, redAbove: 10 },
|
||||
postTreatmentUntil: null,
|
||||
});
|
||||
expect(byId.get(percent.id)).toMatchObject({
|
||||
light: 'yellow',
|
||||
reasonCode: 'INFESTATION_SUMMER',
|
||||
});
|
||||
});
|
||||
|
||||
it('a measurement after a treatment has no light and names the recheck day', async () => {
|
||||
await prisma.treatment.create({
|
||||
data: {
|
||||
colonyId: youngColonyId,
|
||||
userId,
|
||||
date: new Date('2026-08-02T00:00:00.000Z'),
|
||||
startDate: new Date('2026-07-20T00:00:00.000Z'),
|
||||
product: 'Oxuvar',
|
||||
},
|
||||
});
|
||||
const created = (
|
||||
await request(app.getHttpServer())
|
||||
.post('/api/v1/varroa-measurements')
|
||||
.set('Authorization', `Bearer ${token}`)
|
||||
.send({
|
||||
colonyId: youngColonyId,
|
||||
date: '2026-08-07T00:00:00.000Z',
|
||||
method: 'NATURAL_MITE_FALL',
|
||||
calculatedValue: 12,
|
||||
})
|
||||
.expect(201)
|
||||
).body as { id: string };
|
||||
|
||||
const rated = (
|
||||
await get(`/colonies/${youngColonyId}/varroa-measurements`).expect(200)
|
||||
).body as RatedMeasurementBody[];
|
||||
|
||||
expect(rated.find((entry) => entry.id === created.id)?.rating).toEqual({
|
||||
light: 'POST_TREATMENT',
|
||||
reasonCode: 'POST_TREATMENT',
|
||||
band: null,
|
||||
postTreatmentUntil: '2026-08-16',
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user