package api import ( "bytes" "encoding/json" "io" "net/http" "net/http/httptest" "os" "testing" "time" "reanimator/internal/ingest" "reanimator/internal/repository" ) func TestIngestHardwareIdempotent(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() RegisterIngestRoutes(mux, IngestDependencies{Service: ingest.NewService(db)}) server := httptest.NewServer(mux) defer server.Close() payload := map[string]any{ "target_host": "hwserver-1", "collected_at": time.Now().UTC().Format(time.RFC3339), "hardware": map[string]any{ "board": map[string]any{"serial_number": "BOARD123"}, "cpus": []map[string]any{{"socket": 0, "status": "OK"}}, }, } body, err := json.Marshal(payload) if err != nil { t.Fatalf("marshal payload: %v", err) } resp, err := http.Post(server.URL+"/ingest/hardware", "application/json", bytes.NewReader(body)) if err != nil { t.Fatalf("post: %v", err) } defer resp.Body.Close() if resp.StatusCode != http.StatusCreated { t.Fatalf("expected 201, got %d", resp.StatusCode) } var createdResp struct { Summary struct { TimelineEventsCreated int `json:"timeline_events_created"` } `json:"summary"` } createdBody, err := io.ReadAll(resp.Body) if err != nil { t.Fatalf("read body: %v", err) } if err := json.Unmarshal(createdBody, &createdResp); err != nil { t.Fatalf("decode body: %v", err) } if createdResp.Summary.TimelineEventsCreated != 3 { t.Fatalf("expected timeline_events_created=3, got %d", createdResp.Summary.TimelineEventsCreated) } resp, err = http.Post(server.URL+"/ingest/hardware", "application/json", bytes.NewReader(body)) if err != nil { t.Fatalf("post duplicate: %v", err) } defer resp.Body.Close() if resp.StatusCode != http.StatusOK { t.Fatalf("expected 200 on duplicate, got %d", resp.StatusCode) } assertCount(t, db, "log_bundles", 1) assertCount(t, db, "observations", 1) assertCount(t, db, "installations", 1) assertCount(t, db, "components", 1) } func TestIngestHardwareStatusEvents(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() RegisterIngestRoutes(mux, IngestDependencies{Service: ingest.NewService(db)}) server := httptest.NewServer(mux) defer server.Close() payload := map[string]any{ "target_host": "status-server", "collected_at": time.Now().UTC().Format(time.RFC3339), "hardware": map[string]any{ "board": map[string]any{"serial_number": "STATUS-123"}, "storage": []map[string]any{ { "slot": "Storage-0", "serial_number": "WARN-001", "present": true, "status": "Warning", }, { "slot": "Storage-1", "serial_number": "CRIT-001", "present": true, "status": "Critical", }, }, }, } body, err := json.Marshal(payload) if err != nil { t.Fatalf("marshal payload: %v", err) } resp, err := http.Post(server.URL+"/ingest/hardware", "application/json", bytes.NewReader(body)) if err != nil { t.Fatalf("post: %v", err) } defer resp.Body.Close() if resp.StatusCode != http.StatusCreated { t.Fatalf("expected 201, got %d", resp.StatusCode) } assertCountQuery(t, db, "SELECT COUNT(*) FROM failure_events", 1) assertCountQuery(t, db, "SELECT COUNT(*) FROM timeline_events WHERE event_type = 'COMPONENT_WARNING'", 2) assertCountQuery(t, db, "SELECT COUNT(*) FROM timeline_events WHERE event_type = 'COMPONENT_FAILED'", 2) } func TestIngestHardwareFirmwareChange(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() RegisterIngestRoutes(mux, IngestDependencies{Service: ingest.NewService(db)}) server := httptest.NewServer(mux) defer server.Close() boardSerial := "FIRMWARE-123" firstPayload := map[string]any{ "target_host": "firmware-server", "collected_at": time.Now().UTC().Format(time.RFC3339), "hardware": map[string]any{ "board": map[string]any{"serial_number": boardSerial}, "firmware": []map[string]any{ {"device_name": "BIOS", "version": "1.0.0"}, }, }, } secondPayload := map[string]any{ "target_host": "firmware-server", "collected_at": time.Now().UTC().Add(5 * time.Minute).Format(time.RFC3339), "hardware": map[string]any{ "board": map[string]any{"serial_number": boardSerial}, "firmware": []map[string]any{ {"device_name": "BIOS", "version": "1.1.0"}, }, }, } for _, payload := range []map[string]any{firstPayload, secondPayload} { body, err := json.Marshal(payload) if err != nil { t.Fatalf("marshal payload: %v", err) } resp, err := http.Post(server.URL+"/ingest/hardware", "application/json", bytes.NewReader(body)) if err != nil { t.Fatalf("post: %v", err) } resp.Body.Close() if resp.StatusCode != http.StatusCreated { t.Fatalf("expected 201, got %d", resp.StatusCode) } } var version string row := db.QueryRow(` SELECT firmware_version FROM asset_firmware_states WHERE asset_id = (SELECT id FROM assets WHERE vendor_serial = ? LIMIT 1) AND device_name = ? `, boardSerial, "BIOS") if err := row.Scan(&version); err != nil { t.Fatalf("firmware state query: %v", err) } if version != "1.1.0" { t.Fatalf("expected firmware_version 1.1.0, got %s", version) } assertCountQuery(t, db, "SELECT COUNT(*) FROM timeline_events WHERE subject_type = 'asset' AND event_type = 'FIRMWARE_CHANGED'", 2) }