package api import ( "database/sql" "encoding/json" "math" "net/http" "net/http/httptest" "os" "testing" "time" "reanimator/internal/repository" "reanimator/internal/repository/analytics" ) func TestAnalyticsMetrics(t *testing.T) { dsn := os.Getenv("DATABASE_DSN") if dsn == "" { t.Skip("DATABASE_DSN not set") } db, err := repository.Open(dsn) if err != nil { t.Fatalf("open db: %v", err) } defer db.Close() if err := applyMigrations(db); err != nil { t.Fatalf("apply migrations: %v", err) } if err := cleanupRegistry(db); err != nil { t.Fatalf("cleanup: %v", err) } customerID := insertCustomer(t, db, "Acme") projectID := insertProject(t, db, customerID, "Core") assetID := insertAsset(t, db, projectID, "server-01", "ASSET-01") lotA := insertLot(t, db, "LOT-A") lotB := insertLot(t, db, "LOT-B") component1 := insertComponent(t, db, lotA, "COMP-1") component2 := insertComponent(t, db, lotA, "COMP-2") component3 := insertComponent(t, db, lotB, "COMP-3") windowStart := time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC) windowEnd := time.Date(2026, 2, 1, 0, 0, 0, 0, time.UTC) removedOne := time.Date(2026, 1, 15, 0, 0, 0, 0, time.UTC) insertInstallation(t, db, assetID, component1, time.Date(2025, 12, 15, 0, 0, 0, 0, time.UTC), &removedOne) insertInstallation(t, db, assetID, component2, time.Date(2026, 1, 10, 0, 0, 0, 0, time.UTC), nil) removedThree := time.Date(2026, 1, 25, 0, 0, 0, 0, time.UTC) insertInstallation(t, db, assetID, component3, time.Date(2026, 1, 20, 0, 0, 0, 0, time.UTC), &removedThree) insertObservation(t, db, assetID, component1, time.Date(2026, 1, 11, 0, 0, 0, 0, time.UTC), "1.0") insertObservation(t, db, assetID, component2, time.Date(2026, 1, 19, 0, 0, 0, 0, time.UTC), "1.1") insertObservation(t, db, assetID, component2, time.Date(2026, 1, 25, 0, 0, 0, 0, time.UTC), "1.2") insertObservation(t, db, assetID, component3, time.Date(2026, 1, 21, 0, 0, 0, 0, time.UTC), "2.0") insertFailure(t, db, "F1", component1, assetID, time.Date(2026, 1, 12, 0, 0, 0, 0, time.UTC), "hw") insertFailure(t, db, "F2", component2, assetID, time.Date(2026, 1, 20, 0, 0, 0, 0, time.UTC), "hw") insertFailure(t, db, "F3", component3, assetID, time.Date(2026, 1, 22, 0, 0, 0, 0, time.UTC), "hw") mux := http.NewServeMux() RegisterAnalyticsRoutes(mux, AnalyticsDependencies{Analytics: analytics.NewRepository(db)}) server := httptest.NewServer(mux) defer server.Close() t.Run("lot metrics", func(t *testing.T) { resp, err := http.Get(server.URL + "/analytics/lot-metrics?start=" + windowStart.Format(time.RFC3339) + "&end=" + windowEnd.Format(time.RFC3339)) if err != nil { t.Fatalf("request: %v", err) } defer resp.Body.Close() if resp.StatusCode != http.StatusOK { t.Fatalf("expected 200, got %d", resp.StatusCode) } var payload struct { Items []struct { LotCode *string `json:"lot_code"` Failures int64 `json:"failures"` ComponentCount int64 `json:"component_count"` AFR float64 `json:"afr"` MTBFHours *float64 `json:"mtbf_hours"` } `json:"items"` } if err := json.NewDecoder(resp.Body).Decode(&payload); err != nil { t.Fatalf("decode: %v", err) } results := map[string]struct { failures int64 componentCount int64 afr float64 mtbf *float64 }{} for _, item := range payload.Items { if item.LotCode == nil { continue } results[*item.LotCode] = struct { failures int64 componentCount int64 afr float64 mtbf *float64 }{ failures: item.Failures, componentCount: item.ComponentCount, afr: item.AFR, mtbf: item.MTBFHours, } } lotAResult, ok := results["LOT-A"] if !ok { t.Fatalf("missing LOT-A metrics") } if lotAResult.failures != 2 || lotAResult.componentCount != 2 { t.Fatalf("unexpected LOT-A counts: failures=%d components=%d", lotAResult.failures, lotAResult.componentCount) } expectedAfrA := 2 / (864.0 / 8760.0) if math.Abs(lotAResult.afr-expectedAfrA) > 0.1 { t.Fatalf("unexpected LOT-A afr: got %.3f want %.3f", lotAResult.afr, expectedAfrA) } if lotAResult.mtbf == nil || math.Abs(*lotAResult.mtbf-432) > 0.1 { t.Fatalf("unexpected LOT-A mtbf: got %v", lotAResult.mtbf) } lotBResult, ok := results["LOT-B"] if !ok { t.Fatalf("missing LOT-B metrics") } if lotBResult.failures != 1 || lotBResult.componentCount != 1 { t.Fatalf("unexpected LOT-B counts: failures=%d components=%d", lotBResult.failures, lotBResult.componentCount) } expectedAfrB := 1 / (120.0 / 8760.0) if math.Abs(lotBResult.afr-expectedAfrB) > 0.1 { t.Fatalf("unexpected LOT-B afr: got %.3f want %.3f", lotBResult.afr, expectedAfrB) } if lotBResult.mtbf == nil || math.Abs(*lotBResult.mtbf-120) > 0.1 { t.Fatalf("unexpected LOT-B mtbf: got %v", lotBResult.mtbf) } }) t.Run("firmware risk", func(t *testing.T) { resp, err := http.Get(server.URL + "/analytics/firmware-risk?start=" + windowStart.Format(time.RFC3339) + "&end=" + windowEnd.Format(time.RFC3339)) if err != nil { t.Fatalf("request: %v", err) } defer resp.Body.Close() if resp.StatusCode != http.StatusOK { t.Fatalf("expected 200, got %d", resp.StatusCode) } var payload struct { Items []struct { FirmwareVersion string `json:"firmware_version"` Failures int64 `json:"failures"` ComponentCount int64 `json:"component_count"` FailureRate *float64 `json:"failure_rate"` } `json:"items"` } if err := json.NewDecoder(resp.Body).Decode(&payload); err != nil { t.Fatalf("decode: %v", err) } results := map[string]struct { failures int64 componentCount int64 failureRate *float64 }{} for _, item := range payload.Items { results[item.FirmwareVersion] = struct { failures int64 componentCount int64 failureRate *float64 }{ failures: item.Failures, componentCount: item.ComponentCount, failureRate: item.FailureRate, } } fw10 := results["1.0"] if fw10.failures != 1 || fw10.componentCount != 1 || fw10.failureRate == nil || math.Abs(*fw10.failureRate-1.0) > 0.01 { t.Fatalf("unexpected 1.0 risk: %+v", fw10) } fw12 := results["1.2"] if fw12.failures != 0 || fw12.componentCount != 1 || fw12.failureRate == nil || math.Abs(*fw12.failureRate) > 0.01 { t.Fatalf("unexpected 1.2 risk: %+v", fw12) } fw11 := results["1.1"] if fw11.failures != 1 || fw11.componentCount != 0 || fw11.failureRate != nil { t.Fatalf("unexpected 1.1 risk: %+v", fw11) } }) } func insertLot(t *testing.T, db *sql.DB, code string) int64 { t.Helper() result, err := db.Exec(`INSERT INTO lots (code) VALUES (?)`, code) if err != nil { t.Fatalf("insert lot: %v", err) } id, err := result.LastInsertId() if err != nil { t.Fatalf("lot id: %v", err) } return id } func insertComponent(t *testing.T, db *sql.DB, lotID int64, serial string) int64 { t.Helper() result, err := db.Exec(`INSERT INTO components (lot_id, vendor_serial) VALUES (?, ?)`, lotID, serial) if err != nil { t.Fatalf("insert component: %v", err) } id, err := result.LastInsertId() if err != nil { t.Fatalf("component id: %v", err) } return id } func insertInstallation(t *testing.T, db *sql.DB, assetID, componentID int64, installedAt time.Time, removedAt *time.Time) { t.Helper() var removed any if removedAt != nil { removed = *removedAt } _, err := db.Exec( `INSERT INTO installations (asset_id, component_id, installed_at, removed_at) VALUES (?, ?, ?, ?)`, assetID, componentID, installedAt, removed, ) if err != nil { t.Fatalf("insert installation: %v", err) } } func insertLogBundle(t *testing.T, db *sql.DB, assetID int64, collectedAt time.Time, hash string) int64 { t.Helper() result, err := db.Exec( `INSERT INTO log_bundles (asset_id, collected_at, content_hash, payload) VALUES (?, ?, ?, ?)`, assetID, collectedAt, hash, `{}`, ) if err != nil { t.Fatalf("insert log bundle: %v", err) } id, err := result.LastInsertId() if err != nil { t.Fatalf("log bundle id: %v", err) } return id } func insertObservation(t *testing.T, db *sql.DB, assetID, componentID int64, observedAt time.Time, firmware string) { t.Helper() logBundleID := insertLogBundle(t, db, assetID, observedAt, "hash-"+observedAt.Format(time.RFC3339Nano)+"-"+firmware) _, err := db.Exec( `INSERT INTO observations (log_bundle_id, asset_id, component_id, observed_at, firmware_version) VALUES (?, ?, ?, ?, ?)`, logBundleID, assetID, componentID, observedAt, firmware, ) if err != nil { t.Fatalf("insert observation: %v", err) } } func insertFailure(t *testing.T, db *sql.DB, externalID string, componentID, assetID int64, failureTime time.Time, failureType string) { t.Helper() _, err := db.Exec( `INSERT INTO failure_events (source, external_id, component_id, asset_id, failure_type, failure_time) VALUES (?, ?, ?, ?, ?, ?)`, "tests", externalID, componentID, assetID, failureType, failureTime, ) if err != nil { t.Fatalf("insert failure: %v", err) } }