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]>
This commit is contained in:
1 parent
22382d53a2
commit
1971b2d9c4
90 files changed
+8431
-155
No files matched your search
@@ -0,0 +1,173 @@
|
||||
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);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user