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:
@@ -10,3 +10,6 @@
|
||||
*/
|
||||
export * from './common/primitives.schema';
|
||||
export * from './common/pagination.schema';
|
||||
|
||||
// Domain contracts
|
||||
export * from './weather/weather.schema';
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import {
|
||||
weatherQuerySchema,
|
||||
detailedWeatherQuerySchema,
|
||||
weatherResultSchema,
|
||||
} from './weather.schema';
|
||||
|
||||
describe('weatherQuerySchema', () => {
|
||||
it('accepts lat/lng and defaults lang to "de"', () => {
|
||||
const parsed = weatherQuerySchema.parse({ lat: '52.5', lng: '13.4' });
|
||||
expect(parsed).toEqual({ lat: '52.5', lng: '13.4', lang: 'de' });
|
||||
});
|
||||
|
||||
it('keeps an explicit lang and optional date', () => {
|
||||
const parsed = weatherQuerySchema.parse({ lat: '1', lng: '2', date: '2026-07-01', lang: 'en' });
|
||||
expect(parsed.lang).toBe('en');
|
||||
expect(parsed.date).toBe('2026-07-01');
|
||||
});
|
||||
|
||||
it('rejects missing lat/lng', () => {
|
||||
expect(weatherQuerySchema.safeParse({ lng: '13.4' }).success).toBe(false);
|
||||
expect(weatherQuerySchema.safeParse({ lat: '', lng: '13.4' }).success).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('detailedWeatherQuerySchema', () => {
|
||||
it('requires a date', () => {
|
||||
expect(detailedWeatherQuerySchema.safeParse({ lat: '1', lng: '2' }).success).toBe(false);
|
||||
expect(detailedWeatherQuerySchema.safeParse({ lat: '1', lng: '2', date: '2026-07-01' }).success).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('weatherResultSchema', () => {
|
||||
it('accepts a minimal current-weather result', () => {
|
||||
const r = weatherResultSchema.parse({ temp: 21, main: 'Clear', description: 'Klar', type: 'current' });
|
||||
expect(r.temp).toBe(21);
|
||||
});
|
||||
|
||||
it('accepts a detailed result with hourly entries and a no_forecast error', () => {
|
||||
expect(
|
||||
weatherResultSchema.safeParse({
|
||||
temp: 0, main: '', description: '', type: '', error: 'no_forecast',
|
||||
}).success,
|
||||
).toBe(true);
|
||||
expect(
|
||||
weatherResultSchema.safeParse({
|
||||
temp: 18, main: 'Rain', description: 'Regen', type: 'forecast',
|
||||
sunrise: '05:30', sunset: '21:10', precipitation_sum: 2.4,
|
||||
hourly: [{ hour: 9, temp: 17, precipitation: 0.1, precipitation_probability: 20, main: 'Clouds', wind: 12, humidity: 80 }],
|
||||
}).success,
|
||||
).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,60 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
/**
|
||||
* Weather API contract — single source of truth for the /api/weather endpoints.
|
||||
*
|
||||
* The legacy Express routes treat lat/lng as opaque strings (they are parsed with
|
||||
* parseFloat inside the service) and only check for presence, so the query schemas
|
||||
* mirror that: non-empty strings, not coerced numbers. `lang` defaults to 'de',
|
||||
* matching the Express default.
|
||||
*
|
||||
* The bespoke "X is required" 400 messages are reproduced in the controller, not
|
||||
* derived from these schemas, so the error body stays byte-identical to Express.
|
||||
*/
|
||||
|
||||
export const weatherQuerySchema = z.object({
|
||||
lat: z.string().min(1),
|
||||
lng: z.string().min(1),
|
||||
date: z.string().min(1).optional(),
|
||||
lang: z.string().min(1).default('de'),
|
||||
});
|
||||
export type WeatherQuery = z.infer<typeof weatherQuerySchema>;
|
||||
|
||||
/** Detailed weather requires a date (the Express route 400s without it). */
|
||||
export const detailedWeatherQuerySchema = weatherQuerySchema.extend({
|
||||
date: z.string().min(1),
|
||||
});
|
||||
export type DetailedWeatherQuery = z.infer<typeof detailedWeatherQuerySchema>;
|
||||
|
||||
export const hourlyEntrySchema = z.object({
|
||||
hour: z.number(),
|
||||
temp: z.number(),
|
||||
precipitation: z.number(),
|
||||
precipitation_probability: z.number(),
|
||||
main: z.string(),
|
||||
wind: z.number(),
|
||||
humidity: z.number(),
|
||||
});
|
||||
export type HourlyEntry = z.infer<typeof hourlyEntrySchema>;
|
||||
|
||||
/**
|
||||
* Weather response DTO. Fields are optional because the Express service emits
|
||||
* different subsets depending on the request type (current / forecast / climate /
|
||||
* detailed) and on error (`{ ..., error: 'no_forecast' }`).
|
||||
*/
|
||||
export const weatherResultSchema = z.object({
|
||||
temp: z.number(),
|
||||
temp_max: z.number().optional(),
|
||||
temp_min: z.number().optional(),
|
||||
main: z.string(),
|
||||
description: z.string(),
|
||||
type: z.string(),
|
||||
sunrise: z.string().nullable().optional(),
|
||||
sunset: z.string().nullable().optional(),
|
||||
precipitation_sum: z.number().optional(),
|
||||
precipitation_probability_max: z.number().optional(),
|
||||
wind_max: z.number().optional(),
|
||||
hourly: z.array(hourlyEntrySchema).optional(),
|
||||
error: z.string().optional(),
|
||||
});
|
||||
export type WeatherResult = z.infer<typeof weatherResultSchema>;
|
||||
Reference in New Issue
Block a user