generated from CubeCraft-Creations/Tracehound
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)
This commit is contained in:
@@ -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"])
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user