34 added documentation and tests

34 corrected formatting
This commit is contained in:
Hendrik Belitz
2025-06-22 16:30:15 +02:00
parent 9d04ef0798
commit 7dbe1c21cc
21 changed files with 596 additions and 218 deletions
+1 -1
View File
@@ -1,6 +1,6 @@
{
"useTabs": true,
"singleQuote": true,
"singleQuote": false,
"trailingComma": "none",
"printWidth": 100,
"plugins": ["prettier-plugin-svelte", "prettier-plugin-tailwindcss"],
+4 -4
View File
@@ -1,10 +1,10 @@
import { defineConfig } from 'drizzle-kit';
import { defineConfig } from "drizzle-kit";
if (!process.env.DATABASE_URL) throw new Error('DATABASE_URL is not set');
if (!process.env.DATABASE_URL) throw new Error("DATABASE_URL is not set");
export default defineConfig({
schema: './src/lib/server/db/schema.ts',
dialect: 'postgresql',
schema: "./src/lib/server/db/schema.ts",
dialect: "postgresql",
dbCredentials: { url: process.env.DATABASE_URL },
verbose: true,
strict: true
+4 -4
View File
@@ -1,6 +1,6 @@
import { expect, test } from '@playwright/test';
import { expect, test } from "@playwright/test";
test('home page has expected h1', async ({ page }) => {
await page.goto('/');
await expect(page.locator('h1')).toBeVisible();
test("home page has expected h1", async ({ page }) => {
await page.goto("/");
await expect(page.locator("h1")).toBeVisible();
});
+12 -12
View File
@@ -1,13 +1,13 @@
import prettier from 'eslint-config-prettier';
import js from '@eslint/js';
import { includeIgnoreFile } from '@eslint/compat';
import svelte from 'eslint-plugin-svelte';
import globals from 'globals';
import { fileURLToPath } from 'node:url';
import ts from 'typescript-eslint';
import svelteConfig from './svelte.config.js';
import prettier from "eslint-config-prettier";
import js from "@eslint/js";
import { includeIgnoreFile } from "@eslint/compat";
import svelte from "eslint-plugin-svelte";
import globals from "globals";
import { fileURLToPath } from "node:url";
import ts from "typescript-eslint";
import svelteConfig from "./svelte.config.js";
const gitignorePath = fileURLToPath(new URL('./.gitignore', import.meta.url));
const gitignorePath = fileURLToPath(new URL("./.gitignore", import.meta.url));
export default ts.config(
includeIgnoreFile(gitignorePath),
@@ -20,14 +20,14 @@ export default ts.config(
languageOptions: {
globals: { ...globals.browser, ...globals.node }
},
rules: { 'no-undef': 'off' }
rules: { "no-undef": "off" }
},
{
files: ['**/*.svelte', '**/*.svelte.ts', '**/*.svelte.js'],
files: ["**/*.svelte", "**/*.svelte.ts", "**/*.svelte.js"],
languageOptions: {
parserOptions: {
projectService: true,
extraFileExtensions: ['.svelte'],
extraFileExtensions: [".svelte"],
parser: ts.parser,
svelteConfig
}
+3 -3
View File
@@ -1,9 +1,9 @@
import { defineConfig } from '@playwright/test';
import { defineConfig } from "@playwright/test";
export default defineConfig({
webServer: {
command: 'npm run build && npm run preview',
command: "npm run build && npm run preview",
port: 4173
},
testDir: 'e2e'
testDir: "e2e"
});
+1 -1
View File
@@ -1,2 +1,2 @@
@import 'tailwindcss';
@import "tailwindcss";
@plugin '@tailwindcss/typography';
+3 -3
View File
@@ -1,7 +1,7 @@
import { describe, it, expect } from 'vitest';
import { describe, it, expect } from "vitest";
describe('sum test', () => {
it('adds 1 + 2 to equal 3', () => {
describe("sum test", () => {
it("adds 1 + 2 to equal 3", () => {
expect(1 + 2).toBe(3);
});
});
+190
View File
@@ -0,0 +1,190 @@
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { handle } from "./hooks.server.js";
// Mock the Date.now function for rate limiting tests
const mockDateNow = vi.fn();
vi.stubGlobal("Date", { ...Date, now: mockDateNow });
describe("hooks.server", () => {
beforeEach(() => {
vi.clearAllMocks();
mockDateNow.mockReturnValue(1000000); // Fixed timestamp for consistent testing
});
afterEach(() => {
// Reset any global state
vi.clearAllTimers();
});
describe("rate limiting", () => {
const mockResolve = vi.fn();
const createEvent = (ip: string = "192.168.1.1", method: string = "GET") => ({
url: new URL("http://localhost/api/test"),
request: new Request("http://localhost/api/test", {
method,
headers: {
"x-forwarded-for": ip
}
}),
cookies: {} as any,
fetch: {} as any,
getClientAddress: () => ip,
locals: {},
params: {},
route: { id: null },
setHeaders: vi.fn(),
isDataRequest: false,
isSubRequest: false,
platform: {} as any
});
beforeEach(() => {
mockResolve.mockResolvedValue(new Response("OK"));
});
it("should allow requests within rate limit", async () => {
const event = createEvent();
const response = await handle({ event, resolve: mockResolve });
expect(response.status).not.toBe(429);
expect(mockResolve).toHaveBeenCalled();
});
it("should block requests when rate limit exceeded", async () => {
const event = createEvent();
// Make multiple requests to exceed rate limit
for (let i = 0; i < 10; i++) {
await handle({ event, resolve: mockResolve });
}
// This request should be rate limited
const response = await handle({ event, resolve: mockResolve });
expect(response.status).toBe(429);
expect(await response.text()).toBe("Too Many Requests");
});
it("should reset rate limit after window expires", async () => {
const event = createEvent();
// Exceed rate limit
for (let i = 0; i < 11; i++) {
await handle({ event, resolve: mockResolve });
}
// Mock time passing (would need to mock Date.now in real implementation)
// For now, we'll test that different IPs are treated separately
const differentIPEvent = createEvent("192.168.1.2");
const response = await handle({ event: differentIPEvent, resolve: mockResolve });
expect(response.status).not.toBe(429);
});
});
describe("CORS handling", () => {
const mockResolve = vi.fn();
const createCORSEvent = (method: string = "GET", path: string = "/api/test") => ({
url: new URL(`http://localhost${path}`),
request: new Request(`http://localhost${path}`, {
method,
headers: {
"x-forwarded-for": "192.168.2.1" // Different IP for CORS tests
}
}),
cookies: {} as any,
fetch: {} as any,
getClientAddress: () => "192.168.2.1",
locals: {},
params: {},
route: { id: null },
setHeaders: vi.fn(),
isDataRequest: false,
isSubRequest: false,
platform: {} as any
});
beforeEach(() => {
mockResolve.mockResolvedValue(new Response("OK"));
});
it("should handle OPTIONS preflight requests", async () => {
const event = createCORSEvent("OPTIONS");
const response = await handle({ event, resolve: mockResolve });
expect(response.status).toBe(200);
expect(response.headers.get("Access-Control-Allow-Origin")).toBe("*");
expect(response.headers.get("Access-Control-Allow-Methods")).toContain("GET");
expect(mockResolve).not.toHaveBeenCalled();
});
it("should add CORS headers to API routes", async () => {
const event = createCORSEvent();
const response = await handle({ event, resolve: mockResolve });
expect(response.headers.get("Access-Control-Allow-Origin")).toBe("*");
expect(response.headers.get("Access-Control-Allow-Methods")).toContain("GET");
});
});
describe("security headers", () => {
const mockResolve = vi.fn();
const createSecurityEvent = (protocol: string = "http", path: string = "/test") => ({
url: new URL(`${protocol}://localhost${path}`),
request: new Request(`${protocol}://localhost${path}`, {
headers: {
"x-forwarded-for": "192.168.3.1" // Different IP for security tests
}
}),
cookies: {} as any,
fetch: {} as any,
getClientAddress: () => "192.168.3.1",
locals: {},
params: {},
route: { id: null },
setHeaders: vi.fn(),
isDataRequest: false,
isSubRequest: false,
platform: {} as any
});
beforeEach(() => {
mockResolve.mockResolvedValue(new Response("OK"));
});
it("should add security headers to all responses", async () => {
const event = createSecurityEvent();
const response = await handle({ event, resolve: mockResolve });
expect(response.headers.get("X-Frame-Options")).toBe("DENY");
expect(response.headers.get("X-Content-Type-Options")).toBe("nosniff");
expect(response.headers.get("X-XSS-Protection")).toBe("1; mode=block");
expect(response.headers.get("Referrer-Policy")).toBe("strict-origin-when-cross-origin");
expect(response.headers.get("Content-Security-Policy")).toContain("default-src 'self'");
});
it("should add HSTS header for HTTPS requests", async () => {
const event = createSecurityEvent("https");
const response = await handle({ event, resolve: mockResolve });
expect(response.headers.get("Strict-Transport-Security")).toBe(
"max-age=31536000; includeSubDomains; preload"
);
});
it("should not add HSTS header for HTTP requests", async () => {
const event = createSecurityEvent("http");
const response = await handle({ event, resolve: mockResolve });
expect(response.headers.get("Strict-Transport-Security")).toBeNull();
});
});
});
+83 -44
View File
@@ -1,45 +1,68 @@
import { type Handle } from '@sveltejs/kit';
import { type Handle } from "@sveltejs/kit";
/** In-memory store for rate limiting records per client IP */
const rateLimitStore = new Map<string, { count: number; resetTime: number }>();
/** Rate limiting window duration in milliseconds */
const RATE_LIMIT_WINDOW = 1000; // ms
/** Maximum requests allowed per rate limiting window */
const RATE_LIMIT_MAX_REQUESTS = 10;
/**
* Extracts the client IP address from request headers
*
* Checks headers in order of preference:
* 1. x-forwarded-for (takes first IP from comma-separated list)
* 2. x-real-ip
* 3. Returns 'unknown' if no IP found
*
* @param {Request} request - The incoming HTTP request
* @returns {string} The client IP address or 'unknown'
*/
function getClientIP(request: Request): string {
const forwarded = request.headers.get('x-forwarded-for');
const forwarded = request.headers.get("x-forwarded-for");
if (forwarded) {
return forwarded.split(',')[0].trim();
return forwarded.split(",")[0].trim();
}
const realIP = request.headers.get('x-real-ip');
const realIP = request.headers.get("x-real-ip");
if (realIP) {
return realIP;
}
return 'unknown';
return "unknown";
}
/**
* Checks if a client IP is currently rate limited
*
* Uses sliding window rate limiting with configurable window size and max requests.
* Creates or resets rate limit records when window expires.
*
* @param {string} clientIP - The client IP address to check
* @returns {boolean} True if client is rate limited, false otherwise
*/
function isRateLimited(clientIP: string): boolean {
const now = Date.now();
const key = clientIP;
const record = rateLimitStore.get(key);
if (!record || now > record.resetTime) {
// Reset or create new record
rateLimitStore.set(key, { count: 1, resetTime: now + RATE_LIMIT_WINDOW });
return false;
}
if (record.count >= RATE_LIMIT_MAX_REQUESTS) {
return true;
}
record.count++;
return false;
}
/**
/**
* Clean up limit store every minute
*/
setInterval(() => {
@@ -52,51 +75,64 @@ setInterval(() => {
}, 60000);
/**
* Hook to add CORS and security headers and to perform rate limiting
* @param event Event containing a request
* SvelteKit server hook that handles CORS, security headers, and rate limiting
*
* Functionality includes:
* - Rate limiting per client IP with configurable limits
* - CORS headers for OPTIONS preflight requests
* - Security headers (CSP, X-Frame-Options, HSTS, etc.)
* - Special handling for API routes with relaxed CORS
*
* @param {Object} params - Hook parameters
* @param {import('@sveltejs/kit').RequestEvent} params.event - The request event
* @param {Function} params.resolve - Function to resolve the request
* @returns {Promise<Response>} The response with applied headers and rate limiting
*/
export const handle: Handle = async ({ event, resolve }) => {
const { request } = event;
const clientIP = getClientIP(request);
if (isRateLimited(clientIP)) {
return new Response('Too Many Requests', {
return new Response("Too Many Requests", {
status: 429,
headers: {
'Retry-After': '1',
'Content-Type': 'text/plain'
"Retry-After": "1",
"Content-Type": "text/plain"
}
});
}
if (request.method === 'OPTIONS') {
if (request.method === "OPTIONS") {
return new Response(null, {
headers: {
'Access-Control-Allow-Origin': '*', // TODO: Should be limited to own server and registered webhooks etc.
'Access-Control-Allow-Methods': 'GET, POST, PUT, DELETE, PATCH, OPTIONS',
'Access-Control-Allow-Headers': 'Content-Type, Authorization, X-Requested-With',
'Access-Control-Max-Age': '86400'
"Access-Control-Allow-Origin": "*", // TODO: Should be limited to own server and registered webhooks etc.
"Access-Control-Allow-Methods": "GET, POST, PUT, DELETE, PATCH, OPTIONS",
"Access-Control-Allow-Headers": "Content-Type, Authorization, X-Requested-With",
"Access-Control-Max-Age": "86400"
}
});
}
const response = await resolve(event);
// TODO: These has to be rechecked and coordinated with caddy's configuration
response.headers.set('X-Frame-Options', 'DENY');
response.headers.set('X-Content-Type-Options', 'nosniff');
response.headers.set('X-XSS-Protection', '1; mode=block');
response.headers.set('Referrer-Policy', 'strict-origin-when-cross-origin');
response.headers.set('Permissions-Policy', 'camera=(), microphone=(), geolocation=()');
if (event.url.protocol === 'https:') {
response.headers.set('Strict-Transport-Security', 'max-age=31536000; includeSubDomains; preload');
response.headers.set("X-Frame-Options", "DENY");
response.headers.set("X-Content-Type-Options", "nosniff");
response.headers.set("X-XSS-Protection", "1; mode=block");
response.headers.set("Referrer-Policy", "strict-origin-when-cross-origin");
response.headers.set("Permissions-Policy", "camera=(), microphone=(), geolocation=()");
if (event.url.protocol === "https:") {
response.headers.set(
"Strict-Transport-Security",
"max-age=31536000; includeSubDomains; preload"
);
}
const cspDirectives = [
"default-src 'self'",
"script-src 'self' 'unsafe-inline' https://unpkg.com", // Allow inline scripts and unpkg CDN (for Swagger)
"style-src 'self' 'unsafe-inline' https://unpkg.com",
"style-src 'self' 'unsafe-inline' https://unpkg.com",
"img-src 'self' data: https:",
"font-src 'self' data: https://unpkg.com",
"connect-src 'self'",
@@ -107,13 +143,16 @@ export const handle: Handle = async ({ event, resolve }) => {
"frame-ancestors 'none'",
"upgrade-insecure-requests"
];
response.headers.set('Content-Security-Policy', cspDirectives.join('; '));
if (event.url.pathname.startsWith('/api/')) {
response.headers.set('Access-Control-Allow-Origin', '*'); // TODO: Should be limited to own server and registered webhooks etc.
response.headers.set('Access-Control-Allow-Methods', 'GET, POST, PUT, DELETE, PATCH, OPTIONS');
response.headers.set('Access-Control-Allow-Headers', 'Content-Type, Authorization, X-Requested-With');
response.headers.set("Content-Security-Policy", cspDirectives.join("; "));
if (event.url.pathname.startsWith("/api/")) {
response.headers.set("Access-Control-Allow-Origin", "*"); // TODO: Should be limited to own server and registered webhooks etc.
response.headers.set("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, PATCH, OPTIONS");
response.headers.set(
"Access-Control-Allow-Headers",
"Content-Type, Authorization, X-Requested-With"
);
}
return response;
};
};
+5 -5
View File
@@ -1,9 +1,9 @@
import { drizzle } from 'drizzle-orm/postgres-js';
import postgres from 'postgres';
import * as schema from './schema';
import { env } from '$env/dynamic/private';
import { drizzle } from "drizzle-orm/postgres-js";
import postgres from "postgres";
import * as schema from "./schema";
import { env } from "$env/dynamic/private";
if (!env.DATABASE_URL) throw new Error('DATABASE_URL is not set');
if (!env.DATABASE_URL) throw new Error("DATABASE_URL is not set");
const client = postgres(env.DATABASE_URL);
+4 -4
View File
@@ -1,6 +1,6 @@
import { pgTable, serial, integer } from 'drizzle-orm/pg-core';
import { pgTable, serial, integer } from "drizzle-orm/pg-core";
export const user = pgTable('user', {
id: serial('id').primaryKey(),
age: integer('age')
export const user = pgTable("user", {
id: serial("id").primaryKey(),
age: integer("age")
});
+43 -37
View File
@@ -1,5 +1,5 @@
export type JsonSchema = {
type?: 'string' | 'number' | 'integer' | 'boolean' | 'array' | 'object';
type?: "string" | "number" | "integer" | "boolean" | "array" | "object";
properties?: Record<string, JsonSchema>;
items?: JsonSchema;
required?: string[];
@@ -12,15 +12,18 @@ export type JsonSchema = {
export interface OpenApiResponse {
description: string;
content?: Record<string, {
schema: JsonSchema;
example?: unknown;
}>;
content?: Record<
string,
{
schema: JsonSchema;
example?: unknown;
}
>;
}
export interface OpenApiParameter {
name: string;
in: 'query' | 'path' | 'header';
in: "query" | "path" | "header";
description?: string;
required?: boolean;
schema: JsonSchema;
@@ -28,10 +31,13 @@ export interface OpenApiParameter {
export interface OpenApiRequestBody {
description?: string;
content: Record<string, {
schema: JsonSchema;
example?: unknown;
}>;
content: Record<
string,
{
schema: JsonSchema;
example?: unknown;
}
>;
}
export interface OpenApiOperation {
@@ -56,62 +62,62 @@ export function getApiOperations(): Map<string, OpenApiOperation> {
export function generateOpenApiSpec() {
const paths: Record<string, Record<string, OpenApiOperation>> = {};
// Convert registered operations to OpenAPI paths
for (const [key, operation] of apiOperationsRegistry.entries()) {
const [method, path] = key.split(' ', 2);
const [method, path] = key.split(" ", 2);
if (!paths[path]) {
paths[path] = {};
}
paths[path][method.toLowerCase()] = operation;
}
return {
openapi: '3.0.0',
openapi: "3.0.0",
info: {
title: 'Open Reception API',
description: 'End-to-end encrypted appointment booking platform',
version: '0.0.1',
title: "Open Reception API",
description: "End-to-end encrypted appointment booking platform",
version: "0.0.1",
license: {
name: 'AGPL-3.0',
url: 'https://www.gnu.org/licenses/agpl-3.0.html'
name: "AGPL-3.0",
url: "https://www.gnu.org/licenses/agpl-3.0.html"
}
},
servers: [
{
url: '/api',
description: 'API Server'
url: "/api",
description: "API Server"
}
],
paths,
components: {
schemas: {
Error: {
type: 'object',
type: "object",
properties: {
error: { type: 'string', description: 'Error message' },
code: { type: 'string', description: 'Error code' }
error: { type: "string", description: "Error message" },
code: { type: "string", description: "Error code" }
},
required: ['error']
required: ["error"]
},
HealthStatus: {
type: 'object',
type: "object",
properties: {
core: { type: 'boolean', description: 'Core service status' },
database: { type: 'boolean', description: 'Database connectivity status' },
memory: { type: 'integer', description: 'Free memory in megabytes' },
load: { type: 'number', format: 'float', description: 'Average CPU load' }
core: { type: "boolean", description: "Core service status" },
database: { type: "boolean", description: "Database connectivity status" },
memory: { type: "integer", description: "Free memory in megabytes" },
load: { type: "number", format: "float", description: "Average CPU load" }
},
required: ['core', 'database', 'memory', 'load']
required: ["core", "database", "memory", "load"]
}
}
},
tags: [
{ name: 'Health', description: 'Health check and monitoring endpoints' },
{ name: 'Appointments', description: 'Appointment management endpoints' },
{ name: 'Clients', description: 'Client management endpoints' },
{ name: 'Channels', description: 'Channel management endpoints' },
{ name: 'Questionnaires', description: 'Questionnaire management endpoints' }
{ name: "Health", description: "Health check and monitoring endpoints" },
{ name: "Appointments", description: "Appointment management endpoints" },
{ name: "Clients", description: "Client management endpoints" },
{ name: "Channels", description: "Channel management endpoints" },
{ name: "Questionnaires", description: "Questionnaire management endpoints" }
]
};
}
}
+1 -1
View File
@@ -1,5 +1,5 @@
<script lang="ts">
import '../app.css';
import "../app.css";
let { children } = $props();
</script>
+7 -8
View File
@@ -1,6 +1,5 @@
export async function GET() {
const html = `<!DOCTYPE html>
const html = `<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
@@ -53,9 +52,9 @@ export async function GET() {
</body>
</html>`;
return new Response(html, {
headers: {
'Content-Type': 'text/html'
}
});
}
return new Response(html, {
headers: {
"Content-Type": "text/html"
}
});
}
+76 -58
View File
@@ -1,69 +1,87 @@
import { json } from '@sveltejs/kit';
import { db } from '$lib/server/db';
import { sql } from 'drizzle-orm';
import os from 'os';
import { registerOpenAPIRoute } from '$lib/server/openapi';
import { json } from "@sveltejs/kit";
import { db } from "$lib/server/db";
import { sql } from "drizzle-orm";
import os from "os";
import { registerOpenAPIRoute } from "$lib/server/openapi";
registerOpenAPIRoute('/health/services', 'GET', {
summary: 'Get service health status',
description: 'Returns the health status of core services including database connectivity, memory usage, and CPU load',
tags: ['Health'],
responses: {
'200': {
description: 'Service health status',
content: {
'application/json': {
schema: { $ref: '#/components/schemas/HealthStatus' },
example: {
core: true,
database: true,
memory: 2048,
load: 0.75
}
}
}
},
'429': {
description: 'Too Many Requests - Rate limit exceeded',
content: {
'text/plain': {
schema: { type: 'string' },
example: 'Too Many Requests'
}
}
},
'500': {
description: 'Internal Server Error',
content: {
'application/json': {
schema: { $ref: '#/components/schemas/Error' }
}
}
}
}
registerOpenAPIRoute("/health/services", "GET", {
summary: "Get service health status",
description:
"Returns the health status of core services including database connectivity, memory usage, and CPU load",
tags: ["Health"],
responses: {
"200": {
description: "Service health status",
content: {
"application/json": {
schema: { $ref: "#/components/schemas/HealthStatus" },
example: {
core: true,
database: true,
memory: 2048,
load: 0.75
}
}
}
},
"429": {
description: "Too Many Requests - Rate limit exceeded",
content: {
"text/plain": {
schema: { type: "string" },
example: "Too Many Requests"
}
}
},
"500": {
description: "Internal Server Error",
content: {
"application/json": {
schema: { $ref: "#/components/schemas/Error" }
}
}
}
}
});
/**
* Represents the health status response for core services
*/
class ServiceHealthResponse {
core: boolean = true;
database: boolean = false;
memory: number = 0;
load: number = 0;
/** Core service availability status */
core: boolean = true;
/** Database connectivity status */
database: boolean = false;
/** Available memory in MB */
memory: number = 0;
/** Current CPU load average */
load: number = 0;
}
/**
* GET handler for the health services endpoint
*
* Performs health checks on core services including:
* - Database connectivity test
* - System memory availability
* - CPU load average
*
* @returns {Response} JSON response containing service health status
*/
export async function GET() {
const serviceHealth = new ServiceHealthResponse()
const serviceHealth = new ServiceHealthResponse();
try {
await db.execute(sql`SELECT 1`);
serviceHealth.database = true;
} catch {
serviceHealth.database = false;
}
try {
await db.execute(sql`SELECT 1`);
serviceHealth.database = true;
} catch {
serviceHealth.database = false;
}
serviceHealth.memory = Math.round(os.freemem() / 1024 / 1024);
serviceHealth.memory = Math.round(os.freemem() / 1024 / 1024);
const loadAvg = os.loadavg();
serviceHealth.load = Math.round(loadAvg[0] * 100) / 100;
const loadAvg = os.loadavg();
serviceHealth.load = Math.round(loadAvg[0] * 100) / 100;
return json(serviceHealth);
}
return json(serviceHealth);
}
@@ -0,0 +1,126 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { GET } from "./+server.js";
// Mock the database module
vi.mock("$lib/server/db", () => ({
db: {
execute: vi.fn()
}
}));
// Mock the OpenAPI registration
vi.mock("$lib/server/openapi", () => ({
registerOpenAPIRoute: vi.fn()
}));
// Mock os module
vi.mock("os", () => ({
default: {
freemem: vi.fn(),
loadavg: vi.fn()
}
}));
// Mock SvelteKit json helper
vi.mock("@sveltejs/kit", () => ({
json: vi.fn((data) => ({
json: () => Promise.resolve(data),
data
}))
}));
describe("/api/health/services", () => {
beforeEach(() => {
vi.clearAllMocks();
});
it("should return healthy status when database connection succeeds", async () => {
const { db } = await import("$lib/server/db");
const os = (await import("os")).default;
// Mock successful database connection
vi.mocked(db.execute).mockResolvedValue([] as any);
// Mock system metrics
vi.mocked(os.freemem).mockReturnValue(2048 * 1024 * 1024); // 2048 MB in bytes
vi.mocked(os.loadavg).mockReturnValue([0.75, 0.5, 0.3]);
const response = await GET();
const data = await response.json();
expect(data).toEqual({
core: true,
database: true,
memory: 2048,
load: 0.75
});
});
it("should return unhealthy database status when connection fails", async () => {
const { db } = await import("$lib/server/db");
const os = (await import("os")).default;
// Mock failed database connection
vi.mocked(db.execute).mockRejectedValue(new Error("Connection failed"));
// Mock system metrics
vi.mocked(os.freemem).mockReturnValue(1024 * 1024 * 1024); // 1024 MB in bytes
vi.mocked(os.loadavg).mockReturnValue([1.25, 1.0, 0.8]);
const response = await GET();
const data = await response.json();
expect(data).toEqual({
core: true,
database: false,
memory: 1024,
load: 1.25
});
});
it("should calculate memory in MB correctly", async () => {
const { db } = await import("$lib/server/db");
const os = (await import("os")).default;
vi.mocked(db.execute).mockResolvedValue([] as any);
// Test different memory values
vi.mocked(os.freemem).mockReturnValue(512 * 1024 * 1024); // 512 MB in bytes
vi.mocked(os.loadavg).mockReturnValue([0.1, 0.1, 0.1]);
const response = await GET();
const data = await response.json();
expect(data.memory).toBe(512);
});
it("should round load average to 2 decimal places", async () => {
const { db } = await import("$lib/server/db");
const os = (await import("os")).default;
vi.mocked(db.execute).mockResolvedValue([] as any);
vi.mocked(os.freemem).mockReturnValue(1024 * 1024 * 1024);
// Test load average rounding
vi.mocked(os.loadavg).mockReturnValue([1.23456, 0.5, 0.3]);
const response = await GET();
const data = await response.json();
expect(data.load).toBe(1.23);
});
it("should always return core as true", async () => {
const { db } = await import("$lib/server/db");
const os = (await import("os")).default;
vi.mocked(db.execute).mockRejectedValue(new Error("Database error"));
vi.mocked(os.freemem).mockReturnValue(0);
vi.mocked(os.loadavg).mockReturnValue([10.0, 5.0, 2.0]);
const response = await GET();
const data = await response.json();
expect(data.core).toBe(true);
});
});
+6 -6
View File
@@ -1,10 +1,10 @@
import { json } from '@sveltejs/kit';
import { generateOpenApiSpec } from '$lib/server/openapi.js';
import { json } from "@sveltejs/kit";
import { generateOpenApiSpec } from "$lib/server/openapi.js";
// ATTENTION: All routes need to be imported here to make sure they're added to the OpenAPI spec
import '../health/services/+server.js';
import "../health/services/+server.js";
export async function GET() {
const spec = generateOpenApiSpec();
return json(spec);
}
const spec = generateOpenApiSpec();
return json(spec);
}
+7 -7
View File
@@ -1,11 +1,11 @@
import { describe, test, expect } from 'vitest';
import '@testing-library/jest-dom/vitest';
import { render, screen } from '@testing-library/svelte';
import Page from './+page.svelte';
import { describe, test, expect } from "vitest";
import "@testing-library/jest-dom/vitest";
import { render, screen } from "@testing-library/svelte";
import Page from "./+page.svelte";
describe('/+page.svelte', () => {
test('should render h1', () => {
describe("/+page.svelte", () => {
test("should render h1", () => {
render(Page);
expect(screen.getByRole('heading', { level: 1 })).toBeInTheDocument();
expect(screen.getByRole("heading", { level: 1 })).toBeInTheDocument();
});
});
+2 -2
View File
@@ -1,5 +1,5 @@
import adapter from '@sveltejs/adapter-auto';
import { vitePreprocess } from '@sveltejs/vite-plugin-svelte';
import adapter from "@sveltejs/adapter-auto";
import { vitePreprocess } from "@sveltejs/vite-plugin-svelte";
/** @type {import('@sveltejs/kit').Config} */
const config = {
+15 -15
View File
@@ -1,31 +1,31 @@
import tailwindcss from '@tailwindcss/vite';
import { svelteTesting } from '@testing-library/svelte/vite';
import { sveltekit } from '@sveltejs/kit/vite';
import { defineConfig } from 'vite';
import tailwindcss from "@tailwindcss/vite";
import { svelteTesting } from "@testing-library/svelte/vite";
import { sveltekit } from "@sveltejs/kit/vite";
import { defineConfig } from "vite";
export default defineConfig({
plugins: [tailwindcss(), sveltekit()],
test: {
projects: [
{
extends: './vite.config.ts',
extends: "./vite.config.ts",
plugins: [svelteTesting()],
test: {
name: 'client',
environment: 'jsdom',
name: "client",
environment: "jsdom",
clearMocks: true,
include: ['src/**/*.svelte.{test,spec}.{js,ts}'],
exclude: ['src/lib/server/**'],
setupFiles: ['./vitest-setup-client.ts']
include: ["src/**/*.svelte.{test,spec}.{js,ts}"],
exclude: ["src/lib/server/**"],
setupFiles: ["./vitest-setup-client.ts"]
}
},
{
extends: './vite.config.ts',
extends: "./vite.config.ts",
test: {
name: 'server',
environment: 'node',
include: ['src/**/*.{test,spec}.{js,ts}'],
exclude: ['src/**/*.svelte.{test,spec}.{js,ts}']
name: "server",
environment: "node",
include: ["src/**/*.{test,spec}.{js,ts}"],
exclude: ["src/**/*.svelte.{test,spec}.{js,ts}"]
}
}
]
+3 -3
View File
@@ -1,8 +1,8 @@
import '@testing-library/jest-dom/vitest';
import { vi } from 'vitest';
import "@testing-library/jest-dom/vitest";
import { vi } from "vitest";
// required for svelte5 + jsdom as jsdom does not support matchMedia
Object.defineProperty(window, 'matchMedia', {
Object.defineProperty(window, "matchMedia", {
writable: true,
enumerable: true,
value: vi.fn().mockImplementation((query) => ({