package models import ( "database/sql" "os" "testing" "time" _ "modernc.org/sqlite" ) func openTestDB(t *testing.T) *sql.DB { db, err := sql.Open("sqlite", ":memory:") if err != nil { t.Fatalf("failed to open in-memory db: %v", err) } migrations := []string{initSchema, migration002, migration003, migration004, migration005, migration006} for _, m := range migrations { if _, err := db.Exec(m); err != nil { t.Fatalf("failed to apply migration: %v", err) } } return db } const initSchema = ` CREATE TABLE IF NOT EXISTS users ( id INTEGER PRIMARY KEY AUTOINCREMENT, username TEXT UNIQUE NOT NULL, password_hash TEXT NOT NULL, role TEXT NOT NULL DEFAULT 'user', created_at DATETIME DEFAULT CURRENT_TIMESTAMP ); CREATE TABLE IF NOT EXISTS ssh_keys ( id INTEGER PRIMARY KEY AUTOINCREMENT, label TEXT NOT NULL, private_key_path TEXT NOT NULL, public_key TEXT NOT NULL, created_at DATETIME DEFAULT CURRENT_TIMESTAMP ); CREATE TABLE IF NOT EXISTS machines ( id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT NOT NULL, host TEXT NOT NULL, port INTEGER NOT NULL DEFAULT 22, ssh_user TEXT NOT NULL DEFAULT 'root', ssh_key_id INTEGER REFERENCES ssh_keys(id), mac_address TEXT, wol_enabled INTEGER NOT NULL DEFAULT 0, broadcast_addr TEXT, wake_timeout_seconds INTEGER NOT NULL DEFAULT 120, wake_check_interval_seconds INTEGER NOT NULL DEFAULT 5, fingerprint_confirmed INTEGER NOT NULL DEFAULT 0, status TEXT NOT NULL DEFAULT 'unknown', last_seen_at DATETIME, created_at DATETIME DEFAULT CURRENT_TIMESTAMP ); CREATE TABLE IF NOT EXISTS sync_pairs ( id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT NOT NULL, source_machine_id INTEGER REFERENCES machines(id), source_path TEXT NOT NULL, dest_machine_id INTEGER REFERENCES machines(id), dest_path TEXT NOT NULL, direction TEXT NOT NULL DEFAULT 'push', rsync_flags TEXT NOT NULL DEFAULT '-aP', exclude_patterns TEXT NOT NULL DEFAULT '', enabled INTEGER NOT NULL DEFAULT 1, created_at DATETIME DEFAULT CURRENT_TIMESTAMP ); CREATE TABLE IF NOT EXISTS schedules ( id INTEGER PRIMARY KEY AUTOINCREMENT, sync_pair_id INTEGER NOT NULL REFERENCES sync_pairs(id) ON DELETE CASCADE, cron_expr TEXT NOT NULL, next_run_at DATETIME, enabled INTEGER NOT NULL DEFAULT 1, created_at DATETIME DEFAULT CURRENT_TIMESTAMP ); CREATE TABLE IF NOT EXISTS jobs ( id INTEGER PRIMARY KEY AUTOINCREMENT, sync_pair_id INTEGER NOT NULL REFERENCES sync_pairs(id), trigger_type TEXT NOT NULL DEFAULT 'manual', status TEXT NOT NULL DEFAULT 'queued', started_at DATETIME, finished_at DATETIME, log_file TEXT, created_at DATETIME DEFAULT CURRENT_TIMESTAMP ); CREATE TABLE IF NOT EXISTS job_logs ( id INTEGER PRIMARY KEY AUTOINCREMENT, job_id INTEGER NOT NULL REFERENCES jobs(id) ON DELETE CASCADE, stream TEXT NOT NULL, content TEXT NOT NULL, timestamp DATETIME DEFAULT CURRENT_TIMESTAMP ); CREATE TABLE IF NOT EXISTS sessions ( id TEXT PRIMARY KEY, user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, token_hash TEXT NOT NULL, expires_at DATETIME NOT NULL, revoked INTEGER NOT NULL DEFAULT 0, created_at DATETIME DEFAULT CURRENT_TIMESTAMP ); ` const migration002 = ` CREATE INDEX IF NOT EXISTS idx_job_logs_job_id ON job_logs(job_id); CREATE INDEX IF NOT EXISTS idx_jobs_status_created ON jobs(status, created_at DESC); CREATE INDEX IF NOT EXISTS idx_jobs_sync_pair_id ON jobs(sync_pair_id); CREATE TABLE IF NOT EXISTS cleanup_history ( id INTEGER PRIMARY KEY AUTOINCREMENT, deleted_before DATETIME NOT NULL, logs_purged INTEGER NOT NULL DEFAULT 0, jobs_purged INTEGER NOT NULL DEFAULT 0, executed_at DATETIME DEFAULT CURRENT_TIMESTAMP ); ` const migration003 = ` ALTER TABLE jobs ADD COLUMN error_message TEXT; ALTER TABLE jobs ADD COLUMN error_code TEXT; ` const migration004 = ` ALTER TABLE machines ADD COLUMN host_key_fingerprint TEXT; ` const migration005 = ` ALTER TABLE machines ADD COLUMN shutdown_command TEXT NOT NULL DEFAULT 'shutdown now'; ` const migration006 = ` ALTER TABLE jobs ADD COLUMN total_size_bytes INTEGER DEFAULT 0; ALTER TABLE jobs ADD COLUMN sent_bytes INTEGER DEFAULT 0; ` func TestJobRepository_CreateAndGetByID(t *testing.T) { db := openTestDB(t) defer db.Close() repo := NewJobRepository(db) id, err := repo.Create(1, "manual", "queued") if err != nil { t.Fatalf("Create failed: %v", err) } job, err := repo.GetByID(id) if err != nil { t.Fatalf("GetByID failed: %v", err) } if job.ID != id { t.Errorf("expected ID %d, got %d", id, job.ID) } if job.SyncPairID != 1 { t.Errorf("expected SyncPairID 1, got %d", job.SyncPairID) } if job.TriggerType != "manual" { t.Errorf("expected trigger_type 'manual', got %q", job.TriggerType) } if job.Status != "queued" { t.Errorf("expected status 'queued', got %q", job.Status) } } func TestJobRepository_UpdateStatus_SetsStartedAt(t *testing.T) { db := openTestDB(t) defer db.Close() repo := NewJobRepository(db) id, _ := repo.Create(1, "manual", "queued") err := repo.UpdateStatus(id, "running") if err != nil { t.Fatalf("UpdateStatus failed: %v", err) } job, _ := repo.GetByID(id) if job.StartedAt == nil { t.Fatal("expected StartedAt to be set for 'running' status") } if job.Status != "running" { t.Errorf("expected status 'running', got %q", job.Status) } } func TestJobRepository_UpdateStatus_SetsFinishedAt(t *testing.T) { db := openTestDB(t) defer db.Close() repo := NewJobRepository(db) id, _ := repo.Create(1, "manual", "queued") repo.UpdateStatus(id, "running") err := repo.UpdateStatus(id, "success") if err != nil { t.Fatalf("UpdateStatus failed: %v", err) } job, _ := repo.GetByID(id) if job.FinishedAt == nil { t.Fatal("expected FinishedAt to be set for 'success' status") } if job.Status != "success" { t.Errorf("expected status 'success', got %q", job.Status) } } func TestJobRepository_UpdateStatus_Failed(t *testing.T) { db := openTestDB(t) defer db.Close() repo := NewJobRepository(db) id, _ := repo.Create(1, "manual", "queued") repo.UpdateStatus(id, "running") err := repo.UpdateStatus(id, "failed") if err != nil { t.Fatalf("UpdateStatus failed: %v", err) } job, _ := repo.GetByID(id) if job.FinishedAt == nil { t.Fatal("expected FinishedAt to be set for 'failed' status") } } func TestJobRepository_SetError(t *testing.T) { db := openTestDB(t) defer db.Close() repo := NewJobRepository(db) id, _ := repo.Create(1, "manual", "queued") err := repo.SetError(id, "EIO", "disk read failed") if err != nil { t.Fatalf("SetError failed: %v", err) } job, _ := repo.GetByID(id) if job.ErrorCode == nil || *job.ErrorCode != "EIO" { t.Errorf("expected error_code 'EIO', got %v", job.ErrorCode) } if job.ErrorMessage == nil || *job.ErrorMessage != "disk read failed" { t.Errorf("expected error_message 'disk read failed', got %v", job.ErrorMessage) } } func TestJobRepository_GetByStatusAny(t *testing.T) { db := openTestDB(t) defer db.Close() repo := NewJobRepository(db) _, _ = repo.Create(1, "manual", "queued") id2, _ := repo.Create(1, "manual", "running") _, _ = repo.Create(1, "manual", "success") jobs, err := repo.GetByStatusAny([]string{"running"}) if err != nil { t.Fatalf("GetByStatusAny failed: %v", err) } if len(jobs) != 1 { t.Fatalf("expected 1 job, got %d", len(jobs)) } if jobs[0].ID != id2 { t.Errorf("expected job ID %d, got %d", id2, jobs[0].ID) } jobs, _ = repo.GetByStatusAny([]string{"queued", "running"}) if len(jobs) != 2 { t.Fatalf("expected 2 jobs, got %d", len(jobs)) } } func TestJobRepository_DeleteFinishedBefore(t *testing.T) { db := openTestDB(t) defer db.Close() repo := NewJobRepository(db) id1, _ := repo.Create(1, "manual", "queued") repo.UpdateStatus(id1, "running") repo.UpdateStatus(id1, "success") db.Exec("UPDATE jobs SET finished_at = datetime('2020-01-01 00:00:00') WHERE id = ?", id1) id2, _ := repo.Create(1, "manual", "queued") repo.UpdateStatus(id2, "running") repo.UpdateStatus(id2, "success") cutoff := time.Now() deleted, err := repo.DeleteFinishedBefore(cutoff) if err != nil { t.Fatalf("DeleteFinishedBefore failed: %v", err) } if deleted != 1 { t.Errorf("expected 1 deleted, got %d", deleted) } _, err = repo.GetByID(id1) if err != sql.ErrNoRows { t.Errorf("expected id1 to be deleted") } _, err = repo.GetByID(id2) if err != nil { t.Errorf("expected id2 to still exist") } } func TestJobRepository_SetTotals(t *testing.T) { db := openTestDB(t) defer db.Close() repo := NewJobRepository(db) id, _ := repo.Create(1, "manual", "queued") err := repo.SetTotals(id, 1024, 512) if err != nil { t.Fatalf("SetTotals failed: %v", err) } job, _ := repo.GetByID(id) if job.TotalSizeBytes != 1024 { t.Errorf("expected TotalSizeBytes 1024, got %d", job.TotalSizeBytes) } if job.SentBytes != 512 { t.Errorf("expected SentBytes 512, got %d", job.SentBytes) } } func TestMachineRepository_CreateAndGetByID(t *testing.T) { db := openTestDB(t) defer db.Close() repo := NewMachineRepository(db) mac := "AA:BB:CC:DD:EE:FF" bcast := "192.168.1.255" WolEnabled := true WolTimeout := 300 machine := &Machine{ Name: "test-machine", Host: "192.168.1.10", Port: 22, SSHUser: "admin", MACAddress: &mac, WoLEnabled: WolEnabled, BroadcastAddr: &bcast, WakeTimeoutSeconds: WolTimeout, WakeCheckIntervalSeconds: 10, Status: "unknown", ShutdownCommand: "shutdown -h now", } id, err := repo.Create(machine) if err != nil { t.Fatalf("Create failed: %v", err) } retrieved, err := repo.GetByID(id) if err != nil { t.Fatalf("GetByID failed: %v", err) } if retrieved.Name != "test-machine" { t.Errorf("expected name 'test-machine', got %q", retrieved.Name) } if retrieved.Host != "192.168.1.10" { t.Errorf("expected host '192.168.1.10', got %q", retrieved.Host) } if retrieved.MACAddress == nil || *retrieved.MACAddress != mac { t.Errorf("expected MAC %q, got %v", mac, retrieved.MACAddress) } if !retrieved.WoLEnabled { t.Error("expected WoLEnabled to be true") } if retrieved.BroadcastAddr == nil || *retrieved.BroadcastAddr != bcast { t.Errorf("expected broadcast %q, got %v", bcast, retrieved.BroadcastAddr) } if retrieved.WakeTimeoutSeconds != WolTimeout { t.Errorf("expected wake timeout %d, got %d", WolTimeout, retrieved.WakeTimeoutSeconds) } if retrieved.ShutdownCommand != "shutdown -h now" { t.Errorf("expected shutdown command 'shutdown -h now', got %q", retrieved.ShutdownCommand) } } func TestMachineRepository_UpdateStatus(t *testing.T) { db := openTestDB(t) defer db.Close() repo := NewMachineRepository(db) id, _ := repo.Create(&Machine{Name: "test", Host: "192.168.1.1", Status: "unknown"}) err := repo.UpdateStatus(id, "online") if err != nil { t.Fatalf("UpdateStatus failed: %v", err) } machine, _ := repo.GetByID(id) if machine.Status != "online" { t.Errorf("expected status 'online', got %q", machine.Status) } if machine.LastSeenAt == nil { t.Error("expected LastSeenAt to be set") } } func TestMachineRepository_GetAll(t *testing.T) { db := openTestDB(t) defer db.Close() repo := NewMachineRepository(db) repo.Create(&Machine{Name: "machine-a", Host: "192.168.1.1", Status: "online"}) repo.Create(&Machine{Name: "machine-b", Host: "192.168.1.2", Status: "offline"}) machines, err := repo.GetAll() if err != nil { t.Fatalf("GetAll failed: %v", err) } if len(machines) != 2 { t.Errorf("expected 2 machines, got %d", len(machines)) } } func TestSyncPairRepository_CreateAndGetByID(t *testing.T) { db := openTestDB(t) defer db.Close() repo := NewSyncPairRepository(db) sp := &SyncPair{ Name: "backup-data", SourcePath: "/data", DestPath: "/backup", Direction: "push", RsyncFlags: "-aP --delete", ExcludePatterns: "*.tmp\n*.log", Enabled: true, } id, err := repo.Create(sp) if err != nil { t.Fatalf("Create failed: %v", err) } retrieved, err := repo.GetByID(id) if err != nil { t.Fatalf("GetByID failed: %v", err) } if retrieved.Name != "backup-data" { t.Errorf("expected name 'backup-data', got %q", retrieved.Name) } if retrieved.ExcludePatterns != "*.tmp\n*.log" { t.Errorf("expected exclude patterns '*.tmp\\n*.log', got %q", retrieved.ExcludePatterns) } } func TestSyncPairRepository_ExcludePatternsList(t *testing.T) { db := openTestDB(t) defer db.Close() repo := NewSyncPairRepository(db) sp := &SyncPair{ Name: "test-pair", SourcePath: "/src", DestPath: "/dst", ExcludePatterns: "*.tmp\n *.log\n \n*.bak", } id, _ := repo.Create(sp) retrieved, _ := repo.GetByID(id) patterns := retrieved.ExcludePatternsList() if len(patterns) != 3 { t.Fatalf("expected 3 patterns, got %d: %v", len(patterns), patterns) } if patterns[0] != "*.tmp" { t.Errorf("expected first pattern '*.tmp', got %q", patterns[0]) } if patterns[1] != "*.log" { t.Errorf("expected second pattern '*.log', got %q", patterns[1]) } if patterns[2] != "*.bak" { t.Errorf("expected third pattern '*.bak', got %q", patterns[2]) } } func TestSyncPairRepository_GetAll(t *testing.T) { db := openTestDB(t) defer db.Close() repo := NewSyncPairRepository(db) repo.Create(&SyncPair{Name: "pair-a", SourcePath: "/a", DestPath: "/b"}) repo.Create(&SyncPair{Name: "pair-b", SourcePath: "/c", DestPath: "/d"}) pairs, err := repo.GetAll() if err != nil { t.Fatalf("GetAll failed: %v", err) } if len(pairs) != 2 { t.Errorf("expected 2 pairs, got %d", len(pairs)) } } func TestScheduleRepository_CreateAndGetByID(t *testing.T) { db := openTestDB(t) defer db.Close() repo := NewScheduleRepository(db) nextRun := time.Now().Add(1 * time.Hour) sched := &Schedule{ SyncPairID: 1, CronExpr: "0 0 * * *", NextRunAt: &nextRun, Enabled: true, } id, err := repo.Create(sched) if err != nil { t.Fatalf("Create failed: %v", err) } retrieved, err := repo.GetByID(id) if err != nil { t.Fatalf("GetByID failed: %v", err) } if retrieved.CronExpr != "0 0 * * *" { t.Errorf("expected cron '0 0 * * *', got %q", retrieved.CronExpr) } if !retrieved.Enabled { t.Error("expected enabled to be true") } } func TestScheduleRepository_UpdateEnabled(t *testing.T) { db := openTestDB(t) defer db.Close() repo := NewScheduleRepository(db) nextRun := time.Now().Add(1 * time.Hour) id, _ := repo.Create(&Schedule{SyncPairID: 1, CronExpr: "0 0 * * *", NextRunAt: &nextRun, Enabled: true}) sched, _ := repo.GetByID(id) sched.Enabled = false err := repo.Update(sched) if err != nil { t.Fatalf("Update failed: %v", err) } retrieved, _ := repo.GetByID(id) if retrieved.Enabled { t.Error("expected enabled to be false after update") } } func TestScheduleRepository_GetEnabledDue(t *testing.T) { db := openTestDB(t) defer db.Close() repo := NewScheduleRepository(db) pastRun := time.Now().Add(-1 * time.Hour) futureRun := time.Now().Add(1 * time.Hour) _, _ = repo.Create(&Schedule{SyncPairID: 1, CronExpr: "0 0 * * *", NextRunAt: &pastRun, Enabled: true}) _, _ = repo.Create(&Schedule{SyncPairID: 2, CronExpr: "0 0 * * *", NextRunAt: &futureRun, Enabled: true}) _, _ = repo.Create(&Schedule{SyncPairID: 3, CronExpr: "0 0 * * *", NextRunAt: &pastRun, Enabled: false}) due, err := repo.GetEnabledDue(time.Now()) if err != nil { t.Fatalf("GetEnabledDue failed: %v", err) } if len(due) != 1 { t.Errorf("expected 1 due schedule, got %d", len(due)) } if due[0].SyncPairID != 1 { t.Errorf("expected sync_pair_id 1, got %d", due[0].SyncPairID) } } func TestMain(m *testing.M) { os.Exit(m.Run()) }