Files
medassist-ng/backend/src/db/migrate.ts
T
Daniel Volz ba3ebd27f4 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
2025-12-30 11:14:52 +01:00

173 lines
5.9 KiB
TypeScript

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" });
// =============================================================================
// Exported utility functions for testing
// =============================================================================
/** 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,
password_hash text,
avatar_url text,
auth_provider text NOT NULL DEFAULT 'local',
oidc_subject text,
is_active integer NOT NULL DEFAULT 1,
last_login_at integer,
created_at integer NOT NULL DEFAULT (strftime('%s','now')),
updated_at integer NOT NULL DEFAULT (strftime('%s','now'))
);
CREATE TABLE IF NOT EXISTS medications (
id integer PRIMARY KEY AUTOINCREMENT,
user_id integer NOT NULL,
name text NOT NULL,
generic_name text,
taken_by_json text NOT NULL DEFAULT '[]',
pack_count integer NOT NULL DEFAULT 1,
blisters_per_pack integer NOT NULL DEFAULT 1,
pills_per_blister integer NOT NULL DEFAULT 1,
loose_tablets integer NOT NULL DEFAULT 0,
pill_weight_mg integer,
usage_json text NOT NULL DEFAULT '[]',
every_json text NOT NULL DEFAULT '[]',
start_json text NOT NULL DEFAULT '[]',
image_url text,
expiry_date text,
notes text,
intake_reminders_enabled integer NOT NULL DEFAULT 0,
updated_at integer NOT NULL DEFAULT (strftime('%s','now')),
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
);
CREATE TABLE IF NOT EXISTS user_settings (
id integer PRIMARY KEY AUTOINCREMENT,
user_id integer NOT NULL UNIQUE,
email_enabled integer NOT NULL DEFAULT 0,
notification_email text,
email_stock_reminders integer NOT NULL DEFAULT 1,
email_intake_reminders integer NOT NULL DEFAULT 1,
shoutrrr_enabled integer NOT NULL DEFAULT 0,
shoutrrr_url text,
shoutrrr_stock_reminders integer NOT NULL DEFAULT 1,
shoutrrr_intake_reminders integer NOT NULL DEFAULT 1,
reminder_days_before integer NOT NULL DEFAULT 7,
repeat_daily_reminders integer NOT NULL DEFAULT 0,
low_stock_days integer NOT NULL DEFAULT 30,
normal_stock_days integer NOT NULL DEFAULT 90,
high_stock_days integer NOT NULL DEFAULT 180,
language text NOT NULL DEFAULT 'en',
stock_calculation_mode text NOT NULL DEFAULT 'automatic',
last_auto_email_sent text,
last_notification_type text,
last_notification_channel text,
updated_at integer NOT NULL DEFAULT (strftime('%s','now')),
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
);
CREATE TABLE IF NOT EXISTS refresh_tokens (
id integer PRIMARY KEY AUTOINCREMENT,
user_id integer NOT NULL,
token_id text NOT NULL UNIQUE,
expires_at integer NOT NULL,
rotated_at integer,
revoked integer NOT NULL DEFAULT 0,
created_at integer NOT NULL DEFAULT (strftime('%s','now')),
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
);
CREATE TABLE IF NOT EXISTS share_tokens (
id integer PRIMARY KEY AUTOINCREMENT,
user_id integer NOT NULL,
token text NOT NULL UNIQUE,
taken_by text NOT NULL,
schedule_days integer NOT NULL DEFAULT 30,
created_at integer NOT NULL DEFAULT (strftime('%s','now')),
expires_at integer,
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
);
CREATE TABLE IF NOT EXISTS dose_tracking (
id integer PRIMARY KEY AUTOINCREMENT,
user_id integer NOT NULL,
dose_id text NOT NULL,
taken_at integer NOT NULL DEFAULT (strftime('%s','now')),
marked_by text,
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
);
`;
}
/** 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:", getStatementPreview(stmt));
await client.execute(stmt);
}
console.log("Database setup complete!");
process.exit(0);
}
// 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);
});
}