package api import ( "bytes" "database/sql" "encoding/json" "net/http" "net/http/httptest" "strings" "testing" "time" "reanimator/internal/domain" "reanimator/internal/history" "reanimator/internal/repository/failures" "reanimator/internal/repository/registry" ) func TestFailuresPostRegistersManualFailureAndHistoryStatus(t *testing.T) { db := openTestDB(t) defer db.Close() historySvc := history.NewService(db) mux := http.NewServeMux() assetRepo := registry.NewAssetRepository(db) componentRepo := registry.NewComponentRepository(db) installRepo := registry.NewInstallationRepository(db) failureRepo := failures.NewFailureRepository(db) RegisterRegistryRoutes(mux, RegistryDependencies{Assets: assetRepo, Components: componentRepo, History: historySvc}) RegisterHistoryRoutes(mux, HistoryDependencies{Service: historySvc}) RegisterFailureRoutes(mux, FailureDependencies{ Failures: failureRepo, Assets: assetRepo, Components: componentRepo, Installations: installRepo, History: historySvc, }) server := httptest.NewServer(mux) defer server.Close() component := createComponentViaAPI(t, server.URL) asset := createAssetViaAPI(t, server.URL) insertOpenInstallation(t, db, asset.ID, component.ID, "AOC#1") payload := map[string]any{ "component_serial": component.VendorSerial, "server_serial": asset.VendorSerial, "failure_date": "2026-02-23", "description": "manual test failure", } body := postJSONWithResponse(t, server.URL+"/failures", payload, http.StatusOK) var resp struct { Status string `json:"status"` FailureEventID string `json:"failure_event_id"` HistoryEventID *string `json:"history_event_id"` } if err := json.Unmarshal(body, &resp); err != nil { t.Fatalf("decode response: %v", err) } if resp.Status != "registered" { t.Fatalf("unexpected status: %q", resp.Status) } if resp.FailureEventID == "" { t.Fatalf("missing failure_event_id") } if resp.HistoryEventID == nil || *resp.HistoryEventID == "" { t.Fatalf("missing history_event_id") } var source, failureType string if err := db.QueryRow(`SELECT source, failure_type FROM failure_events WHERE id = ?`, resp.FailureEventID).Scan(&source, &failureType); err != nil { t.Fatalf("query failure event: %v", err) } if source != "manual_ui" { t.Fatalf("expected source manual_ui, got %q", source) } if failureType != "component_failed_manual" { t.Fatalf("expected manual failure type, got %q", failureType) } var historyCount int if err := db.QueryRow(`SELECT COUNT(*) FROM component_change_events WHERE id = ? AND part_id = ? AND change_type = 'COMPONENT_STATUS_SET'`, *resp.HistoryEventID, component.ID).Scan(&historyCount); err != nil { t.Fatalf("query component_change_events: %v", err) } if historyCount != 1 { t.Fatalf("expected history status event count=1, got %d", historyCount) } } func TestFailuresPostRejectsServerOnlyInput(t *testing.T) { db := openTestDB(t) defer db.Close() historySvc := history.NewService(db) mux := http.NewServeMux() RegisterFailureRoutes(mux, FailureDependencies{ Failures: failures.NewFailureRepository(db), Assets: registry.NewAssetRepository(db), Components: registry.NewComponentRepository(db), History: historySvc, }) server := httptest.NewServer(mux) defer server.Close() payload := map[string]any{ "server_serial": "SRV-ONLY-1", "failure_date": "2026-02-23", "description": "server only", } body, status := postJSONRaw(t, server.URL+"/failures", payload) if status != http.StatusBadRequest { t.Fatalf("expected 400, got %d body=%s", status, string(body)) } } func TestFailuresPostRejectsMismatchedServerSerialForInstalledComponent(t *testing.T) { db := openTestDB(t) defer db.Close() historySvc := history.NewService(db) mux := http.NewServeMux() assetRepo := registry.NewAssetRepository(db) componentRepo := registry.NewComponentRepository(db) installRepo := registry.NewInstallationRepository(db) failureRepo := failures.NewFailureRepository(db) RegisterRegistryRoutes(mux, RegistryDependencies{Assets: assetRepo, Components: componentRepo, History: historySvc}) RegisterHistoryRoutes(mux, HistoryDependencies{Service: historySvc}) RegisterFailureRoutes(mux, FailureDependencies{ Failures: failureRepo, Assets: assetRepo, Components: componentRepo, Installations: installRepo, History: historySvc, }) server := httptest.NewServer(mux) defer server.Close() component := createComponentViaAPI(t, server.URL) assetA := createAssetViaAPI(t, server.URL) assetB := createAssetViaAPI(t, server.URL) insertOpenInstallation(t, db, assetA.ID, component.ID, "DRV#1") body, status := postJSONRaw(t, server.URL+"/failures", map[string]any{ "component_serial": component.VendorSerial, "server_serial": assetB.VendorSerial, "failure_date": "2026-02-23", "description": "mismatch", }) if status != http.StatusConflict { t.Fatalf("expected 409, got %d body=%s", status, string(body)) } } func TestUIFailuresPageRendersActiveAndChronologySections(t *testing.T) { db := openTestDB(t) defer db.Close() historySvc := history.NewService(db) mux := http.NewServeMux() assetRepo := registry.NewAssetRepository(db) componentRepo := registry.NewComponentRepository(db) installRepo := registry.NewInstallationRepository(db) failureRepo := failures.NewFailureRepository(db) RegisterUIRoutes(mux, UIDependencies{ Assets: assetRepo, Components: componentRepo, Installations: installRepo, Failures: failureRepo, }) RegisterFailureRoutes(mux, FailureDependencies{ Failures: failureRepo, Assets: assetRepo, Components: componentRepo, Installations: installRepo, History: historySvc, }) server := httptest.NewServer(mux) defer server.Close() resp, err := http.Get(server.URL + "/ui/failures") if err != nil { t.Fatalf("GET /ui/failures: %v", err) } defer resp.Body.Close() if resp.StatusCode != http.StatusOK { t.Fatalf("expected 200, got %d", resp.StatusCode) } body := string(mustReadAll(t, resp)) for _, needle := range []string{"Active Failures", "Failure Chronology", "Register Failure"} { if !strings.Contains(body, needle) { t.Fatalf("expected page to contain %q", needle) } } } func createAssetViaAPI(t *testing.T, baseURL string) domain.Asset { t.Helper() payload := map[string]any{ "name": "srv-" + time.Now().UTC().Format("150405.000000000"), "vendor_serial": "SRV-" + time.Now().UTC().Format("150405.000000000"), } respBody := postJSONWithResponse(t, baseURL+"/assets", payload, http.StatusCreated) var out domain.Asset if err := json.Unmarshal(respBody, &out); err != nil { t.Fatalf("decode asset: %v", err) } return out } func insertOpenInstallation(t *testing.T, db *sql.DB, assetID, componentID, slot string) { t.Helper() id := "INS" + time.Now().UTC().Format("150405999999") if len(id) > 16 { id = id[:16] } if _, err := db.Exec( `INSERT INTO installations (id, machine_id, part_id, slot_name, installed_at) VALUES (?, ?, ?, ?, ?)`, id, assetID, componentID, slot, time.Now().UTC().Add(-1*time.Hour), ); err != nil { t.Fatalf("insert installation: %v", err) } } func postJSONRaw(t *testing.T, url string, payload any) ([]byte, 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 %s: %v", url, err) } defer resp.Body.Close() var buf bytes.Buffer if _, err := buf.ReadFrom(resp.Body); err != nil { t.Fatalf("read body: %v", err) } return buf.Bytes(), resp.StatusCode }