Add registry, ingest, timeline, tickets features
This commit is contained in:
180
internal/api/timeline_test.go
Normal file
180
internal/api/timeline_test.go
Normal file
@@ -0,0 +1,180 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"os"
|
||||
"strconv"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"reanimator/internal/domain"
|
||||
"reanimator/internal/ingest"
|
||||
"reanimator/internal/repository"
|
||||
"reanimator/internal/repository/registry"
|
||||
"reanimator/internal/repository/timeline"
|
||||
)
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
customerID := insertCustomer(t, db, "Acme")
|
||||
projectID := insertProject(t, db, customerID, "Core")
|
||||
assetID := insertAsset(t, db, projectID, "server-01", "ASSET-01")
|
||||
|
||||
mux := http.NewServeMux()
|
||||
RegisterIngestRoutes(mux, IngestDependencies{Service: ingest.NewService(db)})
|
||||
RegisterAssetComponentRoutes(mux, AssetComponentDependencies{
|
||||
Assets: registry.NewAssetRepository(db),
|
||||
Components: registry.NewComponentRepository(db),
|
||||
Timeline: timeline.NewEventRepository(db),
|
||||
})
|
||||
server := httptest.NewServer(mux)
|
||||
defer server.Close()
|
||||
|
||||
collectedAt1 := time.Now().UTC().Add(-time.Minute).Format(time.RFC3339)
|
||||
payload1 := map[string]any{
|
||||
"asset_id": assetID,
|
||||
"collected_at": collectedAt1,
|
||||
"components": []map[string]any{
|
||||
{"vendor_serial": "VSN-A", "firmware_version": "1.0.0"},
|
||||
{"vendor_serial": "VSN-B", "firmware_version": "1.0.0"},
|
||||
},
|
||||
}
|
||||
postJSON(t, server.URL+"/ingest/logbundle", payload1, http.StatusCreated)
|
||||
|
||||
collectedAt2 := time.Now().UTC().Format(time.RFC3339)
|
||||
payload2 := map[string]any{
|
||||
"asset_id": assetID,
|
||||
"collected_at": collectedAt2,
|
||||
"components": []map[string]any{
|
||||
{"vendor_serial": "VSN-A", "firmware_version": "2.0.0"},
|
||||
},
|
||||
}
|
||||
postJSON(t, server.URL+"/ingest/logbundle", payload2, http.StatusCreated)
|
||||
|
||||
componentA := lookupComponentID(t, db, "VSN-A")
|
||||
componentB := lookupComponentID(t, db, "VSN-B")
|
||||
|
||||
componentAEvents := fetchTimeline(t, server.URL+"/components/"+itoa(componentA)+"/timeline", 100, nil)
|
||||
assertEventTypes(t, componentAEvents, []string{"INSTALLED", "FIRMWARE_CHANGED"})
|
||||
|
||||
componentBEvents := fetchTimeline(t, server.URL+"/components/"+itoa(componentB)+"/timeline", 100, nil)
|
||||
assertEventTypes(t, componentBEvents, []string{"INSTALLED", "REMOVED"})
|
||||
|
||||
page1 := fetchTimelineResponse(t, server.URL+"/components/"+itoa(componentA)+"/timeline", 1, nil)
|
||||
if page1.NextCursor == nil {
|
||||
t.Fatalf("expected next_cursor")
|
||||
}
|
||||
page2 := fetchTimelineResponse(t, server.URL+"/components/"+itoa(componentA)+"/timeline", 1, page1.NextCursor)
|
||||
if len(page1.Items) == 0 || len(page2.Items) == 0 {
|
||||
t.Fatalf("expected paginated items")
|
||||
}
|
||||
if page2.Items[0].ID == page1.Items[0].ID {
|
||||
t.Fatalf("expected different pages")
|
||||
}
|
||||
if page2.Items[0].EventTime.Before(page1.Items[0].EventTime) {
|
||||
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) int64 {
|
||||
t.Helper()
|
||||
var id int64
|
||||
if err := db.QueryRow(`SELECT id FROM components WHERE vendor_serial = ?`, serial).Scan(&id); err != nil {
|
||||
t.Fatalf("lookup component: %v", err)
|
||||
}
|
||||
return id
|
||||
}
|
||||
|
||||
func fetchTimeline(t *testing.T, baseURL string, limit int, cursor *string) []domain.TimelineEvent {
|
||||
t.Helper()
|
||||
resp := fetchTimelineResponse(t, baseURL, limit, cursor)
|
||||
return resp.Items
|
||||
}
|
||||
|
||||
type timelineResponse struct {
|
||||
Items []domain.TimelineEvent `json:"items"`
|
||||
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", itoa(int64(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 assertEventTypes(t *testing.T, events []domain.TimelineEvent, required []string) {
|
||||
t.Helper()
|
||||
have := make(map[string]bool)
|
||||
for _, event := range events {
|
||||
have[event.EventType] = true
|
||||
}
|
||||
for _, eventType := range required {
|
||||
if !have[eventType] {
|
||||
t.Fatalf("missing event type %s", eventType)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func itoa(value int64) string {
|
||||
return strconv.FormatInt(value, 10)
|
||||
}
|
||||
Reference in New Issue
Block a user