Files
core/internal/api/analytics_test.go

282 lines
8.9 KiB
Go

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)
}
}