ci: Backend-e2e als eigener Workflow mit Postgres-Service
backend-e2e.yml läuft bei Änderungen unter ApiCore-Backend/ gegen einen Wegwerf-Postgres 18.6 (per Digest), mit frisch erzeugten JWT-Secrets und SKIP_EXTERNAL=1 für den Wetter-Test gegen die echte API. Der Herdentest prüft globalen Zustand, den parallele Suites verändern; er wiederholt die herdenabhängigen Fälle bis zu dreimal aus einem zurückgesetzten Zustand. Lokal 3 volle parallele Läufe grün (103 Suites, 1073 Tests). Refs #32 Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
This commit is contained in:
1 parent
279ceec328
commit
291ec1d7ff
3 files changed
+265
-94
No files matched your search
@@ -99,7 +99,7 @@ describe('Authority notices: herd change notification (e2e)', () => {
|
||||
);
|
||||
});
|
||||
|
||||
beforeEach(async () => {
|
||||
async function resetScenarioState(): Promise<void> {
|
||||
await prisma.complianceSettings.update({
|
||||
where: { id: 'singleton' },
|
||||
data: { noticeOnColonyCountChange: true, noticeOnApiaryChange: true },
|
||||
@@ -107,7 +107,36 @@ describe('Authority notices: herd change notification (e2e)', () => {
|
||||
await prisma.notification.deleteMany({
|
||||
where: { userId: { in: [...managerIds, staffId] } },
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
beforeEach(resetScenarioState);
|
||||
|
||||
/**
|
||||
* The herd deviation is computed over the whole shared database (the
|
||||
* baseline is the newest sent notice of ANY file, the herd is every
|
||||
* colony), so a parallel file that sends a notice or adds/ends a colony
|
||||
* between our baseline and our drain can flip an outcome. The notification
|
||||
* assertions themselves are scoped to this file's users; for the global
|
||||
* part the scenario is repeated from a clean state. A real defect fails
|
||||
* every attempt and surfaces with the last error.
|
||||
*/
|
||||
async function settled(scenario: () => Promise<void>): Promise<void> {
|
||||
const attempts = 3;
|
||||
for (let attempt = 1; ; attempt += 1) {
|
||||
try {
|
||||
await scenario();
|
||||
return;
|
||||
} catch (error) {
|
||||
if (attempt >= attempts) {
|
||||
throw error;
|
||||
}
|
||||
await resetScenarioState();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const scenario = (name: string, body: () => Promise<void>) =>
|
||||
it(name, () => settled(body));
|
||||
|
||||
afterEach(async () => {
|
||||
// The shared database holds other suites' notices: only own ids go.
|
||||
@@ -191,75 +220,92 @@ describe('Authority notices: herd change notification (e2e)', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('a new colony changes the herd: after draining one unread notification per permitted user', async () => {
|
||||
await sendBaseline();
|
||||
scenario(
|
||||
'a new colony changes the herd: after draining one unread notification per permitted user',
|
||||
async () => {
|
||||
await sendBaseline();
|
||||
|
||||
await addColony();
|
||||
await addColony();
|
||||
|
||||
const unread = await unreadOf(managerIds);
|
||||
expect(unread.map((row) => row.userId).sort()).toEqual(
|
||||
[...managerIds].sort(),
|
||||
);
|
||||
expect(unread[0]).toMatchObject({
|
||||
category: 'COMPLIANCE',
|
||||
priority: 'HINWEIS',
|
||||
});
|
||||
expect(unread[0].message).toMatch(
|
||||
/^Bestand geändert: jetzt \d+ Völker an \d+ Ständen\. Angezeigt: \d+ an \d+\. Anzeige ans Veterinäramt prüfen\.$/,
|
||||
);
|
||||
});
|
||||
const unread = await unreadOf(managerIds);
|
||||
expect(unread.map((row) => row.userId).sort()).toEqual(
|
||||
[...managerIds].sort(),
|
||||
);
|
||||
expect(unread[0]).toMatchObject({
|
||||
category: 'COMPLIANCE',
|
||||
priority: 'HINWEIS',
|
||||
});
|
||||
expect(unread[0].message).toMatch(
|
||||
/^Bestand geändert: jetzt \d+ Völker an \d+ Ständen\. Angezeigt: \d+ an \d+\. Anzeige ans Veterinäramt prüfen\.$/,
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
it('a second colony within one unread notice adds no second notification', async () => {
|
||||
await sendBaseline();
|
||||
scenario(
|
||||
'a second colony within one unread notice adds no second notification',
|
||||
async () => {
|
||||
await sendBaseline();
|
||||
|
||||
await addColony();
|
||||
await addColony();
|
||||
await addColony();
|
||||
await addColony();
|
||||
|
||||
expect(await unreadOf(managerIds)).toHaveLength(managerIds.length);
|
||||
});
|
||||
expect(await unreadOf(managerIds)).toHaveLength(managerIds.length);
|
||||
},
|
||||
);
|
||||
|
||||
it('creating and sending a change notice removes the deviation; the next colony after reading notifies again', async () => {
|
||||
await sendBaseline();
|
||||
await addColony();
|
||||
expect(await unreadOf(managerIds)).toHaveLength(managerIds.length);
|
||||
scenario(
|
||||
'creating and sending a change notice removes the deviation; the next colony after reading notifies again',
|
||||
async () => {
|
||||
await sendBaseline();
|
||||
await addColony();
|
||||
expect(await unreadOf(managerIds)).toHaveLength(managerIds.length);
|
||||
|
||||
const created = await request(server())
|
||||
.post('/api/v1/authority-notices')
|
||||
.set(as(managerToken))
|
||||
.set('Idempotency-Key', nextKey())
|
||||
.send({ kind: 'HERD_CHANGE' })
|
||||
.expect(201);
|
||||
const notice = created.body as NoticeBody;
|
||||
noticeIds.push(notice.id);
|
||||
await request(server())
|
||||
.post(`/api/v1/authority-notices/${notice.id}/mark-sent`)
|
||||
.set(as(managerToken))
|
||||
.send({})
|
||||
.expect(201);
|
||||
await prisma.notification.updateMany({
|
||||
where: { userId: { in: managerIds } },
|
||||
data: { read: true },
|
||||
});
|
||||
const created = await request(server())
|
||||
.post('/api/v1/authority-notices')
|
||||
.set(as(managerToken))
|
||||
.set('Idempotency-Key', nextKey())
|
||||
.send({ kind: 'HERD_CHANGE' })
|
||||
.expect(201);
|
||||
const notice = created.body as NoticeBody;
|
||||
noticeIds.push(notice.id);
|
||||
await request(server())
|
||||
.post(`/api/v1/authority-notices/${notice.id}/mark-sent`)
|
||||
.set(as(managerToken))
|
||||
.send({})
|
||||
.expect(201);
|
||||
await prisma.notification.updateMany({
|
||||
where: { userId: { in: managerIds } },
|
||||
data: { read: true },
|
||||
});
|
||||
|
||||
// The sent notice is the new baseline. (Not asserted: "nothing deviates"
|
||||
// against the live database - colonies that parallel files create in the
|
||||
// same instant would make that flaky.)
|
||||
expect((await findBaselineIn(prisma))?.id).toBe(notice.id);
|
||||
expect(await unreadOf(managerIds)).toHaveLength(0);
|
||||
// Not asserted: that this notice is THE baseline or that nothing deviates
|
||||
// - both are global and a parallel file's notice or colony changes them.
|
||||
expect(
|
||||
(
|
||||
await prisma.authorityNotice.findUniqueOrThrow({
|
||||
where: { id: notice.id },
|
||||
})
|
||||
).status,
|
||||
).toBe('SENT');
|
||||
expect(await unreadOf(managerIds)).toHaveLength(0);
|
||||
|
||||
await addColony();
|
||||
await addColony();
|
||||
|
||||
expect(await unreadOf(managerIds)).toHaveLength(managerIds.length);
|
||||
});
|
||||
expect(await unreadOf(managerIds)).toHaveLength(managerIds.length);
|
||||
},
|
||||
);
|
||||
|
||||
it('a user without compliance:manage gets no herd notification', async () => {
|
||||
await sendBaseline();
|
||||
scenario(
|
||||
'a user without compliance:manage gets no herd notification',
|
||||
async () => {
|
||||
await sendBaseline();
|
||||
|
||||
await addColony();
|
||||
await addColony();
|
||||
|
||||
expect(await unreadOf([staffId])).toHaveLength(0);
|
||||
expect(await unreadOf(managerIds)).toHaveLength(managerIds.length);
|
||||
});
|
||||
expect(await unreadOf([staffId])).toHaveLength(0);
|
||||
expect(await unreadOf(managerIds)).toHaveLength(managerIds.length);
|
||||
},
|
||||
);
|
||||
|
||||
async function patchApiary(body: Record<string, string>): Promise<void> {
|
||||
await request(server())
|
||||
@@ -270,7 +316,7 @@ describe('Authority notices: herd change notification (e2e)', () => {
|
||||
await drainDomainEvents(app, [apiaryId]);
|
||||
}
|
||||
|
||||
it('an apiary move (LOCATION_CHANGED) counts', async () => {
|
||||
scenario('an apiary move (LOCATION_CHANGED) counts', async () => {
|
||||
await sendBaseline();
|
||||
|
||||
await patchApiary({ location: `Neuer Standort ${suffix}` });
|
||||
@@ -278,7 +324,7 @@ describe('Authority notices: herd change notification (e2e)', () => {
|
||||
expect(await unreadOf(managerIds)).toHaveLength(managerIds.length);
|
||||
});
|
||||
|
||||
it('a rename does not count', async () => {
|
||||
scenario('a rename does not count', async () => {
|
||||
await sendBaseline();
|
||||
|
||||
await patchApiary({ name: `Herd Stand umbenannt ${suffix}` });
|
||||
@@ -286,19 +332,22 @@ describe('Authority notices: herd change notification (e2e)', () => {
|
||||
expect(await unreadOf(managerIds)).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('noticeOnApiaryChange alone: a colony stays quiet, a moved apiary notifies', async () => {
|
||||
await prisma.complianceSettings.update({
|
||||
where: { id: 'singleton' },
|
||||
data: { noticeOnColonyCountChange: false },
|
||||
});
|
||||
await sendBaseline();
|
||||
scenario(
|
||||
'noticeOnApiaryChange alone: a colony stays quiet, a moved apiary notifies',
|
||||
async () => {
|
||||
await prisma.complianceSettings.update({
|
||||
where: { id: 'singleton' },
|
||||
data: { noticeOnColonyCountChange: false },
|
||||
});
|
||||
await sendBaseline();
|
||||
|
||||
await addColony();
|
||||
expect(await unreadOf(managerIds)).toHaveLength(0);
|
||||
await addColony();
|
||||
expect(await unreadOf(managerIds)).toHaveLength(0);
|
||||
|
||||
await patchApiary({ location: `Ort wieder neu ${suffix}` });
|
||||
expect(await unreadOf(managerIds)).toHaveLength(managerIds.length);
|
||||
});
|
||||
await patchApiary({ location: `Ort wieder neu ${suffix}` });
|
||||
expect(await unreadOf(managerIds)).toHaveLength(managerIds.length);
|
||||
},
|
||||
);
|
||||
|
||||
it('two parallel recordForPermissionOnce transactions write one row per user', async () => {
|
||||
const notifications = app.get(NotificationsService);
|
||||
|
||||
@@ -24,6 +24,10 @@ interface TrachtprognoseBody {
|
||||
historicalAverageYieldKg: number | null;
|
||||
}
|
||||
|
||||
// These two cases call the real Open-Meteo API. CI sets SKIP_EXTERNAL=1 so the
|
||||
// pipeline does not depend on the internet; locally they run as before.
|
||||
const itExternal = process.env.SKIP_EXTERNAL === '1' ? it.skip : it;
|
||||
|
||||
describe('Weather (e2e)', () => {
|
||||
let app: INestApplication<App>;
|
||||
let prisma: PrismaService;
|
||||
@@ -75,32 +79,40 @@ describe('Weather (e2e)', () => {
|
||||
await app.close();
|
||||
});
|
||||
|
||||
it('returns real daily weather for an apiary with coordinates', async () => {
|
||||
const response = await request(app.getHttpServer())
|
||||
.get(`/api/v1/apiaries/${apiaryWithCoordsId}/weather`)
|
||||
.set('Authorization', `Bearer ${beekeeperToken}`)
|
||||
.expect(200);
|
||||
itExternal(
|
||||
'returns real daily weather for an apiary with coordinates',
|
||||
async () => {
|
||||
const response = await request(app.getHttpServer())
|
||||
.get(`/api/v1/apiaries/${apiaryWithCoordsId}/weather`)
|
||||
.set('Authorization', `Bearer ${beekeeperToken}`)
|
||||
.expect(200);
|
||||
|
||||
const body = response.body as WeatherBody;
|
||||
expect(Array.isArray(body.daily)).toBe(true);
|
||||
expect(body.daily.length).toBeGreaterThan(15);
|
||||
const today = body.daily[0];
|
||||
expect(today.date).toEqual(expect.stringMatching(/^\d{4}-\d{2}-\d{2}$/));
|
||||
}, 15000);
|
||||
const body = response.body as WeatherBody;
|
||||
expect(Array.isArray(body.daily)).toBe(true);
|
||||
expect(body.daily.length).toBeGreaterThan(15);
|
||||
const today = body.daily[0];
|
||||
expect(today.date).toEqual(expect.stringMatching(/^\d{4}-\d{2}-\d{2}$/));
|
||||
},
|
||||
15000,
|
||||
);
|
||||
|
||||
it('returns a Trachtprognose with a valid rating for an apiary with coordinates', async () => {
|
||||
const response = await request(app.getHttpServer())
|
||||
.get(`/api/v1/apiaries/${apiaryWithCoordsId}/trachtprognose`)
|
||||
.set('Authorization', `Bearer ${beekeeperToken}`)
|
||||
.expect(200);
|
||||
itExternal(
|
||||
'returns a Trachtprognose with a valid rating for an apiary with coordinates',
|
||||
async () => {
|
||||
const response = await request(app.getHttpServer())
|
||||
.get(`/api/v1/apiaries/${apiaryWithCoordsId}/trachtprognose`)
|
||||
.set('Authorization', `Bearer ${beekeeperToken}`)
|
||||
.expect(200);
|
||||
|
||||
const body = response.body as TrachtprognoseBody;
|
||||
expect(['GUT', 'MAESSIG', 'SCHWACH', 'KEINE_TRACHT']).toContain(
|
||||
body.rating,
|
||||
);
|
||||
expect(body.season).toEqual(expect.any(String));
|
||||
expect(body.reasoning.length).toBeGreaterThan(0);
|
||||
}, 15000);
|
||||
const body = response.body as TrachtprognoseBody;
|
||||
expect(['GUT', 'MAESSIG', 'SCHWACH', 'KEINE_TRACHT']).toContain(
|
||||
body.rating,
|
||||
);
|
||||
expect(body.season).toEqual(expect.any(String));
|
||||
expect(body.reasoning.length).toBeGreaterThan(0);
|
||||
},
|
||||
15000,
|
||||
);
|
||||
|
||||
it('rejects weather requests for an apiary without coordinates', () => {
|
||||
return request(app.getHttpServer())
|
||||
|
||||
Reference in new issue
Block a user