Trek_CN/server/tests/integration/weather.test.ts

263 lines
9.3 KiB
TypeScript
Raw Normal View History

/**
* 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: () => {},
}));
refactor(server): replace node-fetch with native fetch + undici, fix photo integrations Replace node-fetch v2 with Node 22's built-in fetch API across the entire server. Add undici as an explicit dependency to provide the dispatcher API needed for DNS pinning (SSRF rebinding prevention) in ssrfGuard.ts. All seven service files that used a plain `import fetch from 'node-fetch'` are updated to use the global. The ssrfGuard safeFetch/createPinnedAgent is rewritten as createPinnedDispatcher using an undici Agent, with correct handling of the `all: true` lookup callback required by Node 18+. The collabService dynamic require() and notifications agent option are updated to use the dispatcher pattern. Test mocks are migrated from vi.mock('node-fetch') to vi.stubGlobal('fetch'), and streaming test fixtures are updated to use Web ReadableStream instead of Node Readable. Fix several bugs in the Synology and Immich photo integrations: - pipeAsset: guard against setting headers after stream has already started - _getSynologySession: clear stale SID and re-login when decrypt_api_key returns null instead of propagating success(null) downstream - _requestSynologyApi: return retrySession error (not stale session) on retry failure; also retry on error codes 106 (timeout) and 107 (duplicate login), not only 119 - searchSynologyPhotos: fix incorrect total field type (Synology list_item returns no total); hasMore correctly uses allItems.length === limit - _splitPackedSynologyId: validate cache_key format before use; callers return 400 - getImmichCredentials / _getSynologyCredentials: treat null from decrypt_api_key as a missing-credentials condition rather than casting null to string - Synology size param: enforce allowlist ['sm', 'm', 'xl'] per API documentation
2026-04-06 03:11:43 +08:00
// 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();
refactor(server): replace node-fetch with native fetch + undici, fix photo integrations Replace node-fetch v2 with Node 22's built-in fetch API across the entire server. Add undici as an explicit dependency to provide the dispatcher API needed for DNS pinning (SSRF rebinding prevention) in ssrfGuard.ts. All seven service files that used a plain `import fetch from 'node-fetch'` are updated to use the global. The ssrfGuard safeFetch/createPinnedAgent is rewritten as createPinnedDispatcher using an undici Agent, with correct handling of the `all: true` lookup callback required by Node 18+. The collabService dynamic require() and notifications agent option are updated to use the dispatcher pattern. Test mocks are migrated from vi.mock('node-fetch') to vi.stubGlobal('fetch'), and streaming test fixtures are updated to use Web ReadableStream instead of Node Readable. Fix several bugs in the Synology and Immich photo integrations: - pipeAsset: guard against setting headers after stream has already started - _getSynologySession: clear stale SID and re-login when decrypt_api_key returns null instead of propagating success(null) downstream - _requestSynologyApi: return retrySession error (not stale session) on retry failure; also retry on error codes 106 (timeout) and 107 (duplicate login), not only 119 - searchSynologyPhotos: fix incorrect total field type (Synology list_item returns no total); hasMore correctly uses allItems.length === limit - _splitPackedSynologyId: validate cache_key format before use; callers return 400 - getImmichCredentials / _getSynologyCredentials: treat null from decrypt_api_key as a missing-credentials condition rather than casting null to string - Synology size param: enforce allowlist ['sm', 'm', 'xl'] per API documentation
2026-04-06 03:11:43 +08:00
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');
});
test: expand test suite to 87.3% backend coverage Add new integration test files covering previously untested routes: - categories.test.ts — GET /api/categories - oidc.test.ts — full OIDC login flow (callback, state, errors) - settings.test.ts — GET/PUT /api/settings, bulk save - tags.test.ts — CRUD for trip tags - todo.test.ts — todo items CRUD and reorder Add new unit test files covering service-layer logic: - adminService.test.ts — user/invite management, packing templates, OIDC settings - atlasService.test.ts — atlas search and place enrichment - authServiceDb.test.ts — DB-backed auth helpers (login, register, MFA) - backupService.test.ts — export/import/restore logic - categoryService.test.ts — category CRUD - dayService.test.ts — day management and accommodation helpers - mapsService.test.ts — route/directions helpers - oidcService.test.ts — OIDC state, auth code, role resolution, user upsert - packingService.test.ts — packing item/bag/template operations - placeService.test.ts — place CRUD and tag attachment - settingsService.test.ts — settings get/set/bulk - tagService.test.ts — tag CRUD - todoService.test.ts — todo CRUD and reorder - tripService.test.ts — trip CRUD, member management, archiving - vacayService.test.ts — vacay integration helpers - tripAccess.test.ts (middleware) — requireTripAccess middleware Expand existing integration and unit test files with additional cases across admin, atlas, auth, backup, collab, days, files, maps, memories (Immich/Synology), notifications, places, reservations, share, vacay, weather, auth middleware, ephemeral tokens, notification preferences, permissions, SSRF guard, and WebSocket connection tests. Update test helpers (factories.ts, test-db.ts) with new factory functions and seed data required by the expanded suite. Fix minor issues in server/src/routes/reservations.ts and server/src/services/atlasService.ts surfaced by new test coverage. Update sonar-project.properties to reflect new coverage thresholds.
2026-04-07 02:06:46 +08:00
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');
});
});