diff --git a/client/src/api/client.ts b/client/src/api/client.ts index 57c90fbb..106a7f36 100644 --- a/client/src/api/client.ts +++ b/client/src/api/client.ts @@ -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 => apiClient.get('/weather', { params: { lat, lng, date } }).then(r => r.data), + getDetailed: (lat: number, lng: number, date: string, lang?: string): Promise => apiClient.get('/weather/detailed', { params: { lat, lng, date, lang } }).then(r => r.data), } export const configApi = { diff --git a/server/package.json b/server/package.json index e10f06dc..87ef8302 100644 --- a/server/package.json +++ b/server/package.json @@ -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": { diff --git a/server/src/app.ts b/server/src/app.ts index bff3d1d3..06202a6a 100644 --- a/server/src/app.ts +++ b/server/src/app.ts @@ -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); diff --git a/server/src/nest/README.md b/server/src/nest/README.md new file mode 100644 index 00000000..9c86ee84 --- /dev/null +++ b/server/src/nest/README.md @@ -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//.schema.ts(.spec.ts) # Zod contract — single source of truth +server/src/nest//.service.ts # business logic (ported 1:1 from the Express service) +server/src/nest//.controller.ts # same routes/verbs/params/status codes as Express +server/src/nest//.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/.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/.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/.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). diff --git a/server/src/nest/app.module.ts b/server/src/nest/app.module.ts index b6381b46..30e87991 100644 --- a/server/src/nest/app.module.ts +++ b/server/src/nest/app.module.ts @@ -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, diff --git a/server/src/nest/strangler.ts b/server/src/nest/strangler.ts index df529f10..038001ae 100644 --- a/server/src/nest/strangler.ts +++ b/server/src/nest/strangler.ts @@ -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; diff --git a/server/src/nest/weather/weather.controller.ts b/server/src/nest/weather/weather.controller.ts new file mode 100644 index 00000000..44760625 --- /dev/null +++ b/server/src/nest/weather/weather.controller.ts @@ -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 { + 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 { + 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); +} diff --git a/server/src/nest/weather/weather.module.ts b/server/src/nest/weather/weather.module.ts new file mode 100644 index 00000000..3615fa1a --- /dev/null +++ b/server/src/nest/weather/weather.module.ts @@ -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 {} diff --git a/server/src/nest/weather/weather.service.ts b/server/src/nest/weather/weather.service.ts new file mode 100644 index 00000000..a9319d3a --- /dev/null +++ b/server/src/nest/weather/weather.service.ts @@ -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 { + return getWeather(lat, lng, date, lang) as Promise; + } + + getDetailed(lat: string, lng: string, date: string, lang: string): Promise { + return getDetailedWeather(lat, lng, date, lang) as Promise; + } +} diff --git a/server/src/routes/weather.ts b/server/src/routes/weather.ts deleted file mode 100644 index 08194dd8..00000000 --- a/server/src/routes/weather.ts +++ /dev/null @@ -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; diff --git a/server/tests/e2e/harness.ts b/server/tests/e2e/harness.ts new file mode 100644 index 00000000..e829bce0 --- /dev/null +++ b/server/tests/e2e/harness.ts @@ -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 { + 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)}`; +} diff --git a/server/tests/e2e/weather.e2e.test.ts b/server/tests/e2e/weather.e2e.test.ts new file mode 100644 index 00000000..fa470857 --- /dev/null +++ b/server/tests/e2e/weather.e2e.test.ts @@ -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(); + 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>; + + 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' }); + }); +}); diff --git a/server/tests/integration/weather.test.ts b/server/tests/integration/weather.test.ts deleted file mode 100644 index 5acded6f..00000000 --- a/server/tests/integration/weather.test.ts +++ /dev/null @@ -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'); - }); -}); diff --git a/server/tests/parity/parity.ts b/server/tests/parity/parity.ts new file mode 100644 index 00000000..dd5dfa6a --- /dev/null +++ b/server/tests/parity/parity.ts @@ -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; + 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 { + 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); +} diff --git a/server/tests/unit/nest/database-service.test.ts b/server/tests/unit/nest/database-service.test.ts new file mode 100644 index 00000000..fdfe5a15 --- /dev/null +++ b/server/tests/unit/nest/database-service.test.ts @@ -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'); + }); +}); diff --git a/server/tests/unit/nest/strangler.test.ts b/server/tests/unit/nest/strangler.test.ts index 53449b57..89970059 100644 --- a/server/tests/unit/nest/strangler.test.ts +++ b/server/tests/unit/nest/strangler.test.ts @@ -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)', () => { diff --git a/server/tests/unit/nest/weather.controller.test.ts b/server/tests/unit/nest/weather.controller.test.ts new file mode 100644 index 00000000..d6dff1f4 --- /dev/null +++ b/server/tests/unit/nest/weather.controller.test.ts @@ -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) { + return new WeatherController(svc as WeatherService); +} + +/** Run `fn`, expecting it to throw an HttpException; return its { status, body }. */ +async function thrown(fn: () => Promise): 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' }, + }); + }); + }); +}); diff --git a/server/vitest.config.ts b/server/vitest.config.ts index ebd5422d..51ca41c2 100644 --- a/server/vitest.config.ts +++ b/server/vitest.config.ts @@ -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'], }, }); \ No newline at end of file diff --git a/shared/src/index.ts b/shared/src/index.ts index 79af47cb..9769fe55 100644 --- a/shared/src/index.ts +++ b/shared/src/index.ts @@ -10,3 +10,6 @@ */ export * from './common/primitives.schema'; export * from './common/pagination.schema'; + +// Domain contracts +export * from './weather/weather.schema'; diff --git a/shared/src/weather/weather.schema.spec.ts b/shared/src/weather/weather.schema.spec.ts new file mode 100644 index 00000000..77aad873 --- /dev/null +++ b/shared/src/weather/weather.schema.spec.ts @@ -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); + }); +}); diff --git a/shared/src/weather/weather.schema.ts b/shared/src/weather/weather.schema.ts new file mode 100644 index 00000000..9a79738a --- /dev/null +++ b/shared/src/weather/weather.schema.ts @@ -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; + +/** 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; + +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; + +/** + * 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;