mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-06-19 13:21:46 +00:00
feat(weather): migrate /api/weather to the NestJS pilot module (L1) (#1053)
First strangler migration (L1): /api/weather is served by a NestJS module.
- @trek/shared/weather Zod contract; Nest controller byte-identical to the legacy Express route (paths, query params, status codes, { error } bodies, lang default, ApiError/500 passthrough). Service reuses getWeather/getDetailedWeather (+ shared cache; MCP tools unchanged).
- Strangler routes /api/weather to Nest by default; the legacy Express route + its migration-time parity test were decommissioned in this PR.
- Frontend (FE2): weatherApi typed against the @trek/shared WeatherResult contract.
- Harness: reusable Nest-vs-Express parity harness, e2e harness (temp SQLite + seed/cookie helpers, real JwtAuthGuard), src/nest coverage gate raised to >=80%, src/nest test guide.
- Verified end-to-end on a prod mirror (dev1): 401/400/200 via Nest with real Open-Meteo data, Express route gone.
This commit is contained in:
@@ -0,0 +1,36 @@
|
||||
/**
|
||||
* DatabaseService — the shared better-sqlite3 provider (F3). Exercises every
|
||||
* helper against the real connection so the typed query surface is covered.
|
||||
*/
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { DatabaseService } from '../../../src/nest/database/database.service';
|
||||
|
||||
describe('DatabaseService (typed query helpers)', () => {
|
||||
const svc = new DatabaseService();
|
||||
|
||||
it('exposes the shared connection', () => {
|
||||
expect(typeof svc.connection.prepare).toBe('function');
|
||||
});
|
||||
|
||||
it('prepare + get + all return rows from the live connection', () => {
|
||||
expect(svc.prepare('SELECT 1 AS one').get()).toEqual({ one: 1 });
|
||||
expect(svc.get('SELECT 2 AS two')).toEqual({ two: 2 });
|
||||
expect(svc.all('SELECT 3 AS three')).toEqual([{ three: 3 }]);
|
||||
});
|
||||
|
||||
it('run + transaction operate on a scratch table', () => {
|
||||
svc.run('CREATE TEMP TABLE IF NOT EXISTS _dbsvc_test (n INTEGER)');
|
||||
svc.run('DELETE FROM _dbsvc_test');
|
||||
|
||||
const info = svc.run('INSERT INTO _dbsvc_test (n) VALUES (?)', 41);
|
||||
expect(info.changes).toBe(1);
|
||||
|
||||
const total = svc.transaction((conn) => {
|
||||
conn.prepare('INSERT INTO _dbsvc_test (n) VALUES (?)').run(1);
|
||||
return conn.prepare('SELECT SUM(n) AS s FROM _dbsvc_test').get() as { s: number };
|
||||
});
|
||||
expect(total.s).toBe(42);
|
||||
|
||||
svc.run('DROP TABLE _dbsvc_test');
|
||||
});
|
||||
});
|
||||
@@ -8,9 +8,9 @@ describe('strangler toggle', () => {
|
||||
else process.env.NEST_PREFIXES = original;
|
||||
});
|
||||
|
||||
it('defaults to /api/_nest when NEST_PREFIXES is unset', () => {
|
||||
it('defaults to the migrated prefixes (/api/_nest + /api/weather) when NEST_PREFIXES is unset', () => {
|
||||
delete process.env.NEST_PREFIXES;
|
||||
expect(getNestPrefixes()).toEqual(['/api/_nest']);
|
||||
expect(getNestPrefixes()).toEqual(['/api/_nest', '/api/weather']);
|
||||
});
|
||||
|
||||
it('parses NEST_PREFIXES (comma-separated, trimmed)', () => {
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
import { HttpException } from '@nestjs/common';
|
||||
import { WeatherController } from '../../../src/nest/weather/weather.controller';
|
||||
import { ApiError } from '../../../src/services/weatherService';
|
||||
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' },
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user