- 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]>
174 lines
5.7 KiB
TypeScript
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);
|
|
});
|
|
});
|