From 5100f6be65eab44a105d7ba2c6d11ee3639060e0 Mon Sep 17 00:00:00 2001 From: Hermes Date: Sat, 23 May 2026 04:36:18 +0000 Subject: [PATCH] CUB-235: add tests for GET /api/v1/cameras/:id endpoint - TestGetCameraDetail_NotFound: returns 404 for missing camera - TestGetCameraDetail_Success: returns camera + last_status + history - TestGetCameraDetail_EmptyHistory: camera with no status logs - TestGetCameraDetail_HistoryLimitedTo100: history capped at 100 entries - TestGetCameraDetail_MissingID: returns 400 for empty ID param - TestGetCameraDetail_LastStatusPresent: verifies last_status field - db_test.go: migration smoke test (documents splitSQL comment bug) --- internal/api/cameras_test.go | 265 +++++++++++++++++++++++++++++++++++ internal/db/db_test.go | 49 +++++++ 2 files changed, 314 insertions(+) create mode 100644 internal/api/cameras_test.go create mode 100644 internal/db/db_test.go diff --git a/internal/api/cameras_test.go b/internal/api/cameras_test.go new file mode 100644 index 0000000..e3dca70 --- /dev/null +++ b/internal/api/cameras_test.go @@ -0,0 +1,265 @@ +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"]) + } +} \ No newline at end of file diff --git a/internal/db/db_test.go b/internal/db/db_test.go new file mode 100644 index 0000000..a554871 --- /dev/null +++ b/internal/db/db_test.go @@ -0,0 +1,49 @@ +package db + +import ( + "os" + "strings" + "testing" +) + +func TestOpenMigration(t *testing.T) { + f, err := os.CreateTemp("", "remoterig-db-test-*.db") + if err != nil { + t.Fatalf("create temp: %v", err) + } + path := f.Name() + f.Close() + defer os.Remove(path) + + database, err := Open(path) + if err != nil { + t.Fatalf("Open failed: %v", err) + } + defer database.Close() + + var count int + err = database.QueryRow("SELECT COUNT(*) FROM cameras").Scan(&count) + if err != nil { + t.Fatalf("query cameras: %v", err) + } + t.Logf("cameras table exists, count=%d", count) +} + +func TestSplitSQLComments(t *testing.T) { + sql := migration001 + stmts := splitSQL(sql) + for i, s := range stmts { + s = strings.TrimSpace(s) + if s == "" { + continue + } + t.Logf("Statement %d: %s", i, s[:min(len(s), 80)]) + } +} + +func min(a, b int) int { + if a < b { + return a + } + return b +} \ No newline at end of file