ilpcw/drone-black-box/main_test.go

213 lines
5.9 KiB
Go

package main
import (
"bytes"
"context"
"database/sql"
"encoding/json"
"net/http"
"net/http/httptest"
"net/url"
"strings"
"testing"
"time"
_ "modernc.org/sqlite"
)
// create an in-memory DB with schema/index identical to main
func newTestDB(t *testing.T) *sql.DB {
t.Helper()
db, err := sql.Open("sqlite", "file:testdb?mode=memory&cache=shared")
if err != nil {
t.Fatalf("open db: %v", err)
}
schema := `CREATE TABLE IF NOT EXISTS drone_events (
id INTEGER PRIMARY KEY AUTOINCREMENT,
drone_id TEXT NOT NULL,
latitude REAL NOT NULL,
longitude REAL NOT NULL,
timestamp TEXT NOT NULL
);`
if _, err := db.Exec(schema); err != nil {
t.Fatalf("schema: %v", err)
}
if _, err := db.Exec(`CREATE INDEX IF NOT EXISTS idx_drone_timestamp ON drone_events(drone_id, timestamp);`); err != nil {
t.Fatalf("index: %v", err)
}
return db
}
func TestIngestAndSnapshot(t *testing.T) {
db := newTestDB(t)
srv := &Server{db: db}
// ingest two drones with multiple timestamps
events := []DroneEvent{
{"d1", 1.0, 2.0, "2025-12-06T00:00:00Z"},
{"d1", 1.1, 2.1, "2025-12-06T00:00:10Z"},
{"d2", 3.0, 4.0, "2025-12-06T00:00:05Z"},
}
for _, ev := range events {
body, _ := json.Marshal(ev)
req := httptest.NewRequest(http.MethodPost, "/ingest", bytes.NewReader(body))
rec := httptest.NewRecorder()
srv.ingestHandler(rec, req)
if rec.Code != http.StatusCreated {
t.Fatalf("ingest %v code=%d body=%s", ev, rec.Code, rec.Body.String())
}
}
// snapshot at 00:00:07 should pick d1@00:00:00? no 00:00:10 too new -> d1@0, d2@5
q := url.Values{}
q.Set("time", "2025-12-06T00:00:07Z")
req := httptest.NewRequest(http.MethodGet, "/snapshot?"+q.Encode(), nil)
rec := httptest.NewRecorder()
srv.snapshotHandler(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("snapshot code=%d body=%s", rec.Code, rec.Body.String())
}
var resp []DroneEvent
if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil {
t.Fatalf("decode snapshot: %v", err)
}
if len(resp) != 2 {
t.Fatalf("expected 2 events, got %d", len(resp))
}
// map by drone
got := map[string]DroneEvent{}
for _, e := range resp {
got[e.DroneID] = e
}
if got["d1"].Timestamp != "2025-12-06T00:00:00Z" {
t.Fatalf("d1 timestamp mismatch: %v", got["d1"])
}
if got["d2"].Timestamp != "2025-12-06T00:00:05Z" {
t.Fatalf("d2 timestamp mismatch: %v", got["d2"])
}
}
func TestSnapshotMissingTime(t *testing.T) {
db := newTestDB(t)
srv := &Server{db: db}
req := httptest.NewRequest(http.MethodGet, "/snapshot", nil)
rec := httptest.NewRecorder()
srv.snapshotHandler(rec, req)
if rec.Code != http.StatusBadRequest {
t.Fatalf("expected 400, got %d", rec.Code)
}
}
func TestHealth(t *testing.T) {
db := newTestDB(t)
srv := &Server{db: db}
req := httptest.NewRequest(http.MethodGet, "/health", nil)
rec := httptest.NewRecorder()
srv.healthHandler(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("health code=%d body=%s", rec.Code, rec.Body.String())
}
}
func TestIngestBadJSON(t *testing.T) {
db := newTestDB(t)
srv := &Server{db: db}
req := httptest.NewRequest(http.MethodPost, "/ingest", strings.NewReader("{bad"))
rec := httptest.NewRecorder()
srv.ingestHandler(rec, req)
if rec.Code != http.StatusBadRequest {
t.Fatalf("expected 400, got %d", rec.Code)
}
}
// Ensure graceful shutdown path does not hang: start a server and shut it down quickly.
func TestGracefulShutdown(t *testing.T) {
db := newTestDB(t)
defer db.Close()
srv := &Server{db: db}
mux := http.NewServeMux()
mux.HandleFunc("GET /health", srv.healthHandler)
testSrv := &http.Server{Addr: "127.0.0.1:0", Handler: mux}
go func() { _ = testSrv.ListenAndServe() }()
ctx, cancel := context.WithTimeout(context.Background(), time.Second)
defer cancel()
if err := testSrv.Shutdown(ctx); err != nil {
t.Fatalf("shutdown: %v", err)
}
}
func TestCORSAllowedOriginAndPreflight(t *testing.T) {
handled := false
next := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
handled = true
w.WriteHeader(http.StatusOK)
})
handler := corsMiddleware(next)
req := httptest.NewRequest(http.MethodGet, "/health", nil)
req.Header.Set("Origin", "http://localhost:5173")
rec := httptest.NewRecorder()
handler.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("expected 200, got %d", rec.Code)
}
if got := rec.Header().Get("Access-Control-Allow-Origin"); got != "http://localhost:5173" {
t.Fatalf("expected allow-origin header, got %q", got)
}
if !handled {
t.Fatalf("expected handler to be called")
}
preflight := httptest.NewRequest(http.MethodOptions, "/health", nil)
preflight.Header.Set("Origin", "http://localhost:5173")
preflightRec := httptest.NewRecorder()
handler.ServeHTTP(preflightRec, preflight)
if preflightRec.Code != http.StatusNoContent {
t.Fatalf("preflight expected 204, got %d", preflightRec.Code)
}
}
func TestIngestDBError(t *testing.T) {
db := newTestDB(t)
db.Close()
srv := &Server{db: db}
ev := DroneEvent{DroneID: "d1", Latitude: 1.0, Longitude: 2.0, Timestamp: "2025-12-06T00:00:00Z"}
body, _ := json.Marshal(ev)
req := httptest.NewRequest(http.MethodPost, "/ingest", bytes.NewReader(body))
rec := httptest.NewRecorder()
srv.ingestHandler(rec, req)
if rec.Code != http.StatusInternalServerError {
t.Fatalf("expected 500, got %d", rec.Code)
}
}
func TestSnapshotQueryError(t *testing.T) {
db := newTestDB(t)
db.Close()
srv := &Server{db: db}
req := httptest.NewRequest(http.MethodGet, "/snapshot?time=2025-12-06T00:00:00Z", nil)
rec := httptest.NewRecorder()
srv.snapshotHandler(rec, req)
if rec.Code != http.StatusInternalServerError {
t.Fatalf("expected 500, got %d", rec.Code)
}
}
func TestHealthFailure(t *testing.T) {
db := newTestDB(t)
db.Close()
srv := &Server{db: db}
req := httptest.NewRequest(http.MethodGet, "/health", nil)
rec := httptest.NewRecorder()
srv.healthHandler(rec, req)
if rec.Code != http.StatusServiceUnavailable {
t.Fatalf("expected 503, got %d", rec.Code)
}
}