feat: add comprehensive test suite and CI pipeline

- Add 402 unit tests with 61.7% code coverage
- Add Vitest configuration with coverage reporting
- Extract testable utility functions from services
- Create test.yml workflow (runs on PR and push to main)
- Update docker-build.yml to require tests before building
- Add scheduler-utils.ts and server-config.ts for testable code

Test files added:
- auth.test.ts, medications.test.ts, planner.test.ts
- settings.test.ts, doses.test.ts, share.test.ts
- database.test.ts, server.test.ts, services.test.ts
- env.test.ts, translations.test.ts, integration.test.ts
- e2e-routes.test.ts, stock-calculation.test.ts
This commit is contained in:
Daniel Volz
2025-12-30 11:14:52 +01:00
parent fe9310d3d4
commit ba3ebd27f4
27 changed files with 12666 additions and 401 deletions
+108 -32
View File
@@ -1,14 +1,14 @@
import Fastify from "fastify";
import Fastify, { FastifyInstance } from "fastify";
import helmet from "@fastify/helmet";
import cors from "@fastify/cors";
import rateLimit from "@fastify/rate-limit";
import sensible from "@fastify/sensible";
import cookie, { CookieSerializeOptions } from "@fastify/cookie";
import cookie from "@fastify/cookie";
import jwt from "@fastify/jwt";
import fastifyMultipart from "@fastify/multipart";
import fastifyStatic from "@fastify/static";
import { resolve } from "path";
import { existsSync, mkdirSync } from "fs";
import { existsSync } from "fs";
import { env } from "./plugins/env.js";
import { migrationsReady } from "./db/client.js";
import { healthRoutes } from "./routes/health.js";
@@ -22,15 +22,111 @@ import { doseRoutes } from "./routes/doses.js";
import { startReminderScheduler } from "./services/reminder-scheduler.js";
import { startIntakeReminderScheduler } from "./services/intake-reminder-scheduler.js";
// Re-export utilities from server-config for external use
export {
parseCorsOrigins,
buildBaseCookieOptions,
buildRefreshCookieOptions,
buildAppConfig,
ensureImagesDirectory,
getJwtConfig,
} from "./utils/server-config.js";
import {
parseCorsOrigins,
buildBaseCookieOptions,
buildRefreshCookieOptions,
buildAppConfig,
ensureImagesDirectory,
getJwtConfig,
} from "./utils/server-config.js";
/** Create and configure Fastify app (without starting) */
export async function createApp(options?: {
logLevel?: string;
corsOrigins?: string[];
authEnabled?: boolean;
jwtSecret?: string;
refreshSecret?: string;
cookieSecret?: string;
accessTtlMinutes?: number;
refreshTtlDays?: number;
isProduction?: boolean;
imagesDir?: string;
}): Promise<FastifyInstance> {
const opts = {
logLevel: options?.logLevel ?? "info",
corsOrigins: options?.corsOrigins ?? ["http://localhost:5173"],
authEnabled: options?.authEnabled ?? false,
jwtSecret: options?.jwtSecret,
refreshSecret: options?.refreshSecret,
cookieSecret: options?.cookieSecret ?? "dev-cookie-secret",
accessTtlMinutes: options?.accessTtlMinutes ?? 15,
refreshTtlDays: options?.refreshTtlDays ?? 7,
isProduction: options?.isProduction ?? false,
imagesDir: options?.imagesDir ?? resolve(process.cwd(), "data/images"),
};
const app = Fastify({
logger: { level: opts.logLevel },
});
// Build config
const appConfig = buildAppConfig({
jwtSecret: opts.jwtSecret,
refreshSecret: opts.refreshSecret,
accessTtlMinutes: opts.accessTtlMinutes,
refreshTtlDays: opts.refreshTtlDays,
isProduction: opts.isProduction,
});
app.decorate("config", appConfig);
// Register plugins
await app.register(sensible);
await app.register(helmet);
await app.register(cors, { origin: opts.corsOrigins, credentials: true });
await app.register(rateLimit, { max: 100, timeWindow: "1 minute" });
await app.register(cookie, { secret: opts.cookieSecret });
// JWT plugin
const jwtConfig = getJwtConfig(opts.authEnabled, opts.jwtSecret);
await app.register(jwt, jwtConfig);
await app.register(fastifyMultipart, { limits: { fileSize: 10 * 1024 * 1024 } });
// Only register static if directory exists
if (existsSync(opts.imagesDir)) {
await app.register(fastifyStatic, {
root: opts.imagesDir,
prefix: "/images/",
decorateReply: false,
});
}
// Register routes
await app.register(healthRoutes);
await app.register(authRoutes);
await app.register(oidcRoutes);
await app.register(medicationRoutes);
await app.register(settingsRoutes);
await app.register(plannerRoutes);
await app.register(shareRoutes);
await app.register(doseRoutes);
return app;
}
// =============================================================================
// Server initialization (runs on import)
// =============================================================================
// Wait for database migrations before anything else
await migrationsReady;
console.log("[DB] Migrations complete, starting server...");
// Ensure images directory exists
const imagesDir = resolve(process.cwd(), "data/images");
if (!existsSync(imagesDir)) {
mkdirSync(imagesDir, { recursive: true });
}
const imagesDir = ensureImagesDirectory();
const app = Fastify({
logger: {
@@ -38,24 +134,14 @@ const app = Fastify({
},
});
const origins = env.CORS_ORIGINS.split(",").map((o) => o.trim()).filter(Boolean);
const origins = parseCorsOrigins(env.CORS_ORIGINS);
// Auth token TTLs (hardcoded - no need for user configuration)
const accessTtlMinutes = env.ACCESS_TOKEN_TTL_MINUTES; // Access token TTL
const refreshTtlDays = env.REFRESH_TOKEN_TTL_DAYS; // Refresh token TTL
const baseCookieOptions: CookieSerializeOptions = {
httpOnly: true,
sameSite: "lax",
secure: env.NODE_ENV === "production",
path: "/",
maxAge: accessTtlMinutes * 60,
};
const refreshCookieOptions: CookieSerializeOptions = {
...baseCookieOptions,
maxAge: refreshTtlDays * 24 * 60 * 60,
};
const baseCookieOptions = buildBaseCookieOptions(accessTtlMinutes, env.NODE_ENV === "production");
const refreshCookieOptions = buildRefreshCookieOptions(baseCookieOptions, refreshTtlDays);
// Config decorator - only include secrets if auth is enabled
app.decorate("config", {
@@ -77,18 +163,8 @@ await app.register(rateLimit, {
await app.register(cookie, { secret: env.COOKIE_SECRET ?? "dev-cookie-secret" });
// JWT plugin - only register with valid secret if auth is enabled
if (env.AUTH_ENABLED && env.JWT_SECRET) {
await app.register(jwt, {
secret: env.JWT_SECRET,
cookie: { cookieName: "access_token", signed: false }
});
} else {
// Dummy JWT for when auth is disabled - prevents errors
await app.register(jwt, {
secret: "auth-disabled-no-secret-needed",
cookie: { cookieName: "access_token", signed: false }
});
}
const jwtConfig = getJwtConfig(env.AUTH_ENABLED, env.JWT_SECRET);
await app.register(jwt, jwtConfig);
await app.register(fastifyMultipart, { limits: { fileSize: 10 * 1024 * 1024 } }); // 10MB limit
await app.register(fastifyStatic, {