Files
core/internal/api/timeline_test.go

337 lines
9.8 KiB
Go

package api
import (
"bytes"
"database/sql"
"encoding/json"
"net/http"
"net/http/httptest"
"net/url"
"os"
"strconv"
"testing"
"time"
"reanimator/internal/history"
"reanimator/internal/ingest"
"reanimator/internal/repository"
"reanimator/internal/repository/registry"
)
func TestTimelineInstalledRemovedFirmwareChanged(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)
}
mux := http.NewServeMux()
historySvc := history.NewService(db)
RegisterIngestRoutes(mux, IngestDependencies{Service: ingest.NewService(db)})
RegisterAssetComponentRoutes(mux, AssetComponentDependencies{
Assets: registry.NewAssetRepository(db),
Components: registry.NewComponentRepository(db),
History: historySvc,
})
server := httptest.NewServer(mux)
defer server.Close()
collectedAt1 := time.Now().UTC().Add(-time.Minute).Format(time.RFC3339)
payload1 := map[string]any{
"target_host": "server-01",
"collected_at": collectedAt1,
"hardware": map[string]any{
"board": map[string]any{"serial_number": "ASSET-01"},
"storage": []map[string]any{
{"serial_number": "VSN-A", "firmware": "1.0.0", "present": true, "status": "OK"},
{"serial_number": "VSN-B", "firmware": "1.0.0", "present": true, "status": "OK"},
},
},
}
postJSON(t, server.URL+"/ingest/hardware", payload1, http.StatusCreated)
collectedAt2 := time.Now().UTC().Format(time.RFC3339)
payload2 := map[string]any{
"target_host": "server-01",
"collected_at": collectedAt2,
"hardware": map[string]any{
"board": map[string]any{"serial_number": "ASSET-01"},
"storage": []map[string]any{
{"serial_number": "VSN-A", "firmware": "2.0.0", "present": true, "status": "OK"},
},
},
}
postJSON(t, server.URL+"/ingest/hardware", payload2, http.StatusCreated)
componentA := lookupComponentID(t, db, "VSN-A")
componentB := lookupComponentID(t, db, "VSN-B")
componentAEvents := fetchTimelineCards(t, server.URL+"/components/"+componentA+"/timeline", 100, nil)
assertVisualActions(t, componentAEvents, []string{"component_installed", "firmware_changed"})
componentBEvents := fetchTimelineCards(t, server.URL+"/components/"+componentB+"/timeline", 100, nil)
assertVisualActions(t, componentBEvents, []string{"component_installed", "component_removed"})
page1 := fetchTimelineResponse(t, server.URL+"/components/"+componentA+"/timeline", 1, nil)
if page1.NextCursor == nil {
t.Fatalf("expected next_cursor")
}
page2 := fetchTimelineResponse(t, server.URL+"/components/"+componentA+"/timeline", 1, page1.NextCursor)
if len(page1.Groups) == 0 || len(page2.Groups) == 0 {
t.Fatalf("expected paginated items")
}
card1 := page1.Groups[0].Cards[0]
card2 := page2.Groups[0].Cards[0]
if card2.CardID == card1.CardID {
t.Fatalf("expected different pages")
}
if card2.TimeSummary.LastEventAt.Before(card1.TimeSummary.LastEventAt) {
t.Fatalf("expected chronological ordering")
}
}
func TestAssetTimelineMovementBulkByIngestSourceRef(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)
}
mux := http.NewServeMux()
historySvc := history.NewService(db)
RegisterIngestRoutes(mux, IngestDependencies{Service: ingest.NewService(db)})
RegisterAssetComponentRoutes(mux, AssetComponentDependencies{
Assets: registry.NewAssetRepository(db),
Components: registry.NewComponentRepository(db),
History: historySvc,
})
server := httptest.NewServer(mux)
defer server.Close()
collectedAt := time.Now().UTC().Format(time.RFC3339)
payload := map[string]any{
"target_host": "server-asset-bulk-01",
"collected_at": collectedAt,
"hardware": map[string]any{
"board": map[string]any{"serial_number": "ASSET-BULK-01"},
"storage": []map[string]any{
{"serial_number": "VSN-BULK-A", "firmware": "1.0.0", "present": true, "status": "OK"},
{"serial_number": "VSN-BULK-B", "firmware": "1.0.0", "present": true, "status": "OK"},
},
},
}
postJSON(t, server.URL+"/ingest/hardware", payload, http.StatusCreated)
assetID := lookupAssetID(t, db, "ASSET-BULK-01")
events := fetchTimelineCards(t, server.URL+"/assets/"+assetID+"/timeline", 100, nil)
var found bool
for _, card := range events {
if card.VisualAction != "component_installed" {
continue
}
if card.Kind == "bulk" && card.Counts.Events >= 2 && card.Counts.AffectedComponents >= 2 {
found = true
break
}
}
if !found {
t.Fatalf("expected bulk component_installed card for asset timeline")
}
}
func TestAssetTimelineFirmwareBulkPerHourSingleCard(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)
}
mux := http.NewServeMux()
historySvc := history.NewService(db)
RegisterIngestRoutes(mux, IngestDependencies{Service: ingest.NewService(db)})
RegisterAssetComponentRoutes(mux, AssetComponentDependencies{
Assets: registry.NewAssetRepository(db),
Components: registry.NewComponentRepository(db),
History: historySvc,
})
server := httptest.NewServer(mux)
defer server.Close()
firstHour := time.Now().UTC().Truncate(time.Hour).Add(-2 * time.Hour)
payload1 := map[string]any{
"target_host": "server-fw-bulk-01",
"collected_at": firstHour.Format(time.RFC3339),
"hardware": map[string]any{
"board": map[string]any{"serial_number": "ASSET-FW-BULK-01"},
"storage": []map[string]any{
{"serial_number": "VSN-FW-A", "firmware": "1.0.0", "present": true, "status": "OK"},
{"serial_number": "VSN-FW-B", "firmware": "1.0.0", "present": true, "status": "OK"},
},
},
}
postJSON(t, server.URL+"/ingest/hardware", payload1, http.StatusCreated)
secondHour := firstHour.Add(40 * time.Minute)
payload2 := map[string]any{
"target_host": "server-fw-bulk-01",
"collected_at": secondHour.Format(time.RFC3339),
"hardware": map[string]any{
"board": map[string]any{"serial_number": "ASSET-FW-BULK-01"},
"storage": []map[string]any{
{"serial_number": "VSN-FW-A", "firmware": "2.0.0", "present": true, "status": "OK"},
{"serial_number": "VSN-FW-B", "firmware": "2.0.0", "present": true, "status": "OK"},
},
},
}
postJSON(t, server.URL+"/ingest/hardware", payload2, http.StatusCreated)
assetID := lookupAssetID(t, db, "ASSET-FW-BULK-01")
events := fetchTimelineCards(t, server.URL+"/assets/"+assetID+"/timeline", 100, nil)
var fwBulkCount int
for _, card := range events {
if card.VisualAction != "firmware_changed" {
continue
}
if card.Kind != "bulk" {
continue
}
if card.Counts.Events >= 2 && card.Counts.AffectedComponents >= 2 {
fwBulkCount++
}
}
if fwBulkCount != 1 {
t.Fatalf("expected exactly one bulk firmware_changed card, got %d", fwBulkCount)
}
}
func postJSON(t *testing.T, url string, payload any, expectedStatus int) {
t.Helper()
body, err := json.Marshal(payload)
if err != nil {
t.Fatalf("marshal payload: %v", err)
}
resp, err := http.Post(url, "application/json", bytes.NewReader(body))
if err != nil {
t.Fatalf("post: %v", err)
}
defer resp.Body.Close()
if resp.StatusCode != expectedStatus {
t.Fatalf("expected %d, got %d", expectedStatus, resp.StatusCode)
}
}
func lookupComponentID(t *testing.T, db *sql.DB, serial string) string {
t.Helper()
var id string
if err := db.QueryRow(`SELECT id FROM parts WHERE vendor_serial = ?`, serial).Scan(&id); err != nil {
t.Fatalf("lookup component: %v", err)
}
return id
}
func lookupAssetID(t *testing.T, db *sql.DB, serial string) string {
t.Helper()
var id string
if err := db.QueryRow(`SELECT id FROM machines WHERE vendor_serial = ?`, serial).Scan(&id); err != nil {
t.Fatalf("lookup asset: %v", err)
}
return id
}
func fetchTimelineCards(t *testing.T, baseURL string, limit int, cursor *string) []history.TimelineCard {
t.Helper()
resp := fetchTimelineResponse(t, baseURL, limit, cursor)
var out []history.TimelineCard
for _, g := range resp.Groups {
out = append(out, g.Cards...)
}
return out
}
type timelineResponse struct {
Groups []struct {
Day string `json:"day"`
Cards []history.TimelineCard `json:"cards"`
} `json:"groups"`
NextCursor *string `json:"next_cursor"`
}
func fetchTimelineResponse(t *testing.T, baseURL string, limit int, cursor *string) timelineResponse {
t.Helper()
query := url.Values{}
if limit > 0 {
query.Set("limit_cards", strconv.Itoa(limit))
}
if cursor != nil {
query.Set("cursor", *cursor)
}
url := baseURL
if encoded := query.Encode(); encoded != "" {
url += "?" + encoded
}
resp, err := http.Get(url)
if err != nil {
t.Fatalf("get timeline: %v", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
t.Fatalf("expected 200, got %d", resp.StatusCode)
}
var payload timelineResponse
if err := json.NewDecoder(resp.Body).Decode(&payload); err != nil {
t.Fatalf("decode response: %v", err)
}
return payload
}
func assertVisualActions(t *testing.T, events []history.TimelineCard, required []string) {
t.Helper()
have := make(map[string]bool)
for _, event := range events {
have[event.VisualAction] = true
}
for _, eventType := range required {
if !have[eventType] {
t.Fatalf("missing event type %s", eventType)
}
}
}