package service import ( "atsflare/common" "atsflare/model" "io" "net/http" "sort" "strings" "testing" "time" ) type roundTripFunc func(req *http.Request) (*http.Response, error) func (f roundTripFunc) RoundTrip(req *http.Request) (*http.Response, error) { return f(req) } func TestRequestNodeAgentPreviewUpdate(t *testing.T) { setupServiceTestDB(t) node, err := CreateNode(NodeInput{Name: "preview-edge-1"}) if err != nil { t.Fatalf("failed to create node: %v", err) } originalClient := UpdateHTTPClientForTest() SetUpdateHTTPClientForTest(&http.Client{ Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) { if req.URL.String() != "https://api.github.com/repos/"+common.AgentUpdateRepo+"/releases/tags/v0.5.0-rc.1" { t.Fatalf("unexpected request url: %s", req.URL.String()) } return &http.Response{ StatusCode: http.StatusOK, Header: make(http.Header), Body: io.NopCloser(strings.NewReader(`{"tag_name":"v0.5.0-rc.1","prerelease":true}`)), }, nil }), }) t.Cleanup(func() { SetUpdateHTTPClientForTest(originalClient) }) updated, err := RequestNodeAgentUpdate(node.ID, NodeAgentUpdateInput{ Channel: "preview", TagName: "v0.5.0-rc.1", }) if err != nil { t.Fatalf("expected preview update request to succeed: %v", err) } if !updated.UpdateRequested { t.Fatal("expected update_requested to be true") } if updated.UpdateChannel != "preview" { t.Fatalf("unexpected update channel: %s", updated.UpdateChannel) } if updated.UpdateTag != "v0.5.0-rc.1" { t.Fatalf("unexpected update tag: %s", updated.UpdateTag) } } func TestHeartbeatNodeReturnsPreviewUpdateSettings(t *testing.T) { setupServiceTestDB(t) node := &model.Node{ NodeID: "node-preview-1", Name: "preview-edge-1", IP: "10.0.0.8", AgentToken: "agent-token", AgentVersion: "v0.4.0", NginxVersion: "1.27.1.2", Status: NodeStatusOnline, UpdateRequested: true, UpdateChannel: "preview", UpdateTag: "v0.5.0-rc.1", RestartOpenrestyRequested: true, AutoUpdateEnabled: false, } if err := node.Insert(); err != nil { t.Fatalf("failed to seed node: %v", err) } if err := model.DB.Create(&model.ConfigVersion{ Version: "20260313-001", SnapshotJSON: "{}", MainConfig: "worker_processes auto;", RenderedConfig: "server { listen 80; }", Checksum: "checksum-active-1", IsActive: true, CreatedBy: "root", }).Error; err != nil { t.Fatalf("failed to seed active config version: %v", err) } resp, err := HeartbeatNode(node, AgentNodePayload{ NodeID: node.NodeID, Name: node.Name, IP: node.IP, AgentVersion: node.AgentVersion, NginxVersion: node.NginxVersion, OpenrestyStatus: OpenrestyStatusUnhealthy, OpenrestyMessage: "port 80 already allocated", }) if err != nil { t.Fatalf("expected heartbeat to succeed: %v", err) } if resp.AgentSettings == nil { t.Fatal("expected agent settings in heartbeat response") } if resp.ActiveConfig == nil { t.Fatal("expected active config summary in heartbeat response") } if resp.ActiveConfig.Version == "" || resp.ActiveConfig.Checksum == "" { t.Fatal("expected active config summary to include version and checksum") } if !resp.AgentSettings.UpdateNow { t.Fatal("expected update_now to be true") } if resp.AgentSettings.UpdateChannel != "preview" { t.Fatalf("unexpected update channel: %s", resp.AgentSettings.UpdateChannel) } if resp.AgentSettings.UpdateTag != "v0.5.0-rc.1" { t.Fatalf("unexpected update tag: %s", resp.AgentSettings.UpdateTag) } if !resp.AgentSettings.RestartOpenrestyNow { t.Fatal("expected restart_openresty_now to be true") } if resp.Node.OpenrestyStatus != OpenrestyStatusUnhealthy { t.Fatalf("expected unhealthy openresty status, got %s", resp.Node.OpenrestyStatus) } if resp.Node.OpenrestyMessage != "port 80 already allocated" { t.Fatalf("unexpected openresty message: %s", resp.Node.OpenrestyMessage) } storedNode, err := model.GetNodeByID(node.ID) if err != nil { t.Fatalf("failed to reload node: %v", err) } if storedNode.UpdateRequested { t.Fatal("expected update_requested to be reset after heartbeat") } if storedNode.UpdateChannel != "stable" { t.Fatalf("expected update channel to reset to stable, got %s", storedNode.UpdateChannel) } if storedNode.UpdateTag != "" { t.Fatalf("expected update tag to be cleared, got %s", storedNode.UpdateTag) } if storedNode.RestartOpenrestyRequested { t.Fatal("expected restart_openresty_requested to be reset after heartbeat") } } func TestRequestNodeOpenrestyRestart(t *testing.T) { setupServiceTestDB(t) node, err := CreateNode(NodeInput{Name: "restart-edge-1"}) if err != nil { t.Fatalf("failed to create node: %v", err) } updated, err := RequestNodeOpenrestyRestart(node.ID) if err != nil { t.Fatalf("expected openresty restart request to succeed: %v", err) } if !updated.RestartOpenrestyRequested { t.Fatal("expected restart_openresty_requested to be true") } } func TestListNodeViewsIncludesLatestApplyLogsForMultipleNodes(t *testing.T) { setupServiceTestDB(t) now := time.Now() nodes := []*model.Node{ { NodeID: "node-a", Name: "edge-a", IP: "10.0.0.11", AgentToken: "token-a", AgentVersion: "v0.5.0", NginxVersion: "1.27.1.2", Status: NodeStatusOnline, LastSeenAt: now, }, { NodeID: "node-b", Name: "edge-b", IP: "10.0.0.12", AgentToken: "token-b", AgentVersion: "v0.5.0", NginxVersion: "1.27.1.2", Status: NodeStatusOnline, LastSeenAt: now, }, } for _, node := range nodes { if err := node.Insert(); err != nil { t.Fatalf("failed to insert node %s: %v", node.NodeID, err) } } logs := []*model.ApplyLog{ {NodeID: "node-a", Version: "20260313-001", Result: ApplyResultOK, Message: "first success", CreatedAt: now.Add(-2 * time.Minute)}, {NodeID: "node-a", Version: "20260313-002", Result: ApplyResultFailed, Message: "latest failure", CreatedAt: now.Add(-1 * time.Minute)}, {NodeID: "node-b", Version: "20260313-003", Result: ApplyResultOK, Message: "latest success", CreatedAt: now}, } for _, log := range logs { if err := model.DB.Create(log).Error; err != nil { t.Fatalf("failed to insert apply log for %s: %v", log.NodeID, err) } } views, err := ListNodeViews() if err != nil { t.Fatalf("ListNodeViews failed: %v", err) } if len(views) != 2 { t.Fatalf("expected 2 node views, got %d", len(views)) } sort.Slice(views, func(i int, j int) bool { return views[i].NodeID < views[j].NodeID }) if views[0].NodeID != "node-a" || views[0].LatestApplyResult != ApplyResultFailed || views[0].LatestApplyMessage != "latest failure" { t.Fatalf("unexpected latest apply log for node-a: %+v", views[0]) } if views[1].NodeID != "node-b" || views[1].LatestApplyResult != ApplyResultOK || views[1].LatestApplyMessage != "latest success" { t.Fatalf("unexpected latest apply log for node-b: %+v", views[1]) } } func TestCollectNodeHeartbeatChangesOnlyReturnsChangedFields(t *testing.T) { now := time.Now() before := &model.Node{ Name: "edge-1", IP: "10.0.0.8", AgentVersion: "v0.5.0", NginxVersion: "1.27.1.2", OpenrestyStatus: OpenrestyStatusHealthy, OpenrestyMessage: "", Status: NodeStatusOnline, CurrentVersion: "20260313-001", LastSeenAt: now.Add(-time.Minute), LastError: "", UpdateRequested: true, UpdateChannel: "preview", UpdateTag: "v0.5.0-rc.1", RestartOpenrestyRequested: true, } after := &model.Node{ Name: "edge-1", IP: "10.0.0.8", AgentVersion: "v0.5.0", NginxVersion: "1.27.1.2", OpenrestyStatus: OpenrestyStatusHealthy, OpenrestyMessage: "", Status: NodeStatusOnline, CurrentVersion: "20260313-001", LastSeenAt: now, LastError: "", UpdateRequested: false, UpdateChannel: "stable", UpdateTag: "", RestartOpenrestyRequested: false, } changes := collectNodeHeartbeatChanges(before, after) if len(changes) != 5 { t.Fatalf("expected 5 changed fields, got %d: %#v", len(changes), changes) } if _, ok := changes["last_seen_at"]; !ok { t.Fatal("expected last_seen_at change to be included") } if value, ok := changes["update_requested"]; !ok || value != false { t.Fatalf("expected update_requested reset, got %#v", value) } if value, ok := changes["update_channel"]; !ok || value != "stable" { t.Fatalf("expected update_channel reset, got %#v", value) } if value, ok := changes["update_tag"]; !ok || value != "" { t.Fatalf("expected update_tag reset, got %#v", value) } if value, ok := changes["restart_openresty_requested"]; !ok || value != false { t.Fatalf("expected restart_openresty_requested reset, got %#v", value) } if _, ok := changes["ip"]; ok { t.Fatal("did not expect unchanged ip to be included") } } func TestListNodeViewsDoesNotPersistComputedStatus(t *testing.T) { setupServiceTestDB(t) node := &model.Node{ NodeID: "node-offline-view", Name: "edge-offline", IP: "10.0.0.21", AgentToken: "token-offline", AgentVersion: "v0.5.0", NginxVersion: "1.27.1.2", Status: NodeStatusOnline, LastSeenAt: time.Now().Add(-common.NodeOfflineThreshold - time.Minute), } if err := node.Insert(); err != nil { t.Fatalf("failed to insert node: %v", err) } views, err := ListNodeViews() if err != nil { t.Fatalf("ListNodeViews failed: %v", err) } if len(views) != 1 { t.Fatalf("expected 1 node view, got %d", len(views)) } if views[0].Status != NodeStatusOffline { t.Fatalf("expected computed offline status in view, got %s", views[0].Status) } storedNode, err := model.GetNodeByID(node.ID) if err != nil { t.Fatalf("failed to reload node: %v", err) } if storedNode.Status != NodeStatusOnline { t.Fatalf("expected list query to avoid persisting computed status, got %s", storedNode.Status) } } func TestHeartbeatNodePersistsObservabilityPayload(t *testing.T) { setupServiceTestDB(t) node := &model.Node{ NodeID: "node-observe-1", Name: "observe-edge-1", IP: "10.0.0.31", AgentToken: "token-observe", AgentVersion: "v0.6.0", NginxVersion: "1.27.1.2", Status: NodeStatusOnline, } if err := node.Insert(); err != nil { t.Fatalf("failed to seed node: %v", err) } _, err := HeartbeatNode(node, AgentNodePayload{ NodeID: node.NodeID, Name: node.Name, IP: node.IP, AgentVersion: node.AgentVersion, NginxVersion: node.NginxVersion, Profile: &AgentNodeSystemProfile{ Hostname: "observe-edge-1", OSName: "Ubuntu", OSVersion: "24.04", KernelVersion: "6.8.0", Architecture: "amd64", CPUModel: "Intel Xeon", CPUCores: 8, TotalMemoryBytes: 16 * 1024 * 1024 * 1024, TotalDiskBytes: 200 * 1024 * 1024 * 1024, UptimeSeconds: 3600, ReportedAtUnix: time.Now().Add(-time.Minute).Unix(), }, Snapshot: &AgentNodeMetricSnapshot{ CapturedAtUnix: time.Now().Add(-30 * time.Second).Unix(), CPUUsagePercent: 42.5, MemoryUsedBytes: 8 * 1024 * 1024 * 1024, MemoryTotalBytes: 16 * 1024 * 1024 * 1024, StorageUsedBytes: 70 * 1024 * 1024 * 1024, StorageTotalBytes: 200 * 1024 * 1024 * 1024, DiskReadBytes: 1024, DiskWriteBytes: 2048, NetworkRxBytes: 4096, NetworkTxBytes: 8192, OpenrestyConnections: 128, }, TrafficReport: &AgentNodeTrafficReport{ WindowStartedAtUnix: time.Now().Add(-time.Minute).Unix(), WindowEndedAtUnix: time.Now().Unix(), RequestCount: 1200, ErrorCount: 12, UniqueVisitorCount: 320, StatusCodes: map[string]int64{"200": 1100, "502": 12}, TopDomains: map[string]int64{"example.com": 900}, SourceCountries: map[string]int64{"CN": 700, "US": 200}, }, HealthEvents: []AgentNodeHealthEvent{ { EventType: "openresty_unhealthy", Severity: NodeHealthSeverityCritical, Message: "reload failed", TriggeredAtUnix: time.Now().Add(-2 * time.Minute).Unix(), }, }, }) if err != nil { t.Fatalf("expected heartbeat to succeed: %v", err) } profile, err := model.GetNodeSystemProfile(node.NodeID) if err != nil { t.Fatalf("expected node profile to persist: %v", err) } if profile.OSName != "Ubuntu" || profile.CPUCores != 8 { t.Fatalf("unexpected system profile: %+v", profile) } snapshots, err := model.ListNodeMetricSnapshots(node.NodeID, time.Time{}, 10) if err != nil { t.Fatalf("expected node snapshots query to succeed: %v", err) } if len(snapshots) != 1 || snapshots[0].OpenrestyConnections != 128 { t.Fatalf("unexpected metric snapshots: %+v", snapshots) } reports, err := model.ListNodeRequestReports(node.NodeID, time.Time{}, 10) if err != nil { t.Fatalf("expected node request reports query to succeed: %v", err) } if len(reports) != 1 || reports[0].RequestCount != 1200 { t.Fatalf("unexpected request reports: %+v", reports) } events, err := model.ListNodeHealthEvents(node.NodeID, true, 10) if err != nil { t.Fatalf("expected node health events query to succeed: %v", err) } if len(events) != 1 || events[0].EventType != "openresty_unhealthy" { t.Fatalf("unexpected active health events: %+v", events) } } func TestHeartbeatNodeResolvesMissingHealthEvents(t *testing.T) { setupServiceTestDB(t) node := &model.Node{ NodeID: "node-event-1", Name: "event-edge-1", IP: "10.0.0.41", AgentToken: "token-event", AgentVersion: "v0.6.0", NginxVersion: "1.27.1.2", Status: NodeStatusOnline, } if err := node.Insert(); err != nil { t.Fatalf("failed to seed node: %v", err) } _, err := HeartbeatNode(node, AgentNodePayload{ NodeID: node.NodeID, Name: node.Name, IP: node.IP, AgentVersion: node.AgentVersion, NginxVersion: node.NginxVersion, HealthEvents: []AgentNodeHealthEvent{ { EventType: "sync_error", Severity: NodeHealthSeverityWarning, Message: "checksum mismatch", TriggeredAtUnix: time.Now().Add(-time.Minute).Unix(), }, }, }) if err != nil { t.Fatalf("expected first heartbeat to succeed: %v", err) } _, err = HeartbeatNode(node, AgentNodePayload{ NodeID: node.NodeID, Name: node.Name, IP: node.IP, AgentVersion: node.AgentVersion, NginxVersion: node.NginxVersion, HealthEvents: []AgentNodeHealthEvent{}, }) if err != nil { t.Fatalf("expected second heartbeat to succeed: %v", err) } activeEvents, err := model.ListNodeHealthEvents(node.NodeID, true, 10) if err != nil { t.Fatalf("expected active node health events query to succeed: %v", err) } if len(activeEvents) != 0 { t.Fatalf("expected no active health events, got %+v", activeEvents) } allEvents, err := model.ListNodeHealthEvents(node.NodeID, false, 10) if err != nil { t.Fatalf("expected all node health events query to succeed: %v", err) } if len(allEvents) != 1 || allEvents[0].Status != NodeHealthEventStatusResolved || allEvents[0].ResolvedAt == nil { t.Fatalf("expected resolved health event record, got %+v", allEvents) } } func TestGetNodeObservability(t *testing.T) { setupServiceTestDB(t) node := &model.Node{ NodeID: "node-observability-query", Name: "query-edge", IP: "10.0.0.61", AgentToken: "token-query", AgentVersion: "v0.6.0", NginxVersion: "1.27.1.2", Status: NodeStatusOnline, } if err := node.Insert(); err != nil { t.Fatalf("failed to insert node: %v", err) } if err := model.UpsertNodeSystemProfile(&model.NodeSystemProfile{ NodeID: node.NodeID, Hostname: "query-edge", OSName: "Ubuntu", Architecture: "amd64", ReportedAt: time.Now(), }); err != nil { t.Fatalf("failed to insert node system profile: %v", err) } if err := (&model.NodeMetricSnapshot{ NodeID: node.NodeID, CapturedAt: time.Now(), }).Insert(); err != nil { t.Fatalf("failed to insert node metric snapshot: %v", err) } if err := (&model.NodeRequestReport{ NodeID: node.NodeID, WindowStartedAt: time.Now().Add(-time.Minute), WindowEndedAt: time.Now(), RequestCount: 123, }).Insert(); err != nil { t.Fatalf("failed to insert node request report: %v", err) } if err := model.DB.Create(&model.NodeHealthEvent{ NodeID: node.NodeID, EventType: "sync_error", Severity: NodeHealthSeverityWarning, Status: NodeHealthEventStatusActive, Message: "checksum mismatch", FirstTriggeredAt: time.Now().Add(-time.Minute), LastTriggeredAt: time.Now(), ReportedAt: time.Now(), }).Error; err != nil { t.Fatalf("failed to insert node health event: %v", err) } view, err := GetNodeObservability(node.ID, NodeObservabilityQuery{Hours: 24, Limit: 10}) if err != nil { t.Fatalf("GetNodeObservability failed: %v", err) } if view.NodeID != node.NodeID { t.Fatalf("unexpected node id: %s", view.NodeID) } if view.Profile == nil || view.Profile.OSName != "Ubuntu" { t.Fatalf("unexpected profile: %+v", view.Profile) } if len(view.MetricSnapshots) != 1 { t.Fatalf("expected 1 metric snapshot, got %d", len(view.MetricSnapshots)) } if len(view.TrafficReports) != 1 || view.TrafficReports[0].RequestCount != 123 { t.Fatalf("unexpected traffic reports: %+v", view.TrafficReports) } if len(view.HealthEvents) != 1 || view.HealthEvents[0].EventType != "sync_error" { t.Fatalf("unexpected health events: %+v", view.HealthEvents) } } func TestGetNodeObservabilityAllowsMissingProfile(t *testing.T) { setupServiceTestDB(t) node := &model.Node{ NodeID: "node-observability-empty", Name: "empty-edge", IP: "10.0.0.62", AgentToken: "token-empty", AgentVersion: "v0.6.0", NginxVersion: "1.27.1.2", Status: NodeStatusOnline, } if err := node.Insert(); err != nil { t.Fatalf("failed to insert node: %v", err) } view, err := GetNodeObservability(node.ID, NodeObservabilityQuery{}) if err != nil { t.Fatalf("GetNodeObservability failed: %v", err) } if view.Profile != nil { t.Fatalf("expected nil profile when profile not reported, got %+v", view.Profile) } }