CUB-235: Add tests for GET /api/v1/cameras/:id with 24h history #9

Closed
Dex wants to merge 1 commits from agent/dex/CUB-235-camera-detail into dev
2 changed files with 314 additions and 0 deletions
+265
View File
@@ -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"])
}
}
+49
View File
@@ -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
}