2 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
overseer 1a8f67a392 Merge pull request 'feat: add v3 hardware case and update hub network' (#6) from agent/hermes/remoterig-hardware-v3-network into dev
Build (Dev) / build (push) Failing after 9s
CI/CD / lint-and-typecheck (push) Successful in 9m29s
CI/CD / test (push) Successful in 9m27s
CI/CD / build (push) Failing after 4m50s
CI/CD / deploy (push) Has been skipped
Reviewed-on: #6
2026-05-22 19:43:40 -04:00
8 changed files with 375 additions and 105 deletions
+8 -11
View File
@@ -1,7 +1,7 @@
# RemoteRig — Camera Node Hardware Design
> **Version:** 0.2.0 | **Status:** Draft
> **Target:** GoPro Hero 3 Black/Silver + ESP-01S/ESP8266 + ESP32-C3 Super Mini + USB power bank
> **Target:** GoPro Hero 3 Black/Silver + ESP8266 + ESP32 + USB power bank
## Overview
@@ -29,8 +29,8 @@ Each camera node is two ESP boards in a small case that clips to the tripod/stan
| Item | Qty | Cost | Notes |
|------|-----|------|-------|
| ESP32-C3 Super Mini | 1 | ~$5 | MQTT bridge — talks to hub; board footprint 22.5 × 18.0mm |
| ESP-01S / ESP8266 | 1 | ~$3 | Camera bridge — talks to GoPro; module envelope 24.7 × 14.3 × 12.0mm |
| ESP32 Dev Board | 1 | ~$5 | MQTT bridge — talks to hub |
| ESP8266 D1 Mini | 1 | ~$3 | Camera bridge — talks to GoPro |
| USB power bank (5000mAh+) | 1 | ~$10 | Powers both boards + GoPro |
| Micro-USB cable (short) | 2 | ~$2 | Power bank → boards + GoPro |
| Jumper wires F-F | 3 | ~$0.25 | UART TX/RX/GND between boards |
@@ -45,8 +45,8 @@ Each camera node is two ESP boards in a small case that clips to the tripod/stan
**Pipeline:** `hardware/DESIGN_PIPELINE.md`
Four exported prototype files:
1. **Case body** — holds both boards side-by-side with extra wiring/service clearance, cable ports, rear mounting boss
2. **Case lid** — screw-on cover with ventilation and underside locating lip for flush seating
1. **Case body** — holds both boards stacked, cable ports, rear dovetail-style receiver
2. **Case lid** — screw-on cover with ventilation
3. **Tripod clamp** — separate screw-tightened C-clamp sized around a 35mm stand/pole
4. **Full preview** — combined visualization STL only, not intended as the print job
@@ -105,11 +105,8 @@ The ESP8266 and GoPro talk over Wi-Fi — **no data cable between them**. The on
| | W × D × H (mm) |
|---|---|
| Board envelope basis | ESP32-C3 Super Mini: 22.5 × 18.0; ESP-01S: 24.7 × 14.3 × 12.0 |
| Internal CAD allowance | ~71.2 × 34.0 × 22.0; intentionally includes wiring gutters and vertical connector clearance |
| Case body external | ~76.0 × 42.6 × 26.0 including rear mount boss depth; main shell ~76.0 × 38.8 × 26.0 |
| Lid external | ~76.0 × 38.8 × 3.6; includes 1.6mm underside locating lip |
| Tripod clamp | ~43.0 × 53.5 × 16.0 |
| Clamp-to-case mount | Two side-by-side M3 screws through flat mounting plate |
| Case body external | ~56.8 × 38.2 × 19.0 |
| Lid external | ~56.8 × 32.8 × 4.0 |
| Tripod clamp | ~43.0 × 56.9 × 16.0 |
| Clamp pole fit | Nominal 35mm; smaller poles TBD / may need inserts |
| Total weight | TBD after prototype print |
Binary file not shown.
Binary file not shown.
Binary file not shown.
+53 -94
View File
@@ -1,39 +1,26 @@
// RemoteRig — Dual-ESP Tripod Case v3
// v3e changes: board-specific envelope for ESP32-C3 Super Mini + ESP-01S with wiring clearance.
// v3 changes: screw-tightened tripod clamp + dovetail slide interface.
// Coordinate system: all case/lid geometry uses bottom-origin Z.
$fn = 36;
// Board dimensions from selected modules.
// ESP32-C3 Super Mini: 22.5 x 18.0 mm footprint.
// ESP-01S / ESP8266: 24.7 x 14.3 x 12.0 mm envelope.
// The case is intentionally larger than board footprints because the field
// build needs room for Dupont/UART wiring, power leads, bend radius, and fingers.
esp32c3_w = 22.5; esp32c3_d = 18.0; esp32c3_h = 6.0; // height allowance includes headers/pins TBD
esp01s_w = 24.7; esp01s_d = 14.3; esp01s_h = 12.0;
board_gap = 8.0; // side-by-side service gap between boards
wire_x = 8.0; // wiring gutter at left/right ends
wire_y = 8.0; // wiring gutter along front/back edges
wire_z = 10.0; // vertical wiring/connector clearance above tallest module
inner_w = esp32c3_w + esp01s_w + board_gap + wire_x*2;
inner_d = max(esp32c3_d, esp01s_d) + wire_y*2;
inner_h = max(esp32c3_h, esp01s_h) + wire_z;
// Board dimensions
esp8266_w = 34.2; esp8266_d = 25.6; esp8266_h = 5;
esp32_w = 52; esp32_d = 28; esp32_h = 5;
board_gap = 3;
stack_h = esp8266_h + esp32_h + board_gap;
inner_w = max(esp8266_w, esp32_w);
inner_d = max(esp8266_d, esp32_d);
inner_h = stack_h + 2;
// Case parameters
wall = 2.0;
tol = 0.4;
outer_w = inner_w + wall*2 + tol*2; // 76.0mm with current board/wiring envelope
outer_d = inner_d + wall*2 + tol*2; // 38.8mm with current board/wiring envelope
outer_h = inner_h + wall*2; // 26.0mm with current board/wiring envelope
outer_w = inner_w + wall*2 + tol*2; // 56.8mm
outer_d = inner_d + wall*2 + tol*2; // 32.8mm
outer_h = inner_h + wall*2; // 19mm
corner_r = 2.5;
// Lid fit parameters
lid_top_thick = 2.0;
lid_lip_h = 1.6;
lid_clearance = 0.6; // clearance around underside locating lip
lid_lip_wall = 1.2; // thickness of perimeter lip/frame
// Tripod clamp parameters
pole_dia = 35; // nominal stand/pole diameter
clamp_thick = 4.0; // ring wall thickness
@@ -42,16 +29,15 @@ mouth_width = 13.0; // clamp opening
m3_clearance = 3.4; // M3 screw clearance
nut_flat = 6.4; // M3 nut trap flat-to-flat
// Case ↔ clamp interface
// v3b removes the dovetail: use a flat two-screw mounting plate instead.
// This is simpler to print, easier to inspect, and field-serviceable.
mount_plate_w = 24.0;
mount_plate_h = 16.0;
mount_plate_thick = 4.0;
mount_hole_spacing = 14.0; // side-by-side M3 case-mount screws
mount_screw_clear = 3.4; // M3 clearance through clamp plate
mount_case_pilot = 2.7; // pilot/insert hole through case boss
mount_boss_r = 1.2;
// Dovetail slide interface
// Male rail is on the case; matching female socket is on the tripod clamp.
// This is easier to inspect and avoids the previous mismatched "two lips + tab" geometry.
rail_z = outer_h * 0.78;
rail_depth = 5.0;
rail_neck_w = 12.0; // narrow width at case wall / slot opening
rail_outer_w = 18.0; // wider retained edge
rail_clearance = 0.45; // FDM sliding clearance per side-ish
socket_wall = 2.2;
// Cable ports
usb_port_w = 12; usb_port_h = 6;
@@ -121,20 +107,15 @@ module screw_post(x, y) {
}
}
module case_mount_boss() {
// Flat rear boss on the case. The clamp plate bolts directly to this face.
// Holes run front/back (Y axis) for M3 screws, heat-set inserts, or nuts.
boss_y = outer_d/2 + mount_plate_thick/2 - 0.2;
difference() {
translate([0, boss_y, outer_h/2])
rounded_cube_centered(mount_plate_w, mount_plate_thick, mount_plate_h, mount_boss_r);
module case_male_dovetail_rail() {
// Positive tapered rail on the case back. Cross-section is narrow at the
// wall and wider at the outside, so the clamp socket captures it.
translate([0, outer_d/2 - 0.15, outer_h/2])
dovetail_prism(rail_z, rail_neck_w, rail_outer_w, rail_depth);
for (xoff = [-mount_hole_spacing/2, mount_hole_spacing/2]) {
translate([xoff, outer_d/2 + mount_plate_thick/2, outer_h/2])
rotate([90, 0, 0])
cylinder(d=mount_case_pilot, h=mount_plate_thick + wall*3, center=true, $fn=24);
}
}
// Bottom stop so the clamp socket cannot slide past the case.
translate([0, outer_d/2 + rail_depth/2, outer_h*0.12])
rounded_cube_centered(rail_outer_w + 3.0, rail_depth + 0.8, 2.4, 0.8);
}
module case_body() {
@@ -142,48 +123,22 @@ module case_body() {
case_shell();
for (x = [-1, 1], y = [-1, 1])
screw_post(x*(outer_w/2 - 5), y*(outer_d/2 - 5));
case_mount_boss();
case_male_dovetail_rail();
}
}
module lid_locating_lip() {
// Thin underside frame that drops into the case opening. This registers the
// lid so it cannot skate/rock on the rim, while staying clear of screw posts.
lip_outer_w = inner_w + tol - lid_clearance;
lip_outer_d = inner_d + tol - lid_clearance;
lip_inner_w = lip_outer_w - lid_lip_wall*2;
lip_inner_d = lip_outer_d - lid_lip_wall*2;
translate([0, 0, -lid_lip_h])
difference() {
rounded_cube0(lip_outer_w, lip_outer_d, lid_lip_h, 1.0);
translate([0, 0, -0.1])
rounded_cube0(lip_inner_w, lip_inner_d, lid_lip_h + 0.2, 0.7);
// Corner reliefs so the lip doesn't interfere with screw posts.
for (x = [-1, 1], y = [-1, 1]) {
translate([x*(outer_w/2 - 5), y*(outer_d/2 - 5), lid_lip_h/2])
cylinder(d=7.0, h=lid_lip_h + 0.4, center=true, $fn=24);
}
}
}
module case_lid() {
difference() {
union() {
// Thinner top cover; underside lip handles registration.
rounded_cube0(outer_w, outer_d, lid_top_thick, 0.8);
lid_locating_lip();
}
rounded_cube0(outer_w, outer_d, wall*2, 1.8);
for (x = [-1, 1], y = [-1, 1]) {
translate([x*(outer_w/2 - 5), y*(outer_d/2 - 5), -lid_lip_h - 0.5])
cylinder(d=2.4, h=lid_top_thick + lid_lip_h + 1, center=false, $fn=20);
translate([x*(outer_w/2 - 5), y*(outer_d/2 - 5), -0.5])
cylinder(d=2.4, h=wall*2 + 1, center=false, $fn=20);
}
for (x = [-outer_w/4, 0, outer_w/4]) {
translate([x, 0, lid_top_thick/2])
cube([8, outer_d*0.6, lid_top_thick*3], center=true);
translate([x, 0, wall*2/2])
cube([8, outer_d*0.6, wall*3], center=true);
}
}
}
@@ -192,7 +147,7 @@ module clamp_ring_with_mouth() {
outer_r = pole_dia/2 + clamp_thick;
difference() {
cylinder(r=outer_r, h=clamp_width, center=true, $fn=72);
cylinder(r=pole_dia/2 + tol, h=clamp_width + 1, center=true, $fn=72);
cylinder(r=pole_dia/2 + rail_clearance, h=clamp_width + 1, center=true, $fn=72);
// Mouth opens toward +Y. Width is intentionally generous for snap-on placement before tightening.
translate([0, outer_r, 0])
cube([mouth_width, outer_r*2, clamp_width + 2], center=true);
@@ -219,21 +174,25 @@ module clamp_ears() {
}
}
module clamp_mount_plate() {
module clamp_dovetail_socket() {
outer_r = pole_dia/2 + clamp_thick;
plate_y = -outer_r - mount_plate_thick/2 + 0.2;
socket_outer_w = rail_outer_w + socket_wall*2;
socket_depth = rail_depth + socket_wall*2;
// Flat plate matching the case boss. Two M3 clearance holes pass through
// along Y so the clamp bolts to the case with ordinary hardware.
// Solid boss on the rear of the clamp, opposite the tightening mouth.
// A matching dovetail void is cut through it along Z so the case rail
// slides in from the top/bottom with practical FDM clearance.
difference() {
translate([0, plate_y, 0])
rounded_cube_centered(mount_plate_w, mount_plate_thick, mount_plate_h, mount_boss_r);
translate([0, -outer_r - socket_depth/2 + socket_wall, 0])
rounded_cube_centered(socket_outer_w, socket_depth, clamp_width, 1.2);
for (xoff = [-mount_hole_spacing/2, mount_hole_spacing/2]) {
translate([xoff, plate_y, 0])
rotate([90, 0, 0])
cylinder(d=mount_screw_clear, h=mount_plate_thick + 2, center=true, $fn=24);
}
translate([0, -outer_r - 0.15, 0])
dovetail_prism(
clamp_width + 1.0,
rail_neck_w + rail_clearance,
rail_outer_w + rail_clearance,
rail_depth + 0.6
);
}
}
@@ -241,7 +200,7 @@ module tripod_clamp() {
union() {
clamp_ring_with_mouth();
clamp_ears();
clamp_mount_plate();
clamp_dovetail_socket();
}
}
@@ -252,7 +211,7 @@ module tripod_clip() {
module full_case() {
case_body();
translate([0, 0, outer_h]) case_lid();
translate([0, 0, outer_h + 2]) case_lid();
translate([0, outer_d/2 + pole_dia/2 + clamp_thick + 8, outer_h/2])
rotate([90, 0, 0]) tripod_clamp();
}
Binary file not shown.
+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
}