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
+193
-22
No files matched your search
@@ -0,0 +1,110 @@
|
|||||||
|
name: Backend E2E
|
||||||
|
|
||||||
|
# Fuehrt `npm run test:e2e` (Jest, maxWorkers 4, siehe test/jest-e2e.json)
|
||||||
|
# gegen einen frischen Postgres-Service-Container aus. Das Backend-Image
|
||||||
|
# (backend-image.yml) fuehrt nur Lint, Typecheck und Unit-Tests aus, weil der
|
||||||
|
# Docker-Build keine Datenbank hat; diese Pipeline schliesst die Luecke.
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
paths:
|
||||||
|
- "ApiCore-Backend/**"
|
||||||
|
- ".gitea/workflows/backend-e2e.yml"
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
|
# Ein neuer Push auf denselben Ref ersetzt den laufenden Lauf.
|
||||||
|
concurrency:
|
||||||
|
group: backend-e2e-${{ gitea.ref }}
|
||||||
|
cancel-in-progress: true
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
e2e:
|
||||||
|
runs-on: docker-builder
|
||||||
|
timeout-minutes: 30
|
||||||
|
# Gitea act_runner unterstuetzt `services:`. Annahme (beim ersten Lauf
|
||||||
|
# pruefen): der Job-Container teilt das Netz der Services und erreicht
|
||||||
|
# Postgres unter dem Service-Namen `postgres`. Laeuft der Runner
|
||||||
|
# stattdessen im Host-Modus, ist der Port auf localhost veroeffentlicht;
|
||||||
|
# der Schritt "Datenbank-Host ermitteln" probiert beide Wege.
|
||||||
|
services:
|
||||||
|
postgres:
|
||||||
|
# Gleiche Major-Version wie Produktion (deploy/k8s/base/postgres).
|
||||||
|
# Per Digest gepinnt (Index-Digest von 18.6-alpine, nachgeschlagen
|
||||||
|
# 2026-10-16); die k8s-Manifeste pinnen Postgres bisher nur per Tag.
|
||||||
|
image: postgres:18.6-alpine@sha256:77f585114c32fbca283dc835b0596f4e52b51b4c6662d7810b2f4084f60a1873
|
||||||
|
env:
|
||||||
|
# Wegwerf-Datenbank im Service-Container, nur im Job-Netz erreichbar
|
||||||
|
# und nach dem Lauf verworfen: kein echtes Secret.
|
||||||
|
POSTGRES_USER: apicore
|
||||||
|
POSTGRES_PASSWORD: apicore-ci-only
|
||||||
|
POSTGRES_DB: apicore_test
|
||||||
|
ports:
|
||||||
|
- 5432:5432
|
||||||
|
options: >-
|
||||||
|
--health-cmd "pg_isready -U apicore -d apicore_test"
|
||||||
|
--health-interval 5s
|
||||||
|
--health-timeout 5s
|
||||||
|
--health-retries 20
|
||||||
|
env:
|
||||||
|
NODE_ENV: test
|
||||||
|
# Das Wetter-e2e ruft die echte Open-Meteo-API; in CI ueberspringen.
|
||||||
|
SKIP_EXTERNAL: "1"
|
||||||
|
JWT_ACCESS_EXPIRES_IN: 15m
|
||||||
|
JWT_REFRESH_EXPIRES_IN: 7d
|
||||||
|
PORT: "3001"
|
||||||
|
FRONTEND_ORIGIN: http://localhost:3000
|
||||||
|
DOCUMENTS_STORAGE_PATH: ./test-uploads
|
||||||
|
defaults:
|
||||||
|
run:
|
||||||
|
working-directory: ApiCore-Backend
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
|
||||||
|
|
||||||
|
- name: Node einrichten
|
||||||
|
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
|
||||||
|
with:
|
||||||
|
node-version: 22
|
||||||
|
|
||||||
|
- name: Datenbank-Host ermitteln und Testumgebung erzeugen
|
||||||
|
# jest-e2e.json laedt .env.test per dotenv, das vorhandene Variablen
|
||||||
|
# nicht ueberschreibt: alles kommt aus der Job-Umgebung, .env.test
|
||||||
|
# existiert in CI nicht (gitignored). Die JWT-Secrets sind frisch
|
||||||
|
# erzeugte Wegwerfwerte (mind. 32 Zeichen, voneinander verschieden),
|
||||||
|
# nie echte Secrets.
|
||||||
|
run: |
|
||||||
|
set -eu
|
||||||
|
host=""
|
||||||
|
for candidate in postgres localhost; do
|
||||||
|
if node -e "const s=require('net').connect(5432,process.argv[1]);s.on('connect',()=>process.exit(0));s.on('error',()=>process.exit(1));setTimeout(()=>process.exit(1),3000)" "$candidate"; then
|
||||||
|
host="$candidate"
|
||||||
|
break
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
if [ -z "$host" ]; then
|
||||||
|
echo "Postgres weder unter 'postgres' noch unter 'localhost' erreichbar" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
echo "Postgres-Host: $host"
|
||||||
|
access="$(openssl rand -hex 32)"
|
||||||
|
refresh="$(openssl rand -hex 32)"
|
||||||
|
echo "::add-mask::$access"
|
||||||
|
echo "::add-mask::$refresh"
|
||||||
|
{
|
||||||
|
echo "DATABASE_URL=postgresql://apicore:apicore-ci-only@${host}:5432/apicore_test?schema=public"
|
||||||
|
echo "JWT_ACCESS_SECRET=$access"
|
||||||
|
echo "JWT_REFRESH_SECRET=$refresh"
|
||||||
|
} >> "$GITEA_ENV"
|
||||||
|
|
||||||
|
- name: Abhaengigkeiten installieren
|
||||||
|
run: npm ci
|
||||||
|
|
||||||
|
- name: Prisma-Client erzeugen
|
||||||
|
run: npx prisma generate
|
||||||
|
|
||||||
|
- name: Migrationen anwenden
|
||||||
|
# Die Rollen (z. B. BEEKEEPER) kommen aus den Migrationen, ein Seed
|
||||||
|
# ist fuer die e2e-Tests nicht noetig.
|
||||||
|
run: npx prisma migrate deploy
|
||||||
|
|
||||||
|
- name: E2E-Tests
|
||||||
|
run: npm run test:e2e -- --ci
|
||||||
@@ -99,7 +99,7 @@ describe('Authority notices: herd change notification (e2e)', () => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
beforeEach(async () => {
|
async function resetScenarioState(): Promise<void> {
|
||||||
await prisma.complianceSettings.update({
|
await prisma.complianceSettings.update({
|
||||||
where: { id: 'singleton' },
|
where: { id: 'singleton' },
|
||||||
data: { noticeOnColonyCountChange: true, noticeOnApiaryChange: true },
|
data: { noticeOnColonyCountChange: true, noticeOnApiaryChange: true },
|
||||||
@@ -107,7 +107,36 @@ describe('Authority notices: herd change notification (e2e)', () => {
|
|||||||
await prisma.notification.deleteMany({
|
await prisma.notification.deleteMany({
|
||||||
where: { userId: { in: [...managerIds, staffId] } },
|
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 () => {
|
afterEach(async () => {
|
||||||
// The shared database holds other suites' notices: only own ids go.
|
// The shared database holds other suites' notices: only own ids go.
|
||||||
@@ -191,7 +220,9 @@ describe('Authority notices: herd change notification (e2e)', () => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('a new colony changes the herd: after draining one unread notification per permitted user', async () => {
|
scenario(
|
||||||
|
'a new colony changes the herd: after draining one unread notification per permitted user',
|
||||||
|
async () => {
|
||||||
await sendBaseline();
|
await sendBaseline();
|
||||||
|
|
||||||
await addColony();
|
await addColony();
|
||||||
@@ -207,18 +238,24 @@ describe('Authority notices: herd change notification (e2e)', () => {
|
|||||||
expect(unread[0].message).toMatch(
|
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\.$/,
|
/^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 () => {
|
scenario(
|
||||||
|
'a second colony within one unread notice adds no second notification',
|
||||||
|
async () => {
|
||||||
await sendBaseline();
|
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 () => {
|
scenario(
|
||||||
|
'creating and sending a change notice removes the deviation; the next colony after reading notifies again',
|
||||||
|
async () => {
|
||||||
await sendBaseline();
|
await sendBaseline();
|
||||||
await addColony();
|
await addColony();
|
||||||
expect(await unreadOf(managerIds)).toHaveLength(managerIds.length);
|
expect(await unreadOf(managerIds)).toHaveLength(managerIds.length);
|
||||||
@@ -241,25 +278,34 @@ describe('Authority notices: herd change notification (e2e)', () => {
|
|||||||
data: { read: true },
|
data: { read: true },
|
||||||
});
|
});
|
||||||
|
|
||||||
// The sent notice is the new baseline. (Not asserted: "nothing deviates"
|
// Not asserted: that this notice is THE baseline or that nothing deviates
|
||||||
// against the live database - colonies that parallel files create in the
|
// - both are global and a parallel file's notice or colony changes them.
|
||||||
// same instant would make that flaky.)
|
expect(
|
||||||
expect((await findBaselineIn(prisma))?.id).toBe(notice.id);
|
(
|
||||||
|
await prisma.authorityNotice.findUniqueOrThrow({
|
||||||
|
where: { id: notice.id },
|
||||||
|
})
|
||||||
|
).status,
|
||||||
|
).toBe('SENT');
|
||||||
expect(await unreadOf(managerIds)).toHaveLength(0);
|
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 () => {
|
scenario(
|
||||||
|
'a user without compliance:manage gets no herd notification',
|
||||||
|
async () => {
|
||||||
await sendBaseline();
|
await sendBaseline();
|
||||||
|
|
||||||
await addColony();
|
await addColony();
|
||||||
|
|
||||||
expect(await unreadOf([staffId])).toHaveLength(0);
|
expect(await unreadOf([staffId])).toHaveLength(0);
|
||||||
expect(await unreadOf(managerIds)).toHaveLength(managerIds.length);
|
expect(await unreadOf(managerIds)).toHaveLength(managerIds.length);
|
||||||
});
|
},
|
||||||
|
);
|
||||||
|
|
||||||
async function patchApiary(body: Record<string, string>): Promise<void> {
|
async function patchApiary(body: Record<string, string>): Promise<void> {
|
||||||
await request(server())
|
await request(server())
|
||||||
@@ -270,7 +316,7 @@ describe('Authority notices: herd change notification (e2e)', () => {
|
|||||||
await drainDomainEvents(app, [apiaryId]);
|
await drainDomainEvents(app, [apiaryId]);
|
||||||
}
|
}
|
||||||
|
|
||||||
it('an apiary move (LOCATION_CHANGED) counts', async () => {
|
scenario('an apiary move (LOCATION_CHANGED) counts', async () => {
|
||||||
await sendBaseline();
|
await sendBaseline();
|
||||||
|
|
||||||
await patchApiary({ location: `Neuer Standort ${suffix}` });
|
await patchApiary({ location: `Neuer Standort ${suffix}` });
|
||||||
@@ -278,7 +324,7 @@ describe('Authority notices: herd change notification (e2e)', () => {
|
|||||||
expect(await unreadOf(managerIds)).toHaveLength(managerIds.length);
|
expect(await unreadOf(managerIds)).toHaveLength(managerIds.length);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('a rename does not count', async () => {
|
scenario('a rename does not count', async () => {
|
||||||
await sendBaseline();
|
await sendBaseline();
|
||||||
|
|
||||||
await patchApiary({ name: `Herd Stand umbenannt ${suffix}` });
|
await patchApiary({ name: `Herd Stand umbenannt ${suffix}` });
|
||||||
@@ -286,7 +332,9 @@ describe('Authority notices: herd change notification (e2e)', () => {
|
|||||||
expect(await unreadOf(managerIds)).toHaveLength(0);
|
expect(await unreadOf(managerIds)).toHaveLength(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('noticeOnApiaryChange alone: a colony stays quiet, a moved apiary notifies', async () => {
|
scenario(
|
||||||
|
'noticeOnApiaryChange alone: a colony stays quiet, a moved apiary notifies',
|
||||||
|
async () => {
|
||||||
await prisma.complianceSettings.update({
|
await prisma.complianceSettings.update({
|
||||||
where: { id: 'singleton' },
|
where: { id: 'singleton' },
|
||||||
data: { noticeOnColonyCountChange: false },
|
data: { noticeOnColonyCountChange: false },
|
||||||
@@ -298,7 +346,8 @@ describe('Authority notices: herd change notification (e2e)', () => {
|
|||||||
|
|
||||||
await patchApiary({ location: `Ort wieder neu ${suffix}` });
|
await patchApiary({ location: `Ort wieder neu ${suffix}` });
|
||||||
expect(await unreadOf(managerIds)).toHaveLength(managerIds.length);
|
expect(await unreadOf(managerIds)).toHaveLength(managerIds.length);
|
||||||
});
|
},
|
||||||
|
);
|
||||||
|
|
||||||
it('two parallel recordForPermissionOnce transactions write one row per user', async () => {
|
it('two parallel recordForPermissionOnce transactions write one row per user', async () => {
|
||||||
const notifications = app.get(NotificationsService);
|
const notifications = app.get(NotificationsService);
|
||||||
|
|||||||
@@ -24,6 +24,10 @@ interface TrachtprognoseBody {
|
|||||||
historicalAverageYieldKg: number | null;
|
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)', () => {
|
describe('Weather (e2e)', () => {
|
||||||
let app: INestApplication<App>;
|
let app: INestApplication<App>;
|
||||||
let prisma: PrismaService;
|
let prisma: PrismaService;
|
||||||
@@ -75,7 +79,9 @@ describe('Weather (e2e)', () => {
|
|||||||
await app.close();
|
await app.close();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('returns real daily weather for an apiary with coordinates', async () => {
|
itExternal(
|
||||||
|
'returns real daily weather for an apiary with coordinates',
|
||||||
|
async () => {
|
||||||
const response = await request(app.getHttpServer())
|
const response = await request(app.getHttpServer())
|
||||||
.get(`/api/v1/apiaries/${apiaryWithCoordsId}/weather`)
|
.get(`/api/v1/apiaries/${apiaryWithCoordsId}/weather`)
|
||||||
.set('Authorization', `Bearer ${beekeeperToken}`)
|
.set('Authorization', `Bearer ${beekeeperToken}`)
|
||||||
@@ -86,9 +92,13 @@ describe('Weather (e2e)', () => {
|
|||||||
expect(body.daily.length).toBeGreaterThan(15);
|
expect(body.daily.length).toBeGreaterThan(15);
|
||||||
const today = body.daily[0];
|
const today = body.daily[0];
|
||||||
expect(today.date).toEqual(expect.stringMatching(/^\d{4}-\d{2}-\d{2}$/));
|
expect(today.date).toEqual(expect.stringMatching(/^\d{4}-\d{2}-\d{2}$/));
|
||||||
}, 15000);
|
},
|
||||||
|
15000,
|
||||||
|
);
|
||||||
|
|
||||||
it('returns a Trachtprognose with a valid rating for an apiary with coordinates', async () => {
|
itExternal(
|
||||||
|
'returns a Trachtprognose with a valid rating for an apiary with coordinates',
|
||||||
|
async () => {
|
||||||
const response = await request(app.getHttpServer())
|
const response = await request(app.getHttpServer())
|
||||||
.get(`/api/v1/apiaries/${apiaryWithCoordsId}/trachtprognose`)
|
.get(`/api/v1/apiaries/${apiaryWithCoordsId}/trachtprognose`)
|
||||||
.set('Authorization', `Bearer ${beekeeperToken}`)
|
.set('Authorization', `Bearer ${beekeeperToken}`)
|
||||||
@@ -100,7 +110,9 @@ describe('Weather (e2e)', () => {
|
|||||||
);
|
);
|
||||||
expect(body.season).toEqual(expect.any(String));
|
expect(body.season).toEqual(expect.any(String));
|
||||||
expect(body.reasoning.length).toBeGreaterThan(0);
|
expect(body.reasoning.length).toBeGreaterThan(0);
|
||||||
}, 15000);
|
},
|
||||||
|
15000,
|
||||||
|
);
|
||||||
|
|
||||||
it('rejects weather requests for an apiary without coordinates', () => {
|
it('rejects weather requests for an apiary without coordinates', () => {
|
||||||
return request(app.getHttpServer())
|
return request(app.getHttpServer())
|
||||||
|
|||||||
Reference in new issue
Block a user