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:
@@ -1,4 +1,5 @@
|
||||
import axios, { AxiosInstance } from 'axios'
|
||||
import type { WeatherResult } from '@trek/shared'
|
||||
import { getSocketId } from './websocket'
|
||||
import { isReachable, probeNow } from '../sync/connectivity'
|
||||
import en from '../i18n/translations/en'
|
||||
@@ -501,8 +502,8 @@ export const reservationsApi = {
|
||||
}
|
||||
|
||||
export const weatherApi = {
|
||||
get: (lat: number, lng: number, date: string) => apiClient.get('/weather', { params: { lat, lng, date } }).then(r => r.data),
|
||||
getDetailed: (lat: number, lng: number, date: string, lang?: string) => apiClient.get('/weather/detailed', { params: { lat, lng, date, lang } }).then(r => r.data),
|
||||
get: (lat: number, lng: number, date: string): Promise<WeatherResult> => apiClient.get('/weather', { params: { lat, lng, date } }).then(r => r.data),
|
||||
getDetailed: (lat: number, lng: number, date: string, lang?: string): Promise<WeatherResult> => apiClient.get('/weather/detailed', { params: { lat, lng, date, lang } }).then(r => r.data),
|
||||
}
|
||||
|
||||
export const configApi = {
|
||||
|
||||
@@ -13,6 +13,8 @@
|
||||
"test:unit": "vitest run tests/unit",
|
||||
"test:integration": "vitest run tests/integration",
|
||||
"test:ws": "vitest run tests/websocket",
|
||||
"test:parity": "vitest run tests/parity",
|
||||
"test:e2e": "vitest run tests/e2e",
|
||||
"test:coverage": "vitest run --coverage"
|
||||
},
|
||||
"dependencies": {
|
||||
|
||||
+2
-2
@@ -26,7 +26,6 @@ import airportsRoutes from './routes/airports';
|
||||
import filesRoutes from './routes/files';
|
||||
import reservationsRoutes from './routes/reservations';
|
||||
import dayNotesRoutes from './routes/dayNotes';
|
||||
import weatherRoutes from './routes/weather';
|
||||
import settingsRoutes from './routes/settings';
|
||||
import budgetRoutes from './routes/budget';
|
||||
import collabRoutes from './routes/collab';
|
||||
@@ -361,7 +360,8 @@ export function createApp(): express.Application {
|
||||
app.use('/api/photos', photoRoutes);
|
||||
app.use('/api/maps', mapsRoutes);
|
||||
app.use('/api/airports', airportsRoutes);
|
||||
app.use('/api/weather', weatherRoutes);
|
||||
// /api/weather is served by the NestJS weather module (see src/nest/weather);
|
||||
// the legacy Express route was decommissioned after the migration (L1).
|
||||
app.use('/api/settings', settingsRoutes);
|
||||
app.use('/api/system-notices', systemNoticesRoutes);
|
||||
app.use('/api/backup', backupRoutes);
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
# NestJS migration layer — module & test guide
|
||||
|
||||
This folder holds the co-hosted NestJS app that incrementally strangles the legacy
|
||||
Express API (see the "Brownfield Rewrite" board). Until a prefix is migrated, the
|
||||
top-level dispatcher in `src/index.ts` routes it to the legacy app; migrated
|
||||
prefixes go to Nest. **Weather (`weather/`) is the reference implementation** — copy
|
||||
its shape when migrating a new domain.
|
||||
|
||||
## Module layout (per domain)
|
||||
|
||||
```
|
||||
shared/src/<domain>/<domain>.schema.ts(.spec.ts) # Zod contract — single source of truth
|
||||
server/src/nest/<domain>/<domain>.service.ts # business logic (ported 1:1 from the Express service)
|
||||
server/src/nest/<domain>/<domain>.controller.ts # same routes/verbs/params/status codes as Express
|
||||
server/src/nest/<domain>/<domain>.module.ts # registered in app.module.ts
|
||||
```
|
||||
|
||||
Add the prefix to `DEFAULT_NEST_PREFIXES` in `strangler.ts` to route it to Nest
|
||||
(operators can override at runtime via the `NEST_PREFIXES` env var — instant
|
||||
rollback, no redeploy).
|
||||
|
||||
## Parity is law
|
||||
|
||||
A migrated route must be **byte-identical** for the client: same URL, method,
|
||||
query/body, HTTP status, `Set-Cookie`, and JSON body — including bespoke error
|
||||
strings. Where the legacy route returns a hand-written error (e.g. weather's
|
||||
`{ error: 'Latitude and longitude are required' }`), reproduce that exact body in
|
||||
the controller rather than relying on the generic `ZodValidationPipe` envelope.
|
||||
|
||||
## How to write the tests
|
||||
|
||||
Every module ships three kinds of tests; the coverage gate (`vitest.config.ts`,
|
||||
scoped to `src/nest/**`) requires ≥80%.
|
||||
|
||||
1. **Service / controller unit spec** — `tests/unit/nest/<domain>.controller.test.ts`.
|
||||
Instantiate the controller with a mocked service; assert status codes, the exact
|
||||
`{ error }` bodies, and that inputs are forwarded correctly (defaults, coercion).
|
||||
See `weather.controller.test.ts`.
|
||||
|
||||
2. **Parity test** — `tests/parity/<domain>.parity.test.ts`. Mock the shared service
|
||||
identically for both apps, then fire the same request at the Express route and the
|
||||
Nest controller with the `expectParity()` harness (`tests/parity/parity.ts`) and
|
||||
assert identical status + body. This is the gate before flipping the toggle.
|
||||
See `weather.parity.test.ts`.
|
||||
|
||||
3. **e2e** — `tests/e2e/<domain>.e2e.test.ts`. Boot the Nest module against a temp
|
||||
in-memory SQLite db via the shared harness (`tests/e2e/harness.ts`:
|
||||
`createTempDb`/`seedUser`/`sessionCookie`), exercising the **real** `JwtAuthGuard`
|
||||
end-to-end (401 without cookie, 200 with a signed session). Mock external I/O
|
||||
(HTTP/etc.). See `weather.e2e.test.ts`.
|
||||
|
||||
## Definition of Done (per module)
|
||||
|
||||
Contract in `@trek/shared` → service ported 1:1 → controller with identical routes →
|
||||
validation/error parity → unit + parity + e2e tests over the gate → prefix toggled to
|
||||
Nest → parity verified on the demo DB → **then** decommission the old Express
|
||||
route/service (separate step, after the toggle is confirmed in prod) → frontend points
|
||||
at the typed contract (Frontend Track).
|
||||
@@ -3,6 +3,7 @@ import { APP_FILTER } from '@nestjs/core';
|
||||
import { DatabaseModule } from './database/database.module';
|
||||
import { HealthController } from './health/health.controller';
|
||||
import { HealthService } from './health/health.service';
|
||||
import { WeatherModule } from './weather/weather.module';
|
||||
import { TrekExceptionFilter } from './common/trek-exception.filter';
|
||||
|
||||
/**
|
||||
@@ -10,7 +11,7 @@ import { TrekExceptionFilter } from './common/trek-exception.filter';
|
||||
* (weather, notifications, ...) get registered here as they are migrated.
|
||||
*/
|
||||
@Module({
|
||||
imports: [DatabaseModule],
|
||||
imports: [DatabaseModule, WeatherModule],
|
||||
controllers: [HealthController],
|
||||
providers: [
|
||||
HealthService,
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
* rollback — no redeploy, no code change. Setting `NEST_PREFIXES=` (empty) routes
|
||||
* everything back to the legacy app.
|
||||
*/
|
||||
const DEFAULT_NEST_PREFIXES = ['/api/_nest'];
|
||||
const DEFAULT_NEST_PREFIXES = ['/api/_nest', '/api/weather'];
|
||||
|
||||
export function getNestPrefixes(): string[] {
|
||||
const raw = process.env.NEST_PREFIXES;
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
import { Controller, Get, HttpException, Query, UseGuards } from '@nestjs/common';
|
||||
import type { WeatherResult } from '@trek/shared';
|
||||
import { WeatherService } from './weather.service';
|
||||
import { JwtAuthGuard } from '../auth/jwt-auth.guard';
|
||||
import { ApiError } from '../../services/weatherService';
|
||||
|
||||
/**
|
||||
* /api/weather — first migrated leaf module (the pilot).
|
||||
*
|
||||
* Behaviour is byte-identical to the legacy Express route (server/src/routes/
|
||||
* weather.ts): same paths, query params, status codes and `{ error }` bodies.
|
||||
*
|
||||
* Parity note: the "X is required" 400s and the 500 fallback messages are bespoke
|
||||
* strings, not the generic Zod-pipe envelope, so they are reproduced here exactly
|
||||
* rather than derived from the schema. The Zod contract/types live in
|
||||
* @trek/shared/weather and are used for typing; `lang` defaults to 'de' only when
|
||||
* the param is absent, matching the Express destructuring default.
|
||||
*/
|
||||
@Controller('api/weather')
|
||||
@UseGuards(JwtAuthGuard)
|
||||
export class WeatherController {
|
||||
constructor(private readonly weather: WeatherService) {}
|
||||
|
||||
@Get()
|
||||
async getWeather(
|
||||
@Query('lat') lat?: string,
|
||||
@Query('lng') lng?: string,
|
||||
@Query('date') date?: string,
|
||||
@Query('lang') lang?: string,
|
||||
): Promise<WeatherResult> {
|
||||
if (!lat || !lng) {
|
||||
throw new HttpException({ error: 'Latitude and longitude are required' }, 400);
|
||||
}
|
||||
try {
|
||||
return await this.weather.get(lat, lng, date, lang ?? 'de');
|
||||
} catch (err: unknown) {
|
||||
throw toHttp(err, 'Weather error:', 'Error fetching weather data');
|
||||
}
|
||||
}
|
||||
|
||||
@Get('detailed')
|
||||
async getDetailed(
|
||||
@Query('lat') lat?: string,
|
||||
@Query('lng') lng?: string,
|
||||
@Query('date') date?: string,
|
||||
@Query('lang') lang?: string,
|
||||
): Promise<WeatherResult> {
|
||||
if (!lat || !lng || !date) {
|
||||
throw new HttpException({ error: 'Latitude, longitude, and date are required' }, 400);
|
||||
}
|
||||
try {
|
||||
return await this.weather.getDetailed(lat, lng, date, lang ?? 'de');
|
||||
} catch (err: unknown) {
|
||||
throw toHttp(err, 'Detailed weather error:', 'Error fetching detailed weather data');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Maps a thrown error to the same status + `{ error }` body the Express route sent. */
|
||||
function toHttp(err: unknown, logPrefix: string, fallback: string): HttpException {
|
||||
if (err instanceof ApiError) {
|
||||
return new HttpException({ error: err.message }, err.status);
|
||||
}
|
||||
console.error(logPrefix, err);
|
||||
return new HttpException({ error: fallback }, 500);
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { WeatherController } from './weather.controller';
|
||||
import { WeatherService } from './weather.service';
|
||||
|
||||
/** Weather domain (pilot leaf module). Registered in AppModule. */
|
||||
@Module({
|
||||
controllers: [WeatherController],
|
||||
providers: [WeatherService],
|
||||
})
|
||||
export class WeatherModule {}
|
||||
@@ -0,0 +1,21 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import type { WeatherResult } from '@trek/shared';
|
||||
import { getWeather, getDetailedWeather } from '../../services/weatherService';
|
||||
|
||||
/**
|
||||
* Thin Nest wrapper around the existing weather service. It delegates to the
|
||||
* exact same `getWeather` / `getDetailedWeather` functions the legacy route and
|
||||
* the MCP tools use, so behaviour — including the shared in-memory cache and the
|
||||
* Open-Meteo calls — is identical. No logic is duplicated; the upstream service
|
||||
* stays the single source of truth (still consumed by the MCP weather tools).
|
||||
*/
|
||||
@Injectable()
|
||||
export class WeatherService {
|
||||
get(lat: string, lng: string, date: string | undefined, lang: string): Promise<WeatherResult> {
|
||||
return getWeather(lat, lng, date, lang) as Promise<WeatherResult>;
|
||||
}
|
||||
|
||||
getDetailed(lat: string, lng: string, date: string, lang: string): Promise<WeatherResult> {
|
||||
return getDetailedWeather(lat, lng, date, lang) as Promise<WeatherResult>;
|
||||
}
|
||||
}
|
||||
@@ -1,45 +0,0 @@
|
||||
import express, { Request, Response } from 'express';
|
||||
import { authenticate } from '../middleware/auth';
|
||||
import { getWeather, getDetailedWeather, ApiError } from '../services/weatherService';
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
router.get('/', authenticate, async (req: Request, res: Response) => {
|
||||
const { lat, lng, date, lang = 'de' } = req.query as { lat: string; lng: string; date?: string; lang?: string };
|
||||
|
||||
if (!lat || !lng) {
|
||||
return res.status(400).json({ error: 'Latitude and longitude are required' });
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await getWeather(lat, lng, date, lang);
|
||||
res.json(result);
|
||||
} catch (err: unknown) {
|
||||
if (err instanceof ApiError) {
|
||||
return res.status(err.status).json({ error: err.message });
|
||||
}
|
||||
console.error('Weather error:', err);
|
||||
res.status(500).json({ error: 'Error fetching weather data' });
|
||||
}
|
||||
});
|
||||
|
||||
router.get('/detailed', authenticate, async (req: Request, res: Response) => {
|
||||
const { lat, lng, date, lang = 'de' } = req.query as { lat: string; lng: string; date: string; lang?: string };
|
||||
|
||||
if (!lat || !lng || !date) {
|
||||
return res.status(400).json({ error: 'Latitude, longitude, and date are required' });
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await getDetailedWeather(lat, lng, date, lang);
|
||||
res.json(result);
|
||||
} catch (err: unknown) {
|
||||
if (err instanceof ApiError) {
|
||||
return res.status(err.status).json({ error: err.message });
|
||||
}
|
||||
console.error('Detailed weather error:', err);
|
||||
res.status(500).json({ error: 'Error fetching detailed weather data' });
|
||||
}
|
||||
});
|
||||
|
||||
export default router;
|
||||
@@ -0,0 +1,65 @@
|
||||
import Database from 'better-sqlite3';
|
||||
import jwt from 'jsonwebtoken';
|
||||
import { JWT_SECRET } from '../../src/config';
|
||||
|
||||
/**
|
||||
* Shared e2e harness for migrated Nest modules.
|
||||
*
|
||||
* Gives each module e2e test a throwaway in-memory SQLite db (the same shape the
|
||||
* shared connection exposes), a seed helper for demo data, and a session-cookie
|
||||
* signer that produces tokens the REAL JwtAuthGuard accepts — so e2e tests cover
|
||||
* the actual auth path end-to-end, not a stubbed guard.
|
||||
*
|
||||
* Wire it in a test with `vi.mock('../../src/db/database', () => ({ db, ... }))`
|
||||
* using the db returned here, then build the Nest app under test.
|
||||
*/
|
||||
|
||||
export interface SeededUser {
|
||||
id: number;
|
||||
username: string;
|
||||
email: string;
|
||||
role: 'user' | 'admin';
|
||||
password_version: number;
|
||||
}
|
||||
|
||||
/** Fresh in-memory db with the minimal `users` table the auth guard reads. */
|
||||
export function createTempDb(): Database.Database {
|
||||
const db = new Database(':memory:');
|
||||
db.exec('PRAGMA journal_mode = WAL');
|
||||
db.exec('PRAGMA foreign_keys = ON');
|
||||
db.exec(`
|
||||
CREATE TABLE users (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
username TEXT NOT NULL,
|
||||
email TEXT NOT NULL UNIQUE,
|
||||
role TEXT NOT NULL DEFAULT 'user',
|
||||
password_version INTEGER NOT NULL DEFAULT 0
|
||||
);
|
||||
`);
|
||||
return db;
|
||||
}
|
||||
|
||||
/** Insert a demo user and return its row. */
|
||||
export function seedUser(db: Database.Database, overrides: Partial<SeededUser> = {}): SeededUser {
|
||||
const user: SeededUser = {
|
||||
id: overrides.id ?? 1,
|
||||
username: overrides.username ?? 'e2e-user',
|
||||
email: overrides.email ?? 'e2e@example.test',
|
||||
role: overrides.role ?? 'user',
|
||||
password_version: overrides.password_version ?? 0,
|
||||
};
|
||||
db.prepare(
|
||||
'INSERT INTO users (id, username, email, role, password_version) VALUES (?, ?, ?, ?, ?)',
|
||||
).run(user.id, user.username, user.email, user.role, user.password_version);
|
||||
return user;
|
||||
}
|
||||
|
||||
/** Sign a `trek_session` token the real guard will accept (matching JWT_SECRET + pv). */
|
||||
export function signSession(userId: number, passwordVersion = 0): string {
|
||||
return jwt.sign({ id: userId, pv: passwordVersion }, JWT_SECRET, { algorithm: 'HS256' });
|
||||
}
|
||||
|
||||
/** Convenience: the Cookie header value for a signed session. */
|
||||
export function sessionCookie(userId: number, passwordVersion = 0): string {
|
||||
return `trek_session=${signSession(userId, passwordVersion)}`;
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
/**
|
||||
* Weather module e2e — exercises the migrated /api/weather endpoints through the
|
||||
* real JwtAuthGuard against a temp SQLite db (seeded via the shared harness).
|
||||
* The weather service is mocked so no real Open-Meteo calls happen.
|
||||
*/
|
||||
import { describe, it, expect, beforeAll, afterAll, vi } from 'vitest';
|
||||
import request from 'supertest';
|
||||
import cookieParser from 'cookie-parser';
|
||||
import type { Server } from 'http';
|
||||
import { Test } from '@nestjs/testing';
|
||||
import { createTempDb, seedUser, sessionCookie } from './harness';
|
||||
|
||||
const { db } = vi.hoisted(() => {
|
||||
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
||||
const Database = require('better-sqlite3');
|
||||
const tmp = new Database(':memory:');
|
||||
tmp.exec('PRAGMA journal_mode = WAL');
|
||||
tmp.exec(`CREATE TABLE users (id INTEGER PRIMARY KEY AUTOINCREMENT, username TEXT NOT NULL,
|
||||
email TEXT NOT NULL UNIQUE, role TEXT NOT NULL DEFAULT 'user', password_version INTEGER NOT NULL DEFAULT 0);`);
|
||||
return { db: tmp };
|
||||
});
|
||||
|
||||
vi.mock('../../src/db/database', () => ({ db, closeDb: () => {}, reinitialize: () => {} }));
|
||||
|
||||
const { mockGet, mockGetDetailed } = vi.hoisted(() => ({ mockGet: vi.fn(), mockGetDetailed: vi.fn() }));
|
||||
vi.mock('../../src/services/weatherService', async (importActual) => {
|
||||
const actual = await importActual<typeof import('../../src/services/weatherService')>();
|
||||
return { ...actual, getWeather: mockGet, getDetailedWeather: mockGetDetailed };
|
||||
});
|
||||
|
||||
import { WeatherModule } from '../../src/nest/weather/weather.module';
|
||||
import { TrekExceptionFilter } from '../../src/nest/common/trek-exception.filter';
|
||||
|
||||
describe('Weather e2e (real auth guard + temp SQLite)', () => {
|
||||
let server: Server;
|
||||
let app: Awaited<ReturnType<typeof build>>;
|
||||
|
||||
async function build() {
|
||||
const moduleRef = await Test.createTestingModule({ imports: [WeatherModule] }).compile();
|
||||
const nest = moduleRef.createNestApplication();
|
||||
nest.use(cookieParser());
|
||||
nest.useGlobalFilters(new TrekExceptionFilter());
|
||||
await nest.init();
|
||||
return nest;
|
||||
}
|
||||
|
||||
beforeAll(async () => {
|
||||
seedUser(db as never, { id: 1 });
|
||||
app = await build();
|
||||
server = app.getHttpServer();
|
||||
mockGet.mockResolvedValue({ temp: 21, main: 'Clear', description: 'Klar', type: 'current' });
|
||||
mockGetDetailed.mockResolvedValue({ temp: 20, main: 'Rain', description: 'Regen', type: 'forecast', hourly: [] });
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await app.close();
|
||||
});
|
||||
|
||||
it('401 { error, code } without a session cookie', async () => {
|
||||
const res = await request(server).get('/api/weather').query({ lat: '1', lng: '2' });
|
||||
expect(res.status).toBe(401);
|
||||
expect(res.body).toEqual({ error: 'Access token required', code: 'AUTH_REQUIRED' });
|
||||
});
|
||||
|
||||
it('401 with an invalid token', async () => {
|
||||
const res = await request(server).get('/api/weather').set('Cookie', 'trek_session=not-a-jwt').query({ lat: '1', lng: '2' });
|
||||
expect(res.status).toBe(401);
|
||||
expect(res.body).toEqual({ error: 'Invalid or expired token', code: 'AUTH_REQUIRED' });
|
||||
});
|
||||
|
||||
it('400 when authenticated but lat/lng missing', async () => {
|
||||
const res = await request(server).get('/api/weather').set('Cookie', sessionCookie(1)).query({ lng: '2' });
|
||||
expect(res.status).toBe(400);
|
||||
expect(res.body).toEqual({ error: 'Latitude and longitude are required' });
|
||||
});
|
||||
|
||||
it('200 with a valid session cookie', async () => {
|
||||
const res = await request(server).get('/api/weather').set('Cookie', sessionCookie(1)).query({ lat: '52.5', lng: '13.4' });
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body).toMatchObject({ temp: 21, main: 'Clear', type: 'current' });
|
||||
});
|
||||
|
||||
it('200 on /detailed with a valid session cookie', async () => {
|
||||
const res = await request(server).get('/api/weather/detailed').set('Cookie', sessionCookie(1)).query({ lat: '1', lng: '2', date: '2026-07-01' });
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body).toMatchObject({ type: 'forecast' });
|
||||
});
|
||||
});
|
||||
@@ -1,262 +0,0 @@
|
||||
/**
|
||||
* Weather integration tests.
|
||||
* Covers WEATHER-001 to WEATHER-007.
|
||||
*
|
||||
* External API calls (Open-Meteo) are mocked via vi.mock.
|
||||
*/
|
||||
import { describe, it, expect, vi, beforeAll, beforeEach, afterAll } from 'vitest';
|
||||
import request from 'supertest';
|
||||
import type { Application } from 'express';
|
||||
|
||||
const { testDb, dbMock } = vi.hoisted(() => {
|
||||
const Database = require('better-sqlite3');
|
||||
const db = new Database(':memory:');
|
||||
db.exec('PRAGMA journal_mode = WAL');
|
||||
db.exec('PRAGMA foreign_keys = ON');
|
||||
db.exec('PRAGMA busy_timeout = 5000');
|
||||
const mock = {
|
||||
db,
|
||||
closeDb: () => {},
|
||||
reinitialize: () => {},
|
||||
getPlaceWithTags: (placeId: number) => {
|
||||
const place: any = db.prepare(`SELECT p.*, c.name as category_name, c.color as category_color, c.icon as category_icon FROM places p LEFT JOIN categories c ON p.category_id = c.id WHERE p.id = ?`).get(placeId);
|
||||
if (!place) return null;
|
||||
const tags = db.prepare(`SELECT t.* FROM tags t JOIN place_tags pt ON t.id = pt.tag_id WHERE pt.place_id = ?`).all(placeId);
|
||||
return { ...place, category: place.category_id ? { id: place.category_id, name: place.category_name, color: place.category_color, icon: place.category_icon } : null, tags };
|
||||
},
|
||||
canAccessTrip: (tripId: any, userId: number) =>
|
||||
db.prepare(`SELECT t.id, t.user_id FROM trips t LEFT JOIN trip_members m ON m.trip_id = t.id AND m.user_id = ? WHERE t.id = ? AND (t.user_id = ? OR m.user_id IS NOT NULL)`).get(userId, tripId, userId),
|
||||
isOwner: (tripId: any, userId: number) =>
|
||||
!!db.prepare('SELECT id FROM trips WHERE id = ? AND user_id = ?').get(tripId, userId),
|
||||
};
|
||||
return { testDb: db, dbMock: mock };
|
||||
});
|
||||
|
||||
vi.mock('../../src/db/database', () => dbMock);
|
||||
vi.mock('../../src/config', () => ({
|
||||
JWT_SECRET: 'test-jwt-secret-for-trek-testing-only',
|
||||
ENCRYPTION_KEY: 'a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2',
|
||||
updateJwtSecret: () => {},
|
||||
}));
|
||||
|
||||
// Prevent real HTTP calls to Open-Meteo
|
||||
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
json: () => Promise.resolve({
|
||||
current: { temperature_2m: 22, weathercode: 1, windspeed_10m: 10, relativehumidity_2m: 60, precipitation: 0 },
|
||||
daily: {
|
||||
time: ['2025-06-01'],
|
||||
temperature_2m_max: [25],
|
||||
temperature_2m_min: [18],
|
||||
weathercode: [1],
|
||||
precipitation_sum: [0],
|
||||
windspeed_10m_max: [15],
|
||||
sunrise: ['2025-06-01T06:00'],
|
||||
sunset: ['2025-06-01T21:00'],
|
||||
},
|
||||
}),
|
||||
}));
|
||||
|
||||
import { createApp } from '../../src/app';
|
||||
import { createTables } from '../../src/db/schema';
|
||||
import { runMigrations } from '../../src/db/migrations';
|
||||
import { resetTestDb } from '../helpers/test-db';
|
||||
import { createUser } from '../helpers/factories';
|
||||
import { authCookie } from '../helpers/auth';
|
||||
import { loginAttempts, mfaAttempts } from '../../src/routes/auth';
|
||||
|
||||
const app: Application = createApp();
|
||||
|
||||
beforeAll(() => {
|
||||
createTables(testDb);
|
||||
runMigrations(testDb);
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
resetTestDb(testDb);
|
||||
loginAttempts.clear();
|
||||
mfaAttempts.clear();
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
testDb.close();
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
describe('Weather validation', () => {
|
||||
it('WEATHER-001 — GET /weather without lat/lng returns 400', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
|
||||
const res = await request(app)
|
||||
.get('/api/weather')
|
||||
.set('Cookie', authCookie(user.id));
|
||||
expect(res.status).toBe(400);
|
||||
});
|
||||
|
||||
it('WEATHER-001 — GET /weather without lng returns 400', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
|
||||
const res = await request(app)
|
||||
.get('/api/weather?lat=48.8566')
|
||||
.set('Cookie', authCookie(user.id));
|
||||
expect(res.status).toBe(400);
|
||||
});
|
||||
|
||||
it('WEATHER-005 — GET /weather/detailed without date returns 400', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
|
||||
const res = await request(app)
|
||||
.get('/api/weather/detailed?lat=48.8566&lng=2.3522')
|
||||
.set('Cookie', authCookie(user.id));
|
||||
expect(res.status).toBe(400);
|
||||
});
|
||||
|
||||
it('WEATHER-001 — GET /weather without auth returns 401', async () => {
|
||||
const res = await request(app)
|
||||
.get('/api/weather?lat=48.8566&lng=2.3522');
|
||||
expect(res.status).toBe(401);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Weather with mocked API', () => {
|
||||
it('WEATHER-001 — GET /weather with lat/lng returns weather data', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
|
||||
const res = await request(app)
|
||||
.get('/api/weather?lat=48.8566&lng=2.3522')
|
||||
.set('Cookie', authCookie(user.id));
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body).toHaveProperty('temp');
|
||||
expect(res.body).toHaveProperty('main');
|
||||
});
|
||||
|
||||
it('WEATHER-002 — GET /weather?date=future returns forecast data', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const futureDate = new Date();
|
||||
futureDate.setDate(futureDate.getDate() + 5);
|
||||
const dateStr = futureDate.toISOString().slice(0, 10);
|
||||
|
||||
const res = await request(app)
|
||||
.get(`/api/weather?lat=48.8566&lng=2.3522&date=${dateStr}`)
|
||||
.set('Cookie', authCookie(user.id));
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body).toHaveProperty('temp');
|
||||
expect(res.body).toHaveProperty('type');
|
||||
});
|
||||
|
||||
it('WEATHER-006 — GET /weather accepts lang parameter', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
|
||||
const res = await request(app)
|
||||
.get('/api/weather?lat=48.8566&lng=2.3522&lang=en')
|
||||
.set('Cookie', authCookie(user.id));
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body).toHaveProperty('temp');
|
||||
});
|
||||
|
||||
it('WEATHER-007 — GET /weather returns 500 on non-ok API response (ApiError path)', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
// Use unique coords to avoid cache from previous tests
|
||||
vi.mocked(global.fetch as any).mockResolvedValueOnce({
|
||||
ok: false,
|
||||
status: 503,
|
||||
json: () => Promise.resolve({ error: true, reason: 'Service unavailable' }),
|
||||
});
|
||||
const futureDate = new Date();
|
||||
futureDate.setDate(futureDate.getDate() + 3);
|
||||
const dateStr = futureDate.toISOString().slice(0, 10);
|
||||
|
||||
const res = await request(app)
|
||||
.get(`/api/weather?lat=55.0&lng=25.0&date=${dateStr}`)
|
||||
.set('Cookie', authCookie(user.id));
|
||||
expect(res.status).toBe(503);
|
||||
expect(res.body).toHaveProperty('error');
|
||||
});
|
||||
|
||||
it('WEATHER-008 — GET /weather returns 500 on network error (generic error path)', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
vi.mocked(global.fetch as any).mockRejectedValueOnce(new Error('Network error'));
|
||||
const futureDate = new Date();
|
||||
futureDate.setDate(futureDate.getDate() + 4);
|
||||
const dateStr = futureDate.toISOString().slice(0, 10);
|
||||
|
||||
const res = await request(app)
|
||||
.get(`/api/weather?lat=56.0&lng=26.0&date=${dateStr}`)
|
||||
.set('Cookie', authCookie(user.id));
|
||||
expect(res.status).toBe(500);
|
||||
expect(res.body).toHaveProperty('error');
|
||||
});
|
||||
|
||||
it('WEATHER-009 — GET /weather/detailed returns detailed weather data', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const futureDate = new Date();
|
||||
futureDate.setDate(futureDate.getDate() + 2);
|
||||
const dateStr = futureDate.toISOString().slice(0, 10);
|
||||
|
||||
// Override mock with full detailed forecast response
|
||||
vi.mocked(global.fetch as any).mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: () => Promise.resolve({
|
||||
daily: {
|
||||
time: [dateStr],
|
||||
temperature_2m_max: [24],
|
||||
temperature_2m_min: [16],
|
||||
weathercode: [1],
|
||||
precipitation_sum: [0],
|
||||
windspeed_10m_max: [12],
|
||||
sunrise: [`${dateStr}T06:00`],
|
||||
sunset: [`${dateStr}T21:00`],
|
||||
precipitation_probability_max: [10],
|
||||
},
|
||||
hourly: {
|
||||
time: [`${dateStr}T12:00`],
|
||||
temperature_2m: [20],
|
||||
precipitation_probability: [5],
|
||||
precipitation: [0],
|
||||
weathercode: [1],
|
||||
windspeed_10m: [10],
|
||||
relativehumidity_2m: [55],
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
||||
const res = await request(app)
|
||||
.get(`/api/weather/detailed?lat=50.0&lng=10.0&date=${dateStr}`)
|
||||
.set('Cookie', authCookie(user.id));
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body).toHaveProperty('temp');
|
||||
expect(res.body.type).toBe('forecast');
|
||||
});
|
||||
|
||||
it('WEATHER-010 — GET /weather/detailed returns error status on ApiError', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
vi.mocked(global.fetch as any).mockResolvedValueOnce({
|
||||
ok: false,
|
||||
status: 502,
|
||||
json: () => Promise.resolve({ error: true, reason: 'Bad Gateway' }),
|
||||
});
|
||||
const futureDate = new Date();
|
||||
futureDate.setDate(futureDate.getDate() + 6);
|
||||
const dateStr = futureDate.toISOString().slice(0, 10);
|
||||
|
||||
const res = await request(app)
|
||||
.get(`/api/weather/detailed?lat=57.0&lng=27.0&date=${dateStr}`)
|
||||
.set('Cookie', authCookie(user.id));
|
||||
expect(res.status).toBe(502);
|
||||
expect(res.body).toHaveProperty('error');
|
||||
});
|
||||
|
||||
it('WEATHER-011 — GET /weather/detailed returns 500 on network error', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
vi.mocked(global.fetch as any).mockRejectedValueOnce(new Error('Network error'));
|
||||
const futureDate = new Date();
|
||||
futureDate.setDate(futureDate.getDate() + 7);
|
||||
const dateStr = futureDate.toISOString().slice(0, 10);
|
||||
|
||||
const res = await request(app)
|
||||
.get(`/api/weather/detailed?lat=58.0&lng=28.0&date=${dateStr}`)
|
||||
.set('Cookie', authCookie(user.id));
|
||||
expect(res.status).toBe(500);
|
||||
expect(res.body).toHaveProperty('error');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,39 @@
|
||||
import request from 'supertest';
|
||||
import { expect } from 'vitest';
|
||||
import type { Server } from 'http';
|
||||
|
||||
export interface ParityRequest {
|
||||
method?: 'get' | 'post' | 'put' | 'patch' | 'delete';
|
||||
path: string;
|
||||
query?: Record<string, string>;
|
||||
body?: unknown;
|
||||
}
|
||||
|
||||
/**
|
||||
* Reusable Nest-vs-Express parity harness.
|
||||
*
|
||||
* Fires the same HTTP request at the legacy Express app and the migrated Nest app
|
||||
* and asserts the response is client-identical — same status code and same JSON
|
||||
* body. With the underlying service mocked identically for both, any difference is
|
||||
* purely framework-layer (routing, validation, error envelope), which is exactly
|
||||
* what a migration must not change. Use one assertion per migrated route/case.
|
||||
*/
|
||||
export async function expectParity(
|
||||
expressServer: Server | Express.Application,
|
||||
nestServer: Server,
|
||||
req: ParityRequest,
|
||||
): Promise<void> {
|
||||
const fire = (target: Server | Express.Application) => {
|
||||
const method = req.method ?? 'get';
|
||||
let r = request(target as never)[method](req.path);
|
||||
if (req.query) r = r.query(req.query);
|
||||
if (req.body !== undefined) r = r.send(req.body as object);
|
||||
return r;
|
||||
};
|
||||
|
||||
const [ex, ne] = await Promise.all([fire(expressServer), fire(nestServer)]);
|
||||
|
||||
const label = `${(req.method ?? 'GET').toUpperCase()} ${req.path}`;
|
||||
expect(ne.status, `${label}: status mismatch`).toBe(ex.status);
|
||||
expect(ne.body, `${label}: body mismatch`).toEqual(ex.body);
|
||||
}
|
||||
@@ -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' },
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
+10
-2
@@ -29,9 +29,10 @@ export default defineConfig({
|
||||
reportsDirectory: './coverage',
|
||||
include: ['src/**/*.ts'],
|
||||
// Coverage gate scoped to the new NestJS code only — the legacy codebase
|
||||
// is intentionally ungated. Ratchet these up as more modules are migrated.
|
||||
// is intentionally ungated. Raised to the DoD's >=80% bar once the first
|
||||
// module (weather) landed; ratchet further as more modules are migrated.
|
||||
thresholds: {
|
||||
'src/nest/**/*.ts': { statements: 60, branches: 55, functions: 55, lines: 60 },
|
||||
'src/nest/**/*.ts': { statements: 80, branches: 80, functions: 80, lines: 80 },
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -57,5 +58,12 @@ export default defineConfig({
|
||||
import.meta.url
|
||||
).pathname,
|
||||
},
|
||||
// The server build emits @trek/shared next to its source (shared/src/*.js,
|
||||
// needed by the prod dist via tsc-alias). Vite's default extension order
|
||||
// prefers .js over .ts, so after a build the tests would load that compiled
|
||||
// CJS instead of the source — and its `require('zod')` is unresolvable from
|
||||
// the shared/ dir on CI (only server deps are installed there). Resolve .ts
|
||||
// first so tests always run the source, whose zod import resolves via Vite.
|
||||
extensions: ['.ts', '.mts', '.mjs', '.js', '.cts', '.cjs', '.tsx', '.jsx', '.json'],
|
||||
},
|
||||
});
|
||||
@@ -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