Files
ApiCore/ApiCore-Backend/test/weather-history.e2e-spec.ts
Nick SzaunigandClaude Opus 5.5 1971b2d9c4 feat: T4b/T4c wave 8 - weather history, phenology, honey chain, Telegram base
- Weather history from Open-Meteo behind an adapter: worker loop
  phenology-weather with backoff, Retry-After, User-Agent, chunked
  writes and forecast-to-archive correction
- Phenology: growing degree sum per DWD, phase from reports, GTS and
  calendar, calibration from previous years, daily job step,
  SeasonService reads the stored state
- Honey chain: prefill for extractions and batches (proposals only),
  plausibility and withdrawal warnings, Extraction lock, chain events
  and batch.created webhook from HoneyBatchRecorded
- Telegram base: schema, NotificationDelivery outbox, hardened bot
  client, startup check without crash, no-network-outside-loops guard
- ARCHITECTURE.md: sections 11.11-11.13, assumptions 42-46, env table

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-14 20:00:46 +02:00

174 lines
5.7 KiB
TypeScript

import { Test, TestingModule } from '@nestjs/testing';
import {
addDays,
berlinToday,
daysBetween,
dayToDate,
} from '../src/common/dates';
import { PrismaModule } from '../src/prisma/prisma.module';
import { PrismaService } from '../src/prisma/prisma.service';
import { RECENT_WINDOW_DAYS } from '../src/weather/history/weather-history.config';
import { WeatherHistoryModule } from '../src/weather/history/weather-history.module';
import { WeatherHistoryService } from '../src/weather/history/weather-history.service';
// Two stands on one rounded point (61.23 / 5.31) and one on a point that fails.
// Other e2e files share the database, so nothing here counts requests or rows
// of other stands.
const GOOD = { lat: '61.23', lon: '5.31' };
const BAD = { lat: '62.11', lon: '6.12' };
function json(body: unknown): Response {
return new Response(JSON.stringify(body), { status: 200 });
}
function days(from: string, to: string): string[] {
const out: string[] = [];
for (let day = from; day <= to; day = addDays(day, 1)) out.push(day);
return out;
}
describe('Weather history (e2e)', () => {
const today = berlinToday();
const yesterday = addDays(today, -1);
const januaryFirst = `${today.slice(0, 4)}-01-01`;
const expectedDays = Math.max(0, daysBetween(januaryFirst, yesterday) + 1);
const suffix = `${Date.now()}`;
let moduleRef: TestingModule;
let prisma: PrismaService;
let service: WeatherHistoryService;
let fetchMock: jest.SpiedFunction<typeof fetch>;
let apiaryIds: string[];
let badApiaryId: string;
const ownCalls = (point: { lat: string; lon: string }): URL[] =>
fetchMock.mock.calls
.map(([url]) => url as URL)
.filter(
(url) =>
url.searchParams.get('latitude') === point.lat &&
url.searchParams.get('longitude') === point.lon,
);
beforeAll(async () => {
moduleRef = await Test.createTestingModule({
imports: [PrismaModule, WeatherHistoryModule],
}).compile();
await moduleRef.init();
prisma = moduleRef.get(PrismaService);
service = moduleRef.get(WeatherHistoryService);
const a = await prisma.apiary.create({
data: {
name: `Weather history A ${suffix}`,
latitude: 61.2345,
longitude: 5.3109,
},
});
const b = await prisma.apiary.create({
data: {
name: `Weather history B ${suffix}`,
latitude: 61.2349,
longitude: 5.3101,
},
});
const bad = await prisma.apiary.create({
data: {
name: `Weather history bad ${suffix}`,
latitude: 62.1149,
longitude: 6.1201,
},
});
apiaryIds = [a.id, b.id];
badApiaryId = bad.id;
});
afterAll(async () => {
await prisma.apiary.deleteMany({
where: { id: { in: [...apiaryIds, badApiaryId] } },
});
await moduleRef.close();
});
beforeEach(() => {
fetchMock = jest.spyOn(global, 'fetch').mockImplementation((input) => {
const url = input as URL;
const lat = url.searchParams.get('latitude');
if (lat === BAD.lat) {
return Promise.resolve(new Response('nope', { status: 500 }));
}
if (lat !== GOOD.lat) {
// Stands of other e2e files: no data.
return Promise.resolve(
json({ daily: { time: [], temperature_2m_mean: [] } }),
);
}
const archive = url.searchParams.has('start_date');
const time = archive
? days(
url.searchParams.get('start_date') as string,
url.searchParams.get('end_date') as string,
)
: days(addDays(today, -92), today);
return Promise.resolve(
json({
daily: { time, temperature_2m_mean: time.map(() => 10.5) },
}),
);
});
});
afterEach(() => {
fetchMock.mockRestore();
});
it('fetches missing days once, with rounded coordinates, and writes nothing on an error', async () => {
const controller = new AbortController();
const result = await service.syncOnce(today, controller.signal);
// The failing point wrote nothing and counted as failed.
expect(result.failedPoints).toBeGreaterThanOrEqual(1);
expect(
await prisma.apiaryWeatherDay.count({ where: { apiaryId: badApiaryId } }),
).toBe(0);
// One archive and one forecast request for the shared rounded point.
const calls = ownCalls(GOOD);
expect(calls.length).toBe(expectedDays > 0 ? 2 : 0);
for (const url of calls) {
expect(url.searchParams.get('latitude')).toBe('61.23');
expect(url.searchParams.get('longitude')).toBe('5.31');
expect(url.searchParams.get('daily')).toBe('temperature_2m_mean');
expect(url.searchParams.get('timezone')).toBe('Europe/Berlin');
}
// Both stands of the point carry every day from January 1st to yesterday.
const recentStart = addDays(today, -RECENT_WINDOW_DAYS);
for (const apiaryId of apiaryIds) {
const rows = await prisma.apiaryWeatherDay.findMany({
where: { apiaryId },
orderBy: { day: 'asc' },
});
expect(rows).toHaveLength(expectedDays);
for (const row of rows) {
const day = row.day.toISOString().slice(0, 10);
expect(row.meanTempC).toBe(10.5);
expect(row.source).toBe(day >= recentStart ? 'forecast' : 'archive');
expect(day >= januaryFirst && day <= yesterday).toBe(true);
}
}
if (expectedDays > 0) {
expect(
await prisma.apiaryWeatherDay.count({
where: { apiaryId: apiaryIds[0], day: dayToDate(yesterday) },
}),
).toBe(1);
}
});
it('asks for nothing when every day is already stored', async () => {
await service.syncOnce(today, new AbortController().signal);
expect(ownCalls(GOOD)).toHaveLength(0);
});
});