mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-18 21:16:47 +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.
94 lines
3.9 KiB
TypeScript
94 lines
3.9 KiB
TypeScript
import { describe, it, expect, vi } from 'vitest';
|
|
import { HttpException } from '@nestjs/common';
|
|
import { WeatherController } from '../../../src/nest/weather/weather.controller';
|
|
import { ApiError } from '../../../src/nest/weather/weather.impl';
|
|
import type { WeatherService } from '../../../src/nest/weather/weather.service';
|
|
|
|
function makeController(svc: Partial<WeatherService>) {
|
|
return new WeatherController(svc as WeatherService);
|
|
}
|
|
|
|
/** Run `fn`, expecting it to throw an HttpException; return its { status, body }. */
|
|
async function thrown(fn: () => Promise<unknown>): Promise<{ status: number; body: unknown }> {
|
|
try {
|
|
await fn();
|
|
} catch (err) {
|
|
expect(err).toBeInstanceOf(HttpException);
|
|
const e = err as HttpException;
|
|
return { status: e.getStatus(), body: e.getResponse() };
|
|
}
|
|
throw new Error('expected the handler to throw');
|
|
}
|
|
|
|
describe('WeatherController (parity with the legacy /api/weather route)', () => {
|
|
const sample = { temp: 21, main: 'Clear', description: 'Klar', type: 'current' };
|
|
|
|
describe('GET /api/weather', () => {
|
|
it('400 { error } with the exact legacy message when lat/lng missing', async () => {
|
|
const c = makeController({ get: vi.fn() });
|
|
expect(await thrown(() => c.getWeather(undefined, '13.4'))).toEqual({
|
|
status: 400,
|
|
body: { error: 'Latitude and longitude are required' },
|
|
});
|
|
});
|
|
|
|
it('returns the service result and defaults lang to "de" when absent', async () => {
|
|
const get = vi.fn().mockResolvedValue(sample);
|
|
const c = makeController({ get });
|
|
const res = await c.getWeather('52.5', '13.4', undefined, undefined);
|
|
expect(res).toEqual(sample);
|
|
expect(get).toHaveBeenCalledWith('52.5', '13.4', undefined, 'de');
|
|
});
|
|
|
|
it('passes an explicit lang and date through unchanged', async () => {
|
|
const get = vi.fn().mockResolvedValue(sample);
|
|
const c = makeController({ get });
|
|
await c.getWeather('1', '2', '2026-07-01', 'en');
|
|
expect(get).toHaveBeenCalledWith('1', '2', '2026-07-01', 'en');
|
|
});
|
|
|
|
it('maps an ApiError to its status + { error: message }', async () => {
|
|
const c = makeController({ get: vi.fn().mockRejectedValue(new ApiError(404, 'Open-Meteo API error')) });
|
|
expect(await thrown(() => c.getWeather('1', '2'))).toEqual({
|
|
status: 404,
|
|
body: { error: 'Open-Meteo API error' },
|
|
});
|
|
});
|
|
|
|
it('maps an unexpected error to the exact legacy 500 body', async () => {
|
|
vi.spyOn(console, 'error').mockImplementation(() => {});
|
|
const c = makeController({ get: vi.fn().mockRejectedValue(new Error('boom')) });
|
|
expect(await thrown(() => c.getWeather('1', '2'))).toEqual({
|
|
status: 500,
|
|
body: { error: 'Error fetching weather data' },
|
|
});
|
|
});
|
|
});
|
|
|
|
describe('GET /api/weather/detailed', () => {
|
|
it('400 { error } with the exact legacy message when date missing', async () => {
|
|
const c = makeController({ getDetailed: vi.fn() });
|
|
expect(await thrown(() => c.getDetailed('1', '2', undefined))).toEqual({
|
|
status: 400,
|
|
body: { error: 'Latitude, longitude, and date are required' },
|
|
});
|
|
});
|
|
|
|
it('returns the detailed result and defaults lang to "de"', async () => {
|
|
const getDetailed = vi.fn().mockResolvedValue(sample);
|
|
const c = makeController({ getDetailed });
|
|
await c.getDetailed('1', '2', '2026-07-01', undefined);
|
|
expect(getDetailed).toHaveBeenCalledWith('1', '2', '2026-07-01', 'de');
|
|
});
|
|
|
|
it('maps an unexpected error to the exact detailed 500 body', async () => {
|
|
vi.spyOn(console, 'error').mockImplementation(() => {});
|
|
const c = makeController({ getDetailed: vi.fn().mockRejectedValue(new Error('boom')) });
|
|
expect(await thrown(() => c.getDetailed('1', '2', '2026-07-01'))).toEqual({
|
|
status: 500,
|
|
body: { error: 'Error fetching detailed weather data' },
|
|
});
|
|
});
|
|
});
|
|
});
|