package api import ( "bytes" "database/sql" "encoding/json" "net/http" "net/http/httptest" "os" "testing" "time" "reanimator/internal/repository" "reanimator/internal/repository/registry" ) func TestMachineDispatchSuccess(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, "Dispatch Corp") locationID := insertLocation(t, db, "DC-1", "dc") insertStockMachine(t, db, "DISPATCH-SN-001") mux := http.NewServeMux() RegisterRegistryRoutes(mux, RegistryDependencies{ Customers: registry.NewCustomerRepository(db), Locations: registry.NewLocationRepository(db), Assets: registry.NewAssetRepository(db), }) server := httptest.NewServer(mux) defer server.Close() body := map[string]any{ "serial_numbers": []string{"DISPATCH-SN-001"}, "customer_id": customerID, "location_id": locationID, } raw, _ := json.Marshal(body) resp, err := http.Post(server.URL+"/machines/dispatch", "application/json", bytes.NewReader(raw)) if err != nil { t.Fatalf("dispatch request: %v", err) } resp.Body.Close() if resp.StatusCode != http.StatusOK { t.Fatalf("expected 200, got %d", resp.StatusCode) } var assignedCustomer sql.NullString var assignedLocation sql.NullString if err := db.QueryRow(`SELECT customer_id, location_id FROM machines WHERE vendor_serial = ?`, "DISPATCH-SN-001"). Scan(&assignedCustomer, &assignedLocation); err != nil { t.Fatalf("query machine: %v", err) } if !assignedCustomer.Valid || assignedCustomer.String != customerID { t.Fatalf("expected customer_id=%s, got %v", customerID, assignedCustomer.String) } if !assignedLocation.Valid || assignedLocation.String != locationID { t.Fatalf("expected location_id=%s, got %v", locationID, assignedLocation.String) } assertCountQuery(t, db, "SELECT COUNT(*) FROM timeline_events WHERE event_type = 'MACHINE_DISPATCHED'", 1) } func TestMachineDispatchConflictForAssignedMachine(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) } customerA := insertCustomer(t, db, "A") customerB := insertCustomer(t, db, "B") insertAssignedMachine(t, db, "DISPATCH-SN-LOCKED", customerA, "") mux := http.NewServeMux() RegisterRegistryRoutes(mux, RegistryDependencies{ Customers: registry.NewCustomerRepository(db), Locations: registry.NewLocationRepository(db), Assets: registry.NewAssetRepository(db), }) server := httptest.NewServer(mux) defer server.Close() body := map[string]any{ "serial_numbers": []string{"DISPATCH-SN-LOCKED"}, "customer_id": customerB, } raw, _ := json.Marshal(body) resp, err := http.Post(server.URL+"/machines/dispatch", "application/json", bytes.NewReader(raw)) if err != nil { t.Fatalf("dispatch request: %v", err) } resp.Body.Close() if resp.StatusCode != http.StatusConflict { t.Fatalf("expected 409, got %d", resp.StatusCode) } } func TestMachineReturnToStock(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, "Return Corp") locationID := insertLocation(t, db, "DC-Return", "dc") insertAssignedMachine(t, db, "RETURN-SN-001", customerID, locationID) mux := http.NewServeMux() RegisterRegistryRoutes(mux, RegistryDependencies{ Customers: registry.NewCustomerRepository(db), Locations: registry.NewLocationRepository(db), Assets: registry.NewAssetRepository(db), }) server := httptest.NewServer(mux) defer server.Close() body := map[string]any{"serial_numbers": []string{"RETURN-SN-001"}} raw, _ := json.Marshal(body) resp, err := http.Post(server.URL+"/machines/return-to-stock", "application/json", bytes.NewReader(raw)) if err != nil { t.Fatalf("return request: %v", err) } resp.Body.Close() if resp.StatusCode != http.StatusOK { t.Fatalf("expected 200, got %d", resp.StatusCode) } var returnedCustomer sql.NullString var returnedLocation sql.NullString if err := db.QueryRow(`SELECT customer_id, location_id FROM machines WHERE vendor_serial = ?`, "RETURN-SN-001"). Scan(&returnedCustomer, &returnedLocation); err != nil { t.Fatalf("query machine: %v", err) } if returnedCustomer.Valid || returnedLocation.Valid { t.Fatalf("expected stock assignment after return, got customer=%v location=%v", returnedCustomer.String, returnedLocation.String) } resp, err = http.Post(server.URL+"/machines/return-to-stock", "application/json", bytes.NewReader(raw)) if err != nil { t.Fatalf("second return request: %v", err) } resp.Body.Close() if resp.StatusCode != http.StatusOK { t.Fatalf("expected 200 for idempotent return, got %d", resp.StatusCode) } assertCountQuery(t, db, "SELECT COUNT(*) FROM timeline_events WHERE event_type = 'MACHINE_RETURNED_TO_STOCK'", 1) } func TestDeleteMachineWithDetails(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) } machineID := insertStockMachine(t, db, "DELETE-SN-001") partID := nextTestID("PT") if _, err := db.Exec(`INSERT INTO parts (id, vendor_serial) VALUES (?, ?)`, partID, "PART-DELETE-001"); err != nil { t.Fatalf("insert part: %v", err) } now := time.Now().UTC() installationID := nextTestID("IN") if _, err := db.Exec(`INSERT INTO installations (id, machine_id, part_id, installed_at) VALUES (?, ?, ?, ?)`, installationID, machineID, partID, now); err != nil { t.Fatalf("insert installation: %v", err) } logBundleID := nextTestID("LB") if _, err := db.Exec(`INSERT INTO log_bundles (id, machine_id, collected_at, content_hash, payload) VALUES (?, ?, ?, ?, ?)`, logBundleID, machineID, now, "hash-delete-001", `{"test":"delete"}`); err != nil { t.Fatalf("insert log bundle: %v", err) } observationID := nextTestID("OB") if _, err := db.Exec(`INSERT INTO observations (id, log_bundle_id, machine_id, part_id, observed_at) VALUES (?, ?, ?, ?, ?)`, observationID, logBundleID, machineID, partID, now); err != nil { t.Fatalf("insert observation: %v", err) } failureID := nextTestID("FE") if _, err := db.Exec(`INSERT INTO failure_events (id, source, external_id, part_id, machine_id, failure_type, failure_time) VALUES (?, ?, ?, ?, ?, ?, ?)`, failureID, "test", "delete-event-001", partID, machineID, "CPU", now); err != nil { t.Fatalf("insert failure event: %v", err) } ticketID := nextTestID("TK") if _, err := db.Exec(`INSERT INTO tickets (id, source, external_id, title, status) VALUES (?, ?, ?, ?, ?)`, ticketID, "test", "delete-ticket-001", "Delete test", "OPEN"); err != nil { t.Fatalf("insert ticket: %v", err) } ticketLinkID := nextTestID("TL") if _, err := db.Exec(`INSERT INTO ticket_links (id, ticket_id, machine_id) VALUES (?, ?, ?)`, ticketLinkID, ticketID, machineID); err != nil { t.Fatalf("insert ticket link: %v", err) } firmwareMachineID := nextTestID("TE") if _, err := db.Exec(`INSERT INTO timeline_events (id, subject_type, subject_id, event_type, event_time, machine_id, part_id) VALUES (?, 'asset', ?, 'LOG_COLLECTED', ?, ?, ?)`, firmwareMachineID, machineID, now, machineID, partID); err != nil { t.Fatalf("insert machine timeline: %v", err) } firmwarePartID := nextTestID("TE") if _, err := db.Exec(`INSERT INTO timeline_events (id, subject_type, subject_id, event_type, event_time, machine_id, part_id) VALUES (?, 'component', ?, 'INSTALLED', ?, ?, ?)`, firmwarePartID, partID, now, machineID, partID); err != nil { t.Fatalf("insert part timeline: %v", err) } if _, err := db.Exec(`INSERT INTO machine_firmware_states (machine_id, device_name, firmware_version) VALUES (?, ?, ?)`, machineID, "BIOS", "1.0.0"); err != nil { t.Fatalf("insert firmware state: %v", err) } mux := http.NewServeMux() RegisterRegistryRoutes(mux, RegistryDependencies{ Assets: registry.NewAssetRepository(db), }) server := httptest.NewServer(mux) defer server.Close() req, err := http.NewRequest(http.MethodDelete, server.URL+"/registry/assets/"+machineID, nil) if err != nil { t.Fatalf("new request: %v", err) } resp, err := http.DefaultClient.Do(req) if err != nil { t.Fatalf("delete request: %v", err) } resp.Body.Close() if resp.StatusCode != http.StatusOK { t.Fatalf("expected 200, got %d", resp.StatusCode) } assertCountQuery(t, db, "SELECT COUNT(*) FROM machines WHERE id = '"+machineID+"'", 0) assertCountQuery(t, db, "SELECT COUNT(*) FROM installations WHERE machine_id = '"+machineID+"'", 0) assertCountQuery(t, db, "SELECT COUNT(*) FROM log_bundles WHERE machine_id = '"+machineID+"'", 0) assertCountQuery(t, db, "SELECT COUNT(*) FROM observations WHERE machine_id = '"+machineID+"'", 0) assertCountQuery(t, db, "SELECT COUNT(*) FROM failure_events WHERE machine_id = '"+machineID+"'", 0) assertCountQuery(t, db, "SELECT COUNT(*) FROM ticket_links WHERE machine_id = '"+machineID+"'", 0) assertCountQuery(t, db, "SELECT COUNT(*) FROM machine_firmware_states WHERE machine_id = '"+machineID+"'", 0) assertCountQuery(t, db, "SELECT COUNT(*) FROM timeline_events WHERE machine_id = '"+machineID+"' OR part_id = '"+partID+"' OR subject_id = '"+machineID+"' OR subject_id = '"+partID+"'", 0) assertCountQuery(t, db, "SELECT COUNT(*) FROM parts WHERE id = '"+partID+"'", 0) } func insertLocation(t *testing.T, db *sql.DB, name, kind string) string { t.Helper() id := nextTestID("LN") if _, err := db.Exec(`INSERT INTO locations (id, name, kind) VALUES (?, ?, ?)`, id, name, kind); err != nil { t.Fatalf("insert location: %v", err) } return id } func insertStockMachine(t *testing.T, db *sql.DB, serial string) string { t.Helper() id := nextTestID("ME") if _, err := db.Exec(`INSERT INTO machines (id, project_id, customer_id, location_id, name, vendor_serial) VALUES (?, NULL, NULL, NULL, ?, ?)`, id, serial, serial); err != nil { t.Fatalf("insert stock machine: %v", err) } return id } func insertAssignedMachine(t *testing.T, db *sql.DB, serial, customerID, locationID string) string { t.Helper() id := nextTestID("ME") var location any if locationID != "" { location = locationID } if _, err := db.Exec(`INSERT INTO machines (id, project_id, customer_id, location_id, name, vendor_serial) VALUES (?, NULL, ?, ?, ?, ?)`, id, customerID, location, serial, serial); err != nil { t.Fatalf("insert assigned machine: %v", err) } return id }