f56f2b7c88
- 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
286 lines
8.7 KiB
TypeScript
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";
|