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:
+116
-64
@@ -6,61 +6,46 @@ import dotenv from "dotenv";
|
||||
|
||||
dotenv.config({ path: process.env.DOTENV_PATH || ".env" });
|
||||
|
||||
// Use absolute path to ensure it works in Docker
|
||||
const dataDir = resolve(process.cwd(), "data");
|
||||
const dbPath = resolve(dataDir, "medassist-ng.db");
|
||||
const url = `file:${dbPath}`;
|
||||
// =============================================================================
|
||||
// Exported utility functions for testing
|
||||
// =============================================================================
|
||||
|
||||
console.log(`[DB] Data directory: ${dataDir}`);
|
||||
console.log(`[DB] Database path: ${dbPath}`);
|
||||
console.log(`[DB] Database URL: ${url}`);
|
||||
/** Build the database URL from a path */
|
||||
export function buildDbUrl(dbPath: string): string {
|
||||
return `file:${dbPath}`;
|
||||
}
|
||||
|
||||
// Ensure data directory exists and is writable
|
||||
try {
|
||||
if (!existsSync(dataDir)) {
|
||||
mkdirSync(dataDir, { recursive: true });
|
||||
console.log(`[DB] Created data directory: ${dataDir}`);
|
||||
} else {
|
||||
console.log(`[DB] Data directory exists: ${dataDir}`);
|
||||
/** Get data directory and database path */
|
||||
export function getDbPaths(cwd: string = process.cwd()): { dataDir: string; dbPath: string; url: string } {
|
||||
const dataDir = resolve(cwd, "data");
|
||||
const dbPath = resolve(dataDir, "medassist-ng.db");
|
||||
const url = buildDbUrl(dbPath);
|
||||
return { dataDir, dbPath, url };
|
||||
}
|
||||
|
||||
/** Ensure data directory exists and is writable */
|
||||
export function ensureDataDirectory(dataDir: string): { success: boolean; error?: string } {
|
||||
try {
|
||||
if (!existsSync(dataDir)) {
|
||||
mkdirSync(dataDir, { recursive: true });
|
||||
}
|
||||
|
||||
// Check if directory is writable
|
||||
accessSync(dataDir, constants.W_OK);
|
||||
|
||||
// Try to create a test file to verify write access
|
||||
const testFile = resolve(dataDir, ".write-test");
|
||||
writeFileSync(testFile, "test");
|
||||
|
||||
return { success: true };
|
||||
} catch (err: any) {
|
||||
return { success: false, error: err.message };
|
||||
}
|
||||
|
||||
// Check if directory is writable
|
||||
accessSync(dataDir, constants.W_OK);
|
||||
console.log(`[DB] Data directory is writable`);
|
||||
|
||||
// Log directory stats
|
||||
const stats = statSync(dataDir);
|
||||
console.log(`[DB] Directory permissions: ${stats.mode.toString(8)}`);
|
||||
console.log(`[DB] Directory UID: ${stats.uid}, GID: ${stats.gid}`);
|
||||
|
||||
// Try to create a test file to verify write access
|
||||
const testFile = resolve(dataDir, ".write-test");
|
||||
writeFileSync(testFile, "test");
|
||||
console.log(`[DB] Write test successful`);
|
||||
|
||||
} catch (err: any) {
|
||||
console.error(`[DB] ERROR: Cannot access data directory: ${err.message}`);
|
||||
console.error(`[DB] Please ensure the volume mount has correct permissions.`);
|
||||
console.error(`[DB] Try running on host: sudo chown -R 1000:1000 ${dataDir}`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
let client: Client;
|
||||
try {
|
||||
client = createClient({ url });
|
||||
console.log(`[DB] Database client created successfully`);
|
||||
} catch (err: any) {
|
||||
console.error(`[DB] ERROR: Failed to create database client: ${err.message}`);
|
||||
console.error(`[DB] Database path: ${dbPath}`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
export const db = drizzle(client);
|
||||
|
||||
// Auto-run migrations (self-healing database)
|
||||
async function runMigrations() {
|
||||
// First, ensure all tables exist (for fresh databases)
|
||||
const tableCreations = [
|
||||
/** Get the SQL statements for creating all tables */
|
||||
export function getTableCreationSQL(): string[] {
|
||||
return [
|
||||
`CREATE TABLE IF NOT EXISTS users (
|
||||
id integer PRIMARY KEY AUTOINCREMENT,
|
||||
username text NOT NULL UNIQUE,
|
||||
@@ -148,31 +133,98 @@ async function runMigrations() {
|
||||
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
|
||||
)`,
|
||||
];
|
||||
}
|
||||
|
||||
/** Run table creation migrations on a client */
|
||||
export async function runTableMigrations(client: Client): Promise<{ success: boolean; errors: string[] }> {
|
||||
const tableCreations = getTableCreationSQL();
|
||||
const errors: string[] = [];
|
||||
|
||||
for (const sql of tableCreations) {
|
||||
try {
|
||||
await client.execute(sql);
|
||||
} catch (e: any) {
|
||||
console.error(`[DB] Table creation error:`, e.message);
|
||||
errors.push(e.message);
|
||||
}
|
||||
}
|
||||
|
||||
return { success: errors.length === 0, errors };
|
||||
}
|
||||
|
||||
/** Ensure default user exists for auth-disabled mode */
|
||||
export async function ensureDefaultUser(client: Client, authEnabled: boolean): Promise<boolean> {
|
||||
if (authEnabled) {
|
||||
return false; // No default user needed
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await client.execute("SELECT id FROM users WHERE id = 1");
|
||||
if (result.rows.length === 0) {
|
||||
await client.execute(
|
||||
"INSERT INTO users (id, username, auth_provider) VALUES (1, 'default', 'local')"
|
||||
);
|
||||
return true; // Created
|
||||
}
|
||||
return false; // Already exists
|
||||
} catch (e: any) {
|
||||
console.error(`[DB] Error creating default user:`, e.message);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Database initialization (runs on import)
|
||||
// =============================================================================
|
||||
|
||||
// Use absolute path to ensure it works in Docker
|
||||
const { dataDir, dbPath, url } = getDbPaths();
|
||||
|
||||
console.log(`[DB] Data directory: ${dataDir}`);
|
||||
console.log(`[DB] Database path: ${dbPath}`);
|
||||
console.log(`[DB] Database URL: ${url}`);
|
||||
|
||||
// Ensure data directory exists and is writable
|
||||
const dirResult = ensureDataDirectory(dataDir);
|
||||
if (!dirResult.success) {
|
||||
console.error(`[DB] ERROR: Cannot access data directory: ${dirResult.error}`);
|
||||
console.error(`[DB] Please ensure the volume mount has correct permissions.`);
|
||||
console.error(`[DB] Try running on host: sudo chown -R 1000:1000 ${dataDir}`);
|
||||
process.exit(1);
|
||||
} else {
|
||||
console.log(`[DB] Data directory is writable`);
|
||||
|
||||
// Log directory stats
|
||||
const stats = statSync(dataDir);
|
||||
console.log(`[DB] Directory permissions: ${stats.mode.toString(8)}`);
|
||||
console.log(`[DB] Directory UID: ${stats.uid}, GID: ${stats.gid}`);
|
||||
console.log(`[DB] Write test successful`);
|
||||
}
|
||||
|
||||
let client: Client;
|
||||
try {
|
||||
client = createClient({ url });
|
||||
console.log(`[DB] Database client created successfully`);
|
||||
} catch (err: any) {
|
||||
console.error(`[DB] ERROR: Failed to create database client: ${err.message}`);
|
||||
console.error(`[DB] Database path: ${dbPath}`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
export const db = drizzle(client);
|
||||
|
||||
// Auto-run migrations (self-healing database)
|
||||
async function runMigrations() {
|
||||
const result = await runTableMigrations(client);
|
||||
if (result.errors.length > 0) {
|
||||
result.errors.forEach(err => console.error(`[DB] Table creation error:`, err));
|
||||
}
|
||||
console.log(`[DB] Tables verified/created`);
|
||||
|
||||
// If auth is disabled, ensure a default user exists (ID=1)
|
||||
const authEnabled = process.env.AUTH_ENABLED === "true";
|
||||
if (!authEnabled) {
|
||||
try {
|
||||
// Check if default user exists
|
||||
const result = await client.execute("SELECT id FROM users WHERE id = 1");
|
||||
if (result.rows.length === 0) {
|
||||
await client.execute(
|
||||
"INSERT INTO users (id, username, auth_provider) VALUES (1, 'default', 'local')"
|
||||
);
|
||||
console.log(`[DB] Created default user for auth-disabled mode`);
|
||||
}
|
||||
} catch (e: any) {
|
||||
console.error(`[DB] Error creating default user:`, e.message);
|
||||
}
|
||||
const created = await ensureDefaultUser(client, authEnabled);
|
||||
if (created) {
|
||||
console.log(`[DB] Created default user for auth-disabled mode`);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+64
-17
@@ -1,20 +1,17 @@
|
||||
import { createClient } from "@libsql/client";
|
||||
import { createClient, Client } from "@libsql/client";
|
||||
import dotenv from "dotenv";
|
||||
import fs from "fs";
|
||||
import path from "path";
|
||||
|
||||
dotenv.config({ path: process.env.DOTENV_PATH || ".env" });
|
||||
|
||||
const url = "file:./data/medassist-ng.db";
|
||||
// =============================================================================
|
||||
// Exported utility functions for testing
|
||||
// =============================================================================
|
||||
|
||||
async function main() {
|
||||
console.log("Starting database setup...");
|
||||
console.log("Database URL:", url);
|
||||
|
||||
const client = createClient({ url });
|
||||
|
||||
// Create tables - fresh schema without roles, with per-user settings
|
||||
const sql = `
|
||||
/** Get the full migration SQL string */
|
||||
export function getMigrationSQL(): string {
|
||||
return `
|
||||
CREATE TABLE IF NOT EXISTS users (
|
||||
id integer PRIMARY KEY AUTOINCREMENT,
|
||||
username text NOT NULL UNIQUE,
|
||||
@@ -106,12 +103,58 @@ async function main() {
|
||||
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
|
||||
);
|
||||
`;
|
||||
}
|
||||
|
||||
// Execute each statement separately
|
||||
const statements = sql.split(';').filter(s => s.trim().length > 0);
|
||||
/** Split SQL string into individual statements */
|
||||
export function splitSQLStatements(sql: string): string[] {
|
||||
return sql.split(';').filter(s => s.trim().length > 0);
|
||||
}
|
||||
|
||||
/** Execute migration statements on a client */
|
||||
export async function executeMigration(client: Client): Promise<{ success: boolean; executed: number; errors: string[] }> {
|
||||
const sql = getMigrationSQL();
|
||||
const statements = splitSQLStatements(sql);
|
||||
const errors: string[] = [];
|
||||
let executed = 0;
|
||||
|
||||
for (const stmt of statements) {
|
||||
try {
|
||||
await client.execute(stmt);
|
||||
executed++;
|
||||
} catch (err: any) {
|
||||
errors.push(err.message);
|
||||
}
|
||||
}
|
||||
|
||||
return { success: errors.length === 0, executed, errors };
|
||||
}
|
||||
|
||||
/** Get a preview of statement (first N characters) */
|
||||
export function getStatementPreview(stmt: string, maxLength: number = 50): string {
|
||||
const trimmed = stmt.trim();
|
||||
if (trimmed.length <= maxLength) {
|
||||
return trimmed;
|
||||
}
|
||||
return trimmed.substring(0, maxLength) + "...";
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// CLI execution (only runs when called directly)
|
||||
// =============================================================================
|
||||
|
||||
const url = "file:./data/medassist-ng.db";
|
||||
|
||||
async function main() {
|
||||
console.log("Starting database setup...");
|
||||
console.log("Database URL:", url);
|
||||
|
||||
const client = createClient({ url });
|
||||
|
||||
const sql = getMigrationSQL();
|
||||
const statements = splitSQLStatements(sql);
|
||||
|
||||
for (const stmt of statements) {
|
||||
console.log("Executing:", stmt.trim().substring(0, 50) + "...");
|
||||
console.log("Executing:", getStatementPreview(stmt));
|
||||
await client.execute(stmt);
|
||||
}
|
||||
|
||||
@@ -119,7 +162,11 @@ async function main() {
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
main().catch((err) => {
|
||||
console.error("Migration failed:", err);
|
||||
process.exit(1);
|
||||
});
|
||||
// Only run main() if this file is executed directly (not imported)
|
||||
const isMainModule = import.meta.url === `file://${process.argv[1]}`;
|
||||
if (isMainModule) {
|
||||
main().catch((err) => {
|
||||
console.error("Migration failed:", err);
|
||||
process.exit(1);
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user