diff --git a/.gitea/workflows/backend-e2e.yml b/.gitea/workflows/backend-e2e.yml new file mode 100644 index 0000000..8f61668 --- /dev/null +++ b/.gitea/workflows/backend-e2e.yml @@ -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 diff --git a/ApiCore-Backend/test/authority-notices-herd.e2e-spec.ts b/ApiCore-Backend/test/authority-notices-herd.e2e-spec.ts index f81c232..807bb53 100644 --- a/ApiCore-Backend/test/authority-notices-herd.e2e-spec.ts +++ b/ApiCore-Backend/test/authority-notices-herd.e2e-spec.ts @@ -99,7 +99,7 @@ describe('Authority notices: herd change notification (e2e)', () => { ); }); - beforeEach(async () => { + async function resetScenarioState(): Promise { 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): Promise { + 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) => + 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): Promise { 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); diff --git a/ApiCore-Backend/test/weather.e2e-spec.ts b/ApiCore-Backend/test/weather.e2e-spec.ts index 118cbd1..f18abf7 100644 --- a/ApiCore-Backend/test/weather.e2e-spec.ts +++ b/ApiCore-Backend/test/weather.e2e-spec.ts @@ -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; 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())