package api import ( "database/sql" "encoding/json" "fmt" "net/http" "net/http/httptest" "os" "testing" "github.com/cubecraft/remoterig/internal/db" "github.com/go-chi/chi/v5" _ "modernc.org/sqlite" ) // openTestDB creates a file-based SQLite database with the schema applied // manually, bypassing the migration splitter in db.Open. func openTestDB(t *testing.T) *db.DB { t.Helper() f, err := os.CreateTemp("", "remoterig-api-test-*.db") if err != nil { t.Fatalf("create temp: %v", err) } path := f.Name() f.Close() t.Cleanup(func() { os.Remove(path) }) sqldb, err := sql.Open("sqlite", path) if err != nil { t.Fatalf("open db: %v", err) } // Apply pragmas if _, err := sqldb.Exec("PRAGMA journal_mode=WAL"); err != nil { t.Fatalf("WAL: %v", err) } if _, err := sqldb.Exec("PRAGMA foreign_keys=ON"); err != nil { t.Fatalf("FK: %v", err) } // Apply schema directly statements := []string{ `CREATE TABLE IF NOT EXISTS cameras (camera_id TEXT PRIMARY KEY, friendly_name TEXT NOT NULL, mac_address TEXT UNIQUE, created_at DATETIME NOT NULL DEFAULT (datetime('now')), updated_at DATETIME NOT NULL DEFAULT (datetime('now')))`, `CREATE TABLE IF NOT EXISTS status_logs (id INTEGER PRIMARY KEY AUTOINCREMENT, camera_id TEXT NOT NULL REFERENCES cameras(camera_id), recorded_at DATETIME NOT NULL DEFAULT (datetime('now')), battery_pct INTEGER, video_remaining_sec INTEGER, recording_state INTEGER NOT NULL DEFAULT 0, mode TEXT, resolution TEXT, fps INTEGER, online INTEGER NOT NULL DEFAULT 1, raw_battery_pct REAL)`, `CREATE TABLE IF NOT EXISTS recording_events (id INTEGER PRIMARY KEY AUTOINCREMENT, camera_id TEXT NOT NULL REFERENCES cameras(camera_id), started_at DATETIME NOT NULL, stopped_at DATETIME, reason TEXT, duration INTEGER)`, `CREATE TABLE IF NOT EXISTS settings (key TEXT PRIMARY KEY, value TEXT NOT NULL, updated_at DATETIME NOT NULL DEFAULT (datetime('now')))`, `CREATE INDEX IF NOT EXISTS idx_cameras_mac ON cameras(mac_address)`, `CREATE INDEX IF NOT EXISTS idx_status_logs_camera_time ON status_logs(camera_id, recorded_at DESC)`, `CREATE INDEX IF NOT EXISTS idx_recording_events_camera_time ON recording_events(camera_id, started_at DESC)`, } for _, stmt := range statements { if _, err := sqldb.Exec(stmt); err != nil { t.Fatalf("exec schema: %v\nstmt: %s", err, stmt[:min(len(stmt), 80)]) } } return &db.DB{DB: sqldb} } func min(a, b int) int { if a < b { return a } return b } // seedTestCamera inserts a camera and optional status logs into the database. func seedTestCamera(t *testing.T, database *db.DB, cameraID, friendlyName string, statusLogs int) { t.Helper() _, err := database.Exec(` INSERT INTO cameras (camera_id, friendly_name, mac_address) VALUES (?, ?, ?) `, cameraID, friendlyName, cameraID+"-mac") if err != nil { t.Fatalf("failed to seed camera: %v", err) } for i := 0; i < statusLogs; i++ { online := 1 if i == 0 { online = 0 } _, err := database.Exec(` INSERT INTO status_logs (camera_id, battery_pct, video_remaining_sec, recording_state, mode, resolution, fps, online, recorded_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, datetime('now', ? || ' minutes')) `, cameraID, 80-i, 3600-i*100, i%2, "video", "4K", 30, online, fmt.Sprintf("-%d", i)) if err != nil { t.Fatalf("failed to seed status_log %d: %v", i, err) } } } func TestGetCameraDetail_NotFound(t *testing.T) { database := openTestDB(t) defer database.Close() handler := GetCameraDetail(database) r := chi.NewRouter() r.Get("/cameras/{id}", handler) req := httptest.NewRequest(http.MethodGet, "/cameras/nonexistent", nil) w := httptest.NewRecorder() r.ServeHTTP(w, req) if w.Code != http.StatusNotFound { t.Errorf("expected status 404, got %d", w.Code) } var resp map[string]string json.NewDecoder(w.Body).Decode(&resp) if resp["error"] != "camera not found" { t.Errorf("expected 'camera not found' error, got %q", resp["error"]) } } func TestGetCameraDetail_Success(t *testing.T) { database := openTestDB(t) defer database.Close() seedTestCamera(t, database, "cam-1", "GoPro Hero", 3) handler := GetCameraDetail(database) r := chi.NewRouter() r.Get("/cameras/{id}", handler) req := httptest.NewRequest(http.MethodGet, "/cameras/cam-1", nil) w := httptest.NewRecorder() r.ServeHTTP(w, req) if w.Code != http.StatusOK { t.Fatalf("expected status 200, got %d, body: %s", w.Code, w.Body.String()) } var resp map[string]interface{} if err := json.NewDecoder(w.Body).Decode(&resp); err != nil { t.Fatalf("failed to decode response: %v", err) } camera, ok := resp["camera"].(map[string]interface{}) if !ok { t.Fatal("response missing 'camera' field") } if camera["camera_id"] != "cam-1" { t.Errorf("expected camera_id 'cam-1', got %v", camera["camera_id"]) } if camera["friendly_name"] != "GoPro Hero" { t.Errorf("expected friendly_name 'GoPro Hero', got %v", camera["friendly_name"]) } history, ok := resp["history"].([]interface{}) if !ok { t.Fatal("response missing 'history' field or wrong type") } if len(history) != 3 { t.Errorf("expected 3 history entries, got %d", len(history)) } } func TestGetCameraDetail_EmptyHistory(t *testing.T) { database := openTestDB(t) defer database.Close() seedTestCamera(t, database, "cam-2", "Cam No Status", 0) handler := GetCameraDetail(database) r := chi.NewRouter() r.Get("/cameras/{id}", handler) req := httptest.NewRequest(http.MethodGet, "/cameras/cam-2", nil) w := httptest.NewRecorder() r.ServeHTTP(w, req) if w.Code != http.StatusOK { t.Fatalf("expected status 200, got %d", w.Code) } var resp map[string]interface{} json.NewDecoder(w.Body).Decode(&resp) history, ok := resp["history"].([]interface{}) if !ok { t.Fatal("response missing 'history' field") } if len(history) != 0 { t.Errorf("expected empty history, got %d entries", len(history)) } } func TestGetCameraDetail_HistoryLimitedTo100(t *testing.T) { database := openTestDB(t) defer database.Close() seedTestCamera(t, database, "cam-3", "Verbose Cam", 105) handler := GetCameraDetail(database) r := chi.NewRouter() r.Get("/cameras/{id}", handler) req := httptest.NewRequest(http.MethodGet, "/cameras/cam-3", nil) w := httptest.NewRecorder() r.ServeHTTP(w, req) if w.Code != http.StatusOK { t.Fatalf("expected status 200, got %d", w.Code) } var resp map[string]interface{} json.NewDecoder(w.Body).Decode(&resp) history, ok := resp["history"].([]interface{}) if !ok { t.Fatal("response missing 'history' field") } if len(history) > 100 { t.Errorf("expected history capped at 100, got %d", len(history)) } } func TestGetCameraDetail_MissingID(t *testing.T) { database := openTestDB(t) defer database.Close() handler := GetCameraDetail(database) // Without a chi router, chi.URLParam returns "", triggering the 400 branch req := httptest.NewRequest(http.MethodGet, "/cameras/", nil) w := httptest.NewRecorder() handler.ServeHTTP(w, req) if w.Code != http.StatusBadRequest { t.Errorf("expected status 400, got %d", w.Code) } } func TestGetCameraDetail_LastStatusPresent(t *testing.T) { database := openTestDB(t) defer database.Close() seedTestCamera(t, database, "cam-4", "Status Cam", 2) handler := GetCameraDetail(database) r := chi.NewRouter() r.Get("/cameras/{id}", handler) req := httptest.NewRequest(http.MethodGet, "/cameras/cam-4", nil) w := httptest.NewRecorder() r.ServeHTTP(w, req) if w.Code != http.StatusOK { t.Fatalf("expected status 200, got %d", w.Code) } var resp map[string]interface{} json.NewDecoder(w.Body).Decode(&resp) lastStatus, ok := resp["last_status"].(map[string]interface{}) if !ok { t.Fatal("response missing 'last_status' field or wrong type") } if lastStatus["camera_id"] != "cam-4" { t.Errorf("expected last_status camera_id 'cam-4', got %v", lastStatus["camera_id"]) } }