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