import { afterEach, beforeEach, describe, expect, test } from 'bun:test'; import { existsSync } from 'node:fs'; import { unlink } from 'node:fs/promises'; import { and, eq } from 'drizzle-orm'; import { upsertModuleConfig } from '../services/module-config'; import { type DbClient, createDbClient } from './client'; import { capabilities, moduleConfigs, modules, secrets } from './schema'; import type { NewCapability, NewModule, NewSecret } from './schema'; describe('Database Schema', () => { let db: DbClient; let testDbPath: string; beforeEach(async () => { // Create unique database path for each test testDbPath = `./test-celilo-${Date.now()}-${Math.random()}.db`; // Create fresh database db = createDbClient({ path: testDbPath }); // Create tables manually for testing (no migrations yet) db.$client.run(` CREATE TABLE IF NOT EXISTS modules ( id TEXT PRIMARY KEY, name TEXT NOT NULL, version TEXT NOT NULL, description TEXT, state TEXT NOT NULL DEFAULT 'IMPORTED', manifest_data TEXT NOT NULL, source_path TEXT NOT NULL, imported_at INTEGER NOT NULL DEFAULT (unixepoch()), updated_at INTEGER NOT NULL DEFAULT (unixepoch()), error_message TEXT ) `); db.$client.run(` CREATE TABLE IF NOT EXISTS module_configs ( id INTEGER PRIMARY KEY AUTOINCREMENT, module_id TEXT NOT NULL, key TEXT NOT NULL, value TEXT NOT NULL, value_json TEXT, created_at INTEGER NOT NULL DEFAULT (unixepoch()), updated_at INTEGER NOT NULL DEFAULT (unixepoch()), FOREIGN KEY (module_id) REFERENCES modules(id) ON DELETE CASCADE ) `); db.$client.run(` CREATE TABLE IF NOT EXISTS capabilities ( id INTEGER PRIMARY KEY AUTOINCREMENT, module_id TEXT NOT NULL, capability_name TEXT NOT NULL, version TEXT NOT NULL, data TEXT NOT NULL, registered_at INTEGER NOT NULL DEFAULT (unixepoch()), FOREIGN KEY (module_id) REFERENCES modules(id) ON DELETE CASCADE ) `); db.$client.run(` CREATE TABLE IF NOT EXISTS secrets ( id INTEGER PRIMARY KEY AUTOINCREMENT, module_id TEXT NOT NULL, name TEXT NOT NULL, encrypted_value TEXT NOT NULL, iv TEXT NOT NULL, auth_tag TEXT NOT NULL, created_at INTEGER NOT NULL DEFAULT (unixepoch()), updated_at INTEGER NOT NULL DEFAULT (unixepoch()), FOREIGN KEY (module_id) REFERENCES modules(id) ON DELETE CASCADE ) `); }); afterEach(async () => { db.$client.close(); if (existsSync(testDbPath)) { await unlink(testDbPath); } // Clean up WAL and SHM files const walPath = `${testDbPath}-wal`; const shmPath = `${testDbPath}-shm`; if (existsSync(walPath)) { await unlink(walPath); } if (existsSync(shmPath)) { await unlink(shmPath); } }); test('should create a module', () => { const newModule: NewModule = { id: 'homebridge', name: 'Homebridge', version: '1.0.0', description: 'HomeKit bridge', sourcePath: '/data/modules/homebridge', manifestData: { id: 'homebridge', requires: {} }, }; const result = db.insert(modules).values(newModule).returning().get(); expect(result).toBeDefined(); expect(result.id).toBe('homebridge'); expect(result.name).toBe('Homebridge'); expect(result.state).toBe('IMPORTED'); }); test('should create module config', () => { // Insert module first const newModule: NewModule = { id: 'homebridge', name: 'Homebridge', version: '1.0.0', sourcePath: '/data/modules/homebridge', manifestData: {}, }; db.insert(modules).values(newModule).run(); // Insert config via the shared upsert helper upsertModuleConfig(db, 'homebridge', 'target_ip', '192.168.0.50'); const result = db .select() .from(moduleConfigs) .where(and(eq(moduleConfigs.moduleId, 'homebridge'), eq(moduleConfigs.key, 'target_ip'))) .get(); expect(result).toBeDefined(); if (!result) throw new Error('expected row'); expect(result.moduleId).toBe('homebridge'); expect(result.key).toBe('target_ip'); expect(result.value).toBe('192.168.0.50'); }); test('should create capability', () => { // Insert module first const newModule: NewModule = { id: 'dns-external', name: 'DNS External', version: '1.0.0', sourcePath: '/data/modules/dns-external', manifestData: {}, }; db.insert(modules).values(newModule).run(); // Insert capability const newCapability: NewCapability = { moduleId: 'dns-external', capabilityName: 'dns_external', version: '1.0.0', data: { nameserver: 'ns1.example.com', zone: 'example.com', }, }; const result = db.insert(capabilities).values(newCapability).returning().get(); expect(result).toBeDefined(); expect(result.capabilityName).toBe('dns_external'); expect(result.data).toEqual({ nameserver: 'ns1.example.com', zone: 'example.com', }); }); test('should create secret', () => { // Insert module first const newModule: NewModule = { id: 'dns-external', name: 'DNS External', version: '1.0.0', sourcePath: '/data/modules/dns-external', manifestData: {}, }; db.insert(modules).values(newModule).run(); // Insert secret const newSecret: NewSecret = { moduleId: 'dns-external', name: 'tsig_key', encryptedValue: 'encrypted_data_here', iv: 'initialization_vector', authTag: 'authentication_tag', }; const result = db.insert(secrets).values(newSecret).returning().get(); expect(result).toBeDefined(); expect(result.name).toBe('tsig_key'); expect(result.encryptedValue).toBe('encrypted_data_here'); }); test('should cascade delete module configs when module is deleted', () => { // Insert module const newModule: NewModule = { id: 'homebridge', name: 'Homebridge', version: '1.0.0', sourcePath: '/data/modules/homebridge', manifestData: {}, }; db.insert(modules).values(newModule).run(); // Insert config via the shared upsert helper upsertModuleConfig(db, 'homebridge', 'target_ip', '192.168.0.50'); // Delete module db.delete(modules).where(eq(modules.id, 'homebridge')).run(); // Config should be deleted const configs = db.select().from(moduleConfigs).all(); expect(configs).toHaveLength(0); }); });