282 lines
8.9 KiB
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)
|
|
}
|
|
}
|