generated from CubeCraft-Creations/Tracehound
Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 5100f6be65 |
@@ -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 {
|
||||
|
||||
@@ -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"])
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -3,12 +3,11 @@
|
||||
|
||||
-- Cameras table: registry of all GoPro cameras
|
||||
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'))
|
||||
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 INDEX IF NOT EXISTS idx_cameras_mac ON cameras(mac_address);
|
||||
|
||||
@@ -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;
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -7,12 +7,11 @@ import (
|
||||
|
||||
// Camera represents a registered GoPro camera in the system.
|
||||
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"`
|
||||
CameraID string `json:"camera_id"`
|
||||
FriendlyName string `json:"friendly_name"`
|
||||
MacAddress string `json:"mac_address,omitempty"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
|
||||
// StatusLog records a single status poll from an ESP8266 node.
|
||||
|
||||
Reference in New Issue
Block a user