Files
core/internal/api/ownership_test.go

319 lines
11 KiB
Go

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
}