mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-22 15:06:32 +00:00
a7d789fdd5
Finishes be-weather-airports-fold. The README has listed weather as the reference implementation since the migration started, while 513 lines of it sat in the legacy layer. The file is nest/weather/weather.impl.ts now, and WeatherService owns what it could not own before: the cache sweep. That sweep was a bare setInterval at module scope, so every process that so much as imported the file started a five-minute timer and nothing ever stopped it — there was nothing to call. It is startCacheCleanup/stopCacheCleanup now, driven by onModuleInit/onModuleDestroy, and the handle is unref-ed so a cache sweep can never hold the process open. The old test file says as much: its second line is "Prevent the module-level setInterval from running during tests", with a vi.useFakeTimers() to do it. That workaround is no longer what keeps the timer quiet. The cache itself stays module state, deliberately. The MCP weather tools call getWeather directly from outside the container, and a cache is only worth having if every caller sees the same one. Hoisting it into the class would mean handing the registrar the container instance to get exactly what a module singleton already provides — the same trade the place-photo cache resolved the other way, because there the scheduler needed the instance anyway. Coverage is the reason this PR carries eleven new tests rather than none. Code in services/ is ungated; the moment it lands in src/nest it counts against the 80% branch threshold, and weather.impl arrived at 75%. The gap was in paths worth having: a failing archive lookup with and without an upstream reason, a far-future date whose climate rows are all null, an expired entry being re- fetched, and the sweep evicting one on its own. Plus the lifecycle itself — start is idempotent, stop is idempotent, the handle is unref-ed. 79% now. services/ is down to 11 files, from 44.
111 lines
3.6 KiB
TypeScript
111 lines
3.6 KiB
TypeScript
/**
|
|
* The weather cache sweep's lifecycle — WEATHER-SWEEP-001..005.
|
|
*
|
|
* The sweep used to be a bare setInterval at module scope: every process that
|
|
* imported weather.impl started a five-minute timer, and nothing could stop it
|
|
* because there was nothing to call. It is start/stop now, owned by
|
|
* WeatherService's onModuleInit/onModuleDestroy — so it needs tests that the
|
|
* old shape could not have had.
|
|
*/
|
|
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
|
|
|
import { startCacheCleanup, stopCacheCleanup, getWeather } from '../../../../src/nest/weather/weather.impl';
|
|
import { WeatherService } from '../../../../src/nest/weather/weather.service';
|
|
|
|
beforeEach(() => {
|
|
vi.useFakeTimers();
|
|
stopCacheCleanup(); // start from a known state whatever ran before
|
|
});
|
|
|
|
afterEach(() => {
|
|
stopCacheCleanup();
|
|
vi.useRealTimers();
|
|
});
|
|
|
|
describe('cache sweep lifecycle', () => {
|
|
it('WEATHER-SWEEP-001: start schedules exactly one interval', () => {
|
|
const setInterval = vi.spyOn(globalThis, 'setInterval');
|
|
|
|
startCacheCleanup();
|
|
|
|
expect(setInterval).toHaveBeenCalledOnce();
|
|
expect(setInterval.mock.calls[0][1]).toBe(5 * 60 * 1000);
|
|
});
|
|
|
|
it('WEATHER-SWEEP-002: starting twice does not stack a second timer', () => {
|
|
const setInterval = vi.spyOn(globalThis, 'setInterval');
|
|
|
|
startCacheCleanup();
|
|
startCacheCleanup();
|
|
|
|
expect(setInterval).toHaveBeenCalledOnce();
|
|
});
|
|
|
|
it('WEATHER-SWEEP-003: the timer is unref-ed, so a sweep never holds the process open', () => {
|
|
const unref = vi.fn();
|
|
vi.spyOn(globalThis, 'setInterval').mockReturnValue({ unref } as unknown as NodeJS.Timeout);
|
|
|
|
startCacheCleanup();
|
|
|
|
expect(unref).toHaveBeenCalledOnce();
|
|
});
|
|
|
|
it('WEATHER-SWEEP-004: stop clears the timer and is idempotent', () => {
|
|
startCacheCleanup();
|
|
expect(vi.getTimerCount()).toBeGreaterThan(0);
|
|
|
|
stopCacheCleanup();
|
|
expect(vi.getTimerCount()).toBe(0);
|
|
|
|
expect(() => stopCacheCleanup()).not.toThrow();
|
|
});
|
|
|
|
it('WEATHER-SWEEP-005: stop then start schedules again', () => {
|
|
startCacheCleanup();
|
|
stopCacheCleanup();
|
|
startCacheCleanup();
|
|
|
|
expect(vi.getTimerCount()).toBeGreaterThan(0);
|
|
});
|
|
});
|
|
|
|
describe('what the sweep actually does', () => {
|
|
it('WEATHER-SWEEP-007: an expired entry is gone after a sweep, without anyone asking for it', async () => {
|
|
const date = new Date(Date.now() + 4 * 24 * 60 * 60 * 1000).toISOString().slice(0, 10);
|
|
const body = {
|
|
daily: { time: [date], temperature_2m_max: [20], temperature_2m_min: [10], weathercode: [0] },
|
|
};
|
|
const fetchMock = vi.fn().mockResolvedValue({
|
|
ok: true, status: 200, json: vi.fn().mockResolvedValue(body),
|
|
} as unknown as Response);
|
|
vi.stubGlobal('fetch', fetchMock);
|
|
|
|
try {
|
|
await getWeather('44.44', '8.88', date, 'en');
|
|
expect(fetchMock).toHaveBeenCalledTimes(1);
|
|
|
|
startCacheCleanup();
|
|
// Past the forecast TTL (1h) and past one sweep interval (5min), so the
|
|
// sweep — not a later read — is what evicts the entry.
|
|
vi.advanceTimersByTime(60 * 60 * 1000 + 5 * 60 * 1000 + 1000);
|
|
|
|
await getWeather('44.44', '8.88', date, 'en');
|
|
expect(fetchMock).toHaveBeenCalledTimes(2);
|
|
} finally {
|
|
vi.unstubAllGlobals();
|
|
}
|
|
});
|
|
});
|
|
|
|
describe('WeatherService lifecycle hooks', () => {
|
|
it('WEATHER-SWEEP-006: onModuleInit starts the sweep and onModuleDestroy stops it', () => {
|
|
const svc = new WeatherService();
|
|
|
|
svc.onModuleInit();
|
|
expect(vi.getTimerCount()).toBeGreaterThan(0);
|
|
|
|
svc.onModuleDestroy();
|
|
expect(vi.getTimerCount()).toBe(0);
|
|
});
|
|
});
|