diff --git a/backend/src/db/client.ts b/backend/src/db/client.ts index 50ead72..5fac93f 100644 --- a/backend/src/db/client.ts +++ b/backend/src/db/client.ts @@ -3,6 +3,7 @@ import { drizzle } from "drizzle-orm/libsql"; import { existsSync, mkdirSync, accessSync, constants, statSync, writeFileSync } from "fs"; import { resolve } from "path"; import dotenv from "dotenv"; +import { getTableCreationSQL } from "./schema-sql"; dotenv.config({ path: process.env.DOTENV_PATH || ".env" }); @@ -43,97 +44,8 @@ export function ensureDataDirectory(dataDir: string): { success: boolean; error? } } -/** 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, - 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, - expiry_warning_days integer NOT NULL DEFAULT 90, - 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 - )`, - ]; -} +/** Get the SQL statements for creating all tables (re-exported from schema-sql) */ +export { getTableCreationSQL } from "./schema-sql"; /** Run table creation migrations on a client */ export async function runTableMigrations(client: Client): Promise<{ success: boolean; errors: string[] }> { diff --git a/backend/src/db/migrate.ts b/backend/src/db/migrate.ts index 7d61f94..c6525df 100644 --- a/backend/src/db/migrate.ts +++ b/backend/src/db/migrate.ts @@ -2,6 +2,7 @@ import { createClient, Client } from "@libsql/client"; import dotenv from "dotenv"; import fs from "fs"; import path from "path"; +import { getTableCreationSQL } from "./schema-sql"; dotenv.config({ path: process.env.DOTENV_PATH || ".env" }); @@ -9,101 +10,8 @@ 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 - ); - `; -} +/** Get the full migration SQL string (re-exported from schema-sql) */ +export { getTableCreationSQL }; /** Split SQL string into individual statements */ export function splitSQLStatements(sql: string): string[] { @@ -112,8 +20,7 @@ export function splitSQLStatements(sql: string): string[] { /** 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 statements = getTableCreationSQL(); const errors: string[] = []; let executed = 0; @@ -150,8 +57,7 @@ async function main() { const client = createClient({ url }); - const sql = getMigrationSQL(); - const statements = splitSQLStatements(sql); + const statements = getTableCreationSQL(); for (const stmt of statements) { console.log("Executing:", getStatementPreview(stmt)); diff --git a/backend/src/db/schema-sql.ts b/backend/src/db/schema-sql.ts new file mode 100644 index 0000000..95ffebd --- /dev/null +++ b/backend/src/db/schema-sql.ts @@ -0,0 +1,99 @@ +/** + * Shared SQL table creation statements for database initialization. + * Used by client.ts, migrate.ts, and test setup to avoid duplication. + */ + +/** + * Get all SQL table creation statements as an array. + * Each statement creates a table if it doesn't exist. + */ +export function getTableCreationSQL(): 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, + expiry_warning_days integer NOT NULL DEFAULT 90, + 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 + )`, + ]; +} diff --git a/backend/src/test/database.test.ts b/backend/src/test/database.test.ts index 22c7bbf..5fd8c27 100644 --- a/backend/src/test/database.test.ts +++ b/backend/src/test/database.test.ts @@ -17,28 +17,29 @@ import { // Import the exported utility functions from migrate.ts import { - getMigrationSQL, + getTableCreationSQL as getTableCreationSQLFromMigrate, splitSQLStatements, executeMigration, getStatementPreview, } from "../db/migrate.js"; describe("Migration Script Utilities", () => { - describe("getMigrationSQL", () => { - it("should return a non-empty SQL string", () => { - const sql = getMigrationSQL(); - expect(typeof sql).toBe("string"); - expect(sql.length).toBeGreaterThan(100); + describe("getTableCreationSQL", () => { + it("should return a non-empty array of SQL statements", () => { + const statements = getTableCreationSQL(); + expect(Array.isArray(statements)).toBe(true); + expect(statements.length).toBeGreaterThan(0); }); it("should contain all table definitions", () => { - const sql = getMigrationSQL(); - expect(sql).toContain("CREATE TABLE IF NOT EXISTS users"); - expect(sql).toContain("CREATE TABLE IF NOT EXISTS medications"); - expect(sql).toContain("CREATE TABLE IF NOT EXISTS user_settings"); - expect(sql).toContain("CREATE TABLE IF NOT EXISTS refresh_tokens"); - expect(sql).toContain("CREATE TABLE IF NOT EXISTS share_tokens"); - expect(sql).toContain("CREATE TABLE IF NOT EXISTS dose_tracking"); + const statements = getTableCreationSQL(); + const allSQL = statements.join(" "); + expect(allSQL).toContain("CREATE TABLE IF NOT EXISTS users"); + expect(allSQL).toContain("CREATE TABLE IF NOT EXISTS medications"); + expect(allSQL).toContain("CREATE TABLE IF NOT EXISTS user_settings"); + expect(allSQL).toContain("CREATE TABLE IF NOT EXISTS refresh_tokens"); + expect(allSQL).toContain("CREATE TABLE IF NOT EXISTS share_tokens"); + expect(allSQL).toContain("CREATE TABLE IF NOT EXISTS dose_tracking"); }); }); @@ -61,9 +62,8 @@ describe("Migration Script Utilities", () => { expect(statements).toHaveLength(2); }); - it("should split migration SQL into 6 statements", () => { - const sql = getMigrationSQL(); - const statements = splitSQLStatements(sql); + it("should handle getTableCreationSQL output correctly", () => { + const statements = getTableCreationSQL(); expect(statements).toHaveLength(6); }); diff --git a/backend/src/test/setup.ts b/backend/src/test/setup.ts index 70606d9..d3f4a5e 100644 --- a/backend/src/test/setup.ts +++ b/backend/src/test/setup.ts @@ -10,6 +10,7 @@ import fastifyMultipart from "@fastify/multipart"; import { createClient, Client } from "@libsql/client"; import { drizzle } from "drizzle-orm/libsql"; import { beforeAll, afterAll, beforeEach } from "vitest"; +import { getTableCreationSQL } from "../db/schema-sql"; // Type for our test database export type TestDb = ReturnType; @@ -63,94 +64,7 @@ export async function buildTestApp(): Promise { * Create test database schema */ async function runTestMigrations(client: Client): Promise { - const tableCreations = [ - `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, - expiry_warning_days integer NOT NULL DEFAULT 90, - 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 - )`, - ]; + const tableCreations = getTableCreationSQL(); for (const sql of tableCreations) { await client.execute(sql);