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:
+108
-32
@@ -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, {
|
||||
|
||||
Reference in New Issue
Block a user