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:
1 parent
335c572dfc
commit
ce9cea1b94
7 files changed
+568
-7
No files matched your search
@@ -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);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user