Files
medassist-ng/backend/src/test/setup.ts
T
Daniel Volz f56f2b7c88 feat: backend improvements - reminder tracking, share stock status, planner notifications (#145)
- Separate stock/intake reminder tracking in DB with dedicated columns
- Add shareStockStatus setting to control stock visibility on shared links
- Rewrite planner notification to support both email and Shoutrrr push
- Add push notification footer text for intake and stock reminders
- New DB migrations: stock_reminder_tracking (0006), share_stock_status (0007)
- Update backend i18n with demandCalculator section and critically low text
- Add 514 passing backend tests including new coverage for all changes
2026-02-09 19:32:32 +01:00

286 lines
8.7 KiB
TypeScript

/**
* Test setup and utilities for MedAssist backend API tests.
* Uses in-memory SQLite for isolation between test files.
*/
import { dirname, resolve } from "node:path";
import { fileURLToPath } from "node:url";
import cookie from "@fastify/cookie";
import jwt from "@fastify/jwt";
import fastifyMultipart from "@fastify/multipart";
import sensible from "@fastify/sensible";
import { type Client, createClient } from "@libsql/client";
import { drizzle } from "drizzle-orm/libsql";
import { migrate } from "drizzle-orm/libsql/migrator";
import Fastify, { type FastifyInstance } from "fastify";
// Get migrations folder path
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
const migrationsFolder = resolve(__dirname, "../../drizzle");
// Type for our test database
export type TestDb = ReturnType<typeof drizzle>;
// =============================================================================
// Test App Builder
// =============================================================================
export interface TestContext {
app: FastifyInstance;
db: TestDb;
client: Client;
}
/**
* Build a test Fastify app with in-memory SQLite.
* Each test file gets its own isolated database.
*/
export async function buildTestApp(): Promise<TestContext> {
// Create in-memory SQLite database
const client = createClient({ url: ":memory:" });
const db = drizzle(client);
// Run schema creation
await runTestMigrations(client);
// Create Fastify app with minimal plugins
const app = Fastify({ logger: false });
await app.register(sensible);
await app.register(cookie, { secret: "test-cookie-secret" });
await app.register(jwt, {
secret: "test-jwt-secret",
cookie: { cookieName: "access_token", signed: false },
});
await app.register(fastifyMultipart, { limits: { fileSize: 10 * 1024 * 1024 } });
// Decorate config (matches index.ts structure)
app.decorate("config", {
accessSecret: "test-jwt-secret",
refreshSecret: "test-refresh-secret",
accessTtl: 15,
refreshTtl: 7,
cookieOptions: { httpOnly: true, sameSite: "lax", secure: false, path: "/" },
refreshCookieOptions: { httpOnly: true, sameSite: "lax", secure: false, path: "/" },
});
return { app, db, client };
}
/**
* Create test database schema using drizzle-kit migrations
*/
async function runTestMigrations(client: Client): Promise<void> {
const db = drizzle(client);
await migrate(db, { migrationsFolder });
}
// =============================================================================
// Factory Helpers
// =============================================================================
export interface CreateUserOptions {
username?: string;
authProvider?: string;
}
/**
* Create a test user and return the ID
*/
export async function createTestUser(client: Client, options: CreateUserOptions = {}): Promise<number> {
const { username = `user_${Date.now()}`, authProvider = "local" } = options;
const result = await client.execute({
sql: `INSERT INTO users (username, auth_provider) VALUES (?, ?) RETURNING id`,
args: [username, authProvider],
});
return result.rows[0].id as number;
}
export interface CreateMedicationOptions {
userId: number;
name?: string;
genericName?: string;
takenBy?: string[];
packCount?: number;
blistersPerPack?: number;
pillsPerBlister?: number;
looseTablets?: number;
pillWeightMg?: number;
expiryDate?: string | null;
notes?: string | null;
intakeRemindersEnabled?: boolean;
/** Array of { usage, every, start } for each blister schedule */
blisters?: Array<{ usage: number; every: number; start: string }>;
}
/**
* Create a test medication and return the ID
*/
export async function createTestMedication(client: Client, options: CreateMedicationOptions): Promise<number> {
const {
userId,
name = "Test Medication",
genericName = null,
takenBy = [],
packCount = 1,
blistersPerPack = 1,
pillsPerBlister = 10,
looseTablets = 0,
pillWeightMg = null,
expiryDate = null,
notes = null,
intakeRemindersEnabled = false,
blisters = [{ usage: 1, every: 1, start: new Date().toISOString() }],
} = options;
// Extract arrays from blisters
const usageJson = JSON.stringify(blisters.map((b) => b.usage));
const everyJson = JSON.stringify(blisters.map((b) => b.every));
const startJson = JSON.stringify(blisters.map((b) => b.start));
const takenByJson = JSON.stringify(takenBy);
const result = await client.execute({
sql: `INSERT INTO medications (
user_id, name, generic_name, taken_by_json,
pack_count, blisters_per_pack, pills_per_blister, loose_tablets,
pill_weight_mg, usage_json, every_json, start_json, expiry_date, notes, intake_reminders_enabled
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) RETURNING id`,
args: [
userId,
name,
genericName,
takenByJson,
packCount,
blistersPerPack,
pillsPerBlister,
looseTablets,
pillWeightMg,
usageJson,
everyJson,
startJson,
expiryDate,
notes,
intakeRemindersEnabled ? 1 : 0,
],
});
return result.rows[0].id as number;
}
export interface CreateShareTokenOptions {
userId: number;
takenBy: string;
token?: string;
scheduleDays?: number;
expiresAt?: number | null;
}
/**
* Create a test share token and return the token string
*/
export async function createTestShareToken(client: Client, options: CreateShareTokenOptions): Promise<string> {
const { userId, takenBy, token = `test_token_${Date.now()}`, scheduleDays = 30, expiresAt = null } = options;
await client.execute({
sql: `INSERT INTO share_tokens (user_id, token, taken_by, schedule_days, expires_at)
VALUES (?, ?, ?, ?, ?)`,
args: [userId, token, takenBy, scheduleDays, expiresAt],
});
return token;
}
export interface CreateDoseTrackingOptions {
userId: number;
doseId: string;
markedBy?: string | null;
takenAt?: number;
}
/**
* Create a dose tracking record
*/
export async function createTestDoseTracking(client: Client, options: CreateDoseTrackingOptions): Promise<void> {
const { userId, doseId, markedBy = null, takenAt = Math.floor(Date.now() / 1000) } = options;
await client.execute({
sql: `INSERT INTO dose_tracking (user_id, dose_id, marked_by, taken_at)
VALUES (?, ?, ?, ?)`,
args: [userId, doseId, markedBy, takenAt],
});
}
export interface UpdateUserSettingsOptions {
userId: number;
stockCalculationMode?: "automatic" | "manual";
lowStockDays?: number;
shareStockStatus?: boolean;
}
/**
* Create or update user settings
*/
export async function setUserSettings(client: Client, options: UpdateUserSettingsOptions): Promise<void> {
const { userId, stockCalculationMode = "automatic", lowStockDays = 30, shareStockStatus } = options;
// Check if settings exist
const existing = await client.execute({
sql: `SELECT id FROM user_settings WHERE user_id = ?`,
args: [userId],
});
if (existing.rows.length > 0) {
await client.execute({
sql: `UPDATE user_settings SET stock_calculation_mode = ?, low_stock_days = ?${shareStockStatus !== undefined ? ", share_stock_status = ?" : ""} WHERE user_id = ?`,
args:
shareStockStatus !== undefined
? [stockCalculationMode, lowStockDays, shareStockStatus ? 1 : 0, userId]
: [stockCalculationMode, lowStockDays, userId],
});
} else {
await client.execute({
sql: `INSERT INTO user_settings (user_id, stock_calculation_mode, low_stock_days${shareStockStatus !== undefined ? ", share_stock_status" : ""}) VALUES (?, ?, ?${shareStockStatus !== undefined ? ", ?" : ""})`,
args:
shareStockStatus !== undefined
? [userId, stockCalculationMode, lowStockDays, shareStockStatus ? 1 : 0]
: [userId, stockCalculationMode, lowStockDays],
});
}
}
// =============================================================================
// Test Cleanup
// =============================================================================
/**
* Close test app and database connections
*/
export async function closeTestApp(ctx: TestContext): Promise<void> {
await ctx.app.close();
ctx.client.close();
}
/**
* Clear all data from test database (between tests)
*/
export async function clearTestData(client: Client): Promise<void> {
// Order matters due to foreign keys
await client.execute("DELETE FROM refill_history");
await client.execute("DELETE FROM dose_tracking");
await client.execute("DELETE FROM share_tokens");
await client.execute("DELETE FROM refresh_tokens");
await client.execute("DELETE FROM user_settings");
await client.execute("DELETE FROM medications");
await client.execute("DELETE FROM users");
}
// =============================================================================
// Vitest Global Setup
// =============================================================================
// Set test environment
process.env.AUTH_ENABLED = "false";
process.env.NODE_ENV = "test";