1 Commits

Author SHA1 Message Date
Hermes 5100f6be65 CUB-235: add tests for GET /api/v1/cameras/:id endpoint
CI/CD / lint-and-typecheck (pull_request) Successful in 9m27s
CI/CD / test (pull_request) Successful in 7s
CI/CD / build (pull_request) Failing after 9s
CI/CD / deploy (pull_request) Has been skipped
- 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)
2026-05-23 04:36:18 +00:00
8 changed files with 328 additions and 49 deletions
+2 -2
View File
@@ -132,10 +132,10 @@ func GetCameraDetail(database *db.DB) http.HandlerFunc {
// Get camera info
var c models.Camera
err := database.QueryRowContext(r.Context(), `
SELECT camera_id, friendly_name, mac_address, battery_calibration_offset, created_at, updated_at
SELECT camera_id, friendly_name, mac_address, created_at, updated_at
FROM cameras WHERE camera_id = ?
`, cameraID).Scan(
&c.CameraID, &c.FriendlyName, &c.MacAddress, &c.BatteryCalibrationOffset,
&c.CameraID, &c.FriendlyName, &c.MacAddress,
&c.CreatedAt, &c.UpdatedAt,
)
if err == sql.ErrNoRows {
+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"])
}
}
-28
View File
@@ -14,9 +14,6 @@ import (
//go:embed migrations/001_create_tables.sql
var migration001 string
//go:embed migrations/002_add_camera_calibration.sql
var migration002 string
// DB wraps the sql.DB with connection-level settings.
type DB struct {
*sql.DB
@@ -65,12 +62,6 @@ func Open(path string) (*DB, error) {
return nil, err
}
log.Println("Migrations complete")
} else {
// Run incremental migrations on existing databases
if err := runIncrementalMigrations(db); err != nil {
db.Close()
return nil, err
}
}
return &DB{db}, nil
@@ -92,25 +83,6 @@ func migrate(db *sql.DB, sql string) error {
return nil
}
// runIncrementalMigrations applies migrations that haven't been run yet on
// an existing database (one where the 001 schema already exists).
func runIncrementalMigrations(db *sql.DB) error {
// Migration 002: add battery_calibration_offset if it doesn't exist
var colCount int
err := db.QueryRow(`SELECT COUNT(*) FROM pragma_table_info('cameras') WHERE name = 'battery_calibration_offset'`).Scan(&colCount)
if err != nil {
return err
}
if colCount == 0 {
log.Println("Running migration 002: add battery_calibration_offset")
if err := migrate(db, migration002); err != nil {
return err
}
}
return nil
}
// splitSQL splits a SQL string on semicolons, respecting quoted strings.
func splitSQL(sql string) []string {
var stmts []string
+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
}
@@ -6,7 +6,6 @@ CREATE TABLE IF NOT EXISTS cameras (
camera_id TEXT PRIMARY KEY,
friendly_name TEXT NOT NULL,
mac_address TEXT UNIQUE,
battery_calibration_offset REAL,
created_at DATETIME NOT NULL DEFAULT (datetime('now')),
updated_at DATETIME NOT NULL DEFAULT (datetime('now'))
);
@@ -1,5 +0,0 @@
-- Migration 002: Add battery_calibration_offset to cameras table
-- This column stores a per-camera calibration value for converting raw battery
-- readings (e.g. GoPro Hero 3 byte at offset 57) into percentage values.
ALTER TABLE cameras ADD COLUMN battery_calibration_offset REAL;
+2 -2
View File
@@ -404,9 +404,9 @@ func extractCameraID(topic string) string {
func getCamera(db *db.DB, cameraID string) (models.Camera, error) {
var cam models.Camera
err := db.QueryRow(
"SELECT camera_id, friendly_name, COALESCE(mac_address,''), COALESCE(battery_calibration_offset, NULL), created_at, updated_at FROM cameras WHERE camera_id = ?",
"SELECT camera_id, friendly_name, COALESCE(mac_address,''), created_at, updated_at FROM cameras WHERE camera_id = ?",
cameraID,
).Scan(&cam.CameraID, &cam.FriendlyName, &cam.MacAddress, &cam.BatteryCalibrationOffset, &cam.CreatedAt, &cam.UpdatedAt)
).Scan(&cam.CameraID, &cam.FriendlyName, &cam.MacAddress, &cam.CreatedAt, &cam.UpdatedAt)
return cam, err
}
-1
View File
@@ -10,7 +10,6 @@ type Camera struct {
CameraID string `json:"camera_id"`
FriendlyName string `json:"friendly_name"`
MacAddress string `json:"mac_address,omitempty"`
BatteryCalibrationOffset *float64 `json:"battery_calibration_offset,omitempty"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}