feat: Wetterintegration + Trachtprognose pro Bienenstand

Neues WeatherModule: liest Bienenstand-Koordinaten und ruft die freie
Open-Meteo-API ab (kein API-Key nötig), liefert 14 Tage Wetterhistorie
+ 7-Tage-Vorhersage sowie eine heuristische Trachtprognose (Saison +
Wetter der letzten 7 Tage + historischer Ernteschnitt für denselben
Kalendermonat am selben Standort - explizit kein ML-Modell, siehe
TASKS.md Abschnitt 26).

- GET /api/v1/apiaries/:id/weather, GET .../trachtprognose, beide
  ueber die bestehende apiaries:read-Berechtigung abgesichert (kein
  neuer Permission-Key nötig, gleiches Muster wie die bestehenden
  Colony-Sub-Controller).
- Bienenstände ohne hinterlegte Koordinaten liefern 400 statt eines
  stillen Fallbacks.
- test/weather.e2e-spec.ts ruft bewusst die echte Open-Meteo-API auf
  (kostenlos, kein Auth, stabil genug für einen einzelnen Testlauf) -
  validiert damit den echten Response-Vertrag statt nur ein Mock.
- TASKS.md: beide Punkte abgehakt, stale Duplikat-Zeilen im
  Später-Abschnitt bereinigt.

Verifiziert: tsc, lint, build, unit (228/228), volle e2e-Suite
163/163 gruen.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
This commit is contained in:
Nick SzaunigandClaude Sonnet 5 committed 2026-08-26 09:43:08 +02:00
1 parent 335c572dfc
commit ce9cea1b94
7 files changed
+568 -7

No files matched your search

+117
View File
@@ -0,0 +1,117 @@
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 DailyWeatherBody {
date: string;
tempMaxC: number | null;
tempMinC: number | null;
precipitationMm: number | null;
}
interface WeatherBody {
daily: DailyWeatherBody[];
}
interface TrachtprognoseBody {
rating: string;
season: string;
reasoning: string[];
historicalAverageYieldKg: number | null;
}
describe('Weather (e2e)', () => {
let app: INestApplication<App>;
let prisma: PrismaService;
const beekeeperEmail = `weather-e2e-beekeeper-${Date.now()}@example.com`;
const password = 'SuperSecret123!';
let beekeeperId: string;
let beekeeperToken: string;
let apiaryWithCoordsId: string;
let apiaryWithoutCoordsId: string;
beforeAll(async () => {
app = await createTestApp();
prisma = app.get(PrismaService);
const passwordHash = await bcrypt.hash(password, 12);
const beekeeper = await prisma.user.create({
data: {
email: beekeeperEmail,
passwordHash,
name: 'Weather E2E Beekeeper',
role: { connect: { key: 'BEEKEEPER' } },
},
});
beekeeperId = beekeeper.id;
beekeeperToken = (await login(app, beekeeperEmail, password)).accessToken;
const withCoords = await prisma.apiary.create({
data: {
name: `Weather E2E Stand mit Koordinaten ${Date.now()}`,
latitude: 52.52,
longitude: 13.405,
},
});
apiaryWithCoordsId = withCoords.id;
const withoutCoords = await prisma.apiary.create({
data: { name: `Weather E2E Stand ohne Koordinaten ${Date.now()}` },
});
apiaryWithoutCoordsId = withoutCoords.id;
});
afterAll(async () => {
await prisma.apiary.deleteMany({
where: { id: { in: [apiaryWithCoordsId, apiaryWithoutCoordsId] } },
});
await prisma.refreshToken.deleteMany({ where: { userId: beekeeperId } });
await prisma.auditLog.deleteMany({ where: { userId: beekeeperId } });
await prisma.user.delete({ where: { id: beekeeperId } });
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);
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);
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())
.get(`/api/v1/apiaries/${apiaryWithoutCoordsId}/weather`)
.set('Authorization', `Bearer ${beekeeperToken}`)
.expect(400);
});
it('rejects unauthenticated weather requests', () => {
return request(app.getHttpServer())
.get(`/api/v1/apiaries/${apiaryWithCoordsId}/weather`)
.expect(401);
});
});