package edge_node import ( "context" "errors" "testing" "time" "gorm.io/driver/sqlite" "gorm.io/gorm" ) func edgeNodeTestService(t *testing.T) (*Service, *gorm.DB, time.Time) { t.Helper() db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{}) if err != nil { t.Fatal(err) } if err = db.AutoMigrate(&Node{}, &Event{}); err != nil { t.Fatal(err) } now := time.Date(2026, 8, 28, 4, 0, 0, 0, time.UTC) service := NewService(db) service.Now = func() time.Time { return now } return service, db, now } func TestListClassifiesOnlineOfflineAndRecoveringWithoutErasingLastKnownState(t *testing.T) { service, db, now := edgeNodeTestService(t) if err := SeedSyntheticFixture(db, now); err != nil { t.Fatal(err) } page, err := service.List(PageRequest{}) if err != nil { t.Fatal(err) } if page.Count != 3 || page.Summary.Online != 1 || page.Summary.Offline != 1 || page.Summary.Recovering != 1 || page.Summary.BackfillQueueDepth != 17 { t.Fatalf("unexpected summary: %+v", page.Summary) } var offline Response for _, item := range page.List { if item.Status == StatusOffline { offline = item } } if !offline.Stale || offline.ControlTunnelStatus != ChannelReady || offline.VideoPlaneStatus != ChannelReady || offline.BackfillQueueDepth != 12 { t.Fatalf("offline projection lost last-known state: %+v", offline) } } func TestApplyHeartbeatRecordsRecoveryAndPreservesIndependentChannelConvergence(t *testing.T) { service, db, now := edgeNodeTestService(t) old := Node{ID: "EDGE-1", Name: "旧节点", StartedAt: now.Add(-time.Hour), LastHeartbeatAt: now.Add(-5 * time.Minute), LastCollectedAt: now.Add(-5 * time.Minute), LoadCapacity: 16, ControlTunnelStatus: ChannelReady, VideoPlaneStatus: ChannelReady, BackfillStatus: BackfillPending, BackfillQueueDepth: 8, ProjectionVersion: 3} if err := db.Create(&old).Error; err != nil { t.Fatal(err) } sample := HeartbeatSample{Node: Node{ID: "EDGE-1", Name: "旧节点", StartedAt: old.StartedAt, LastHeartbeatAt: now.Add(-5 * time.Second), LoadUsed: 4, LoadCapacity: 16, ControlTunnelStatus: ChannelReady, VideoPlaneStatus: ChannelUnavailable, BackfillStatus: BackfillPending, BackfillQueueDepth: 3, RecoveryPhase: "video_reconnecting"}, CollectedAt: now} if err := service.ApplyHeartbeat(context.Background(), sample); err != nil { t.Fatal(err) } item, err := service.Get("EDGE-1") if err != nil { t.Fatal(err) } if item.Status != StatusRecovering || item.ProjectionVersion != 4 || item.VideoPlaneStatus != ChannelUnavailable || item.BackfillQueueDepth != 3 { t.Fatalf("unexpected recovery projection: %+v", item) } if len(item.Events) != 2 || item.Events[0].EventType != "node_recovered" || item.Events[0].FromStatus != StatusOffline || item.Events[0].ToStatus != StatusRecovering || item.Events[1].EventType != "heartbeat_timeout" || item.Events[1].ToStatus != StatusOffline { t.Fatalf("unexpected events: %+v", item.Events) } } func TestApplyHeartbeatRejectsInvalidSampleAndDoesNotPersist(t *testing.T) { service, db, now := edgeNodeTestService(t) err := service.ApplyHeartbeat(context.Background(), HeartbeatSample{Node: Node{ID: "EDGE-2", Name: "节点", StartedAt: now.Add(-time.Hour), LastHeartbeatAt: now, LoadUsed: 17, LoadCapacity: 16}, CollectedAt: now}) if !errors.Is(err, ErrInvalidSample) { t.Fatalf("err=%v", err) } var count int64 db.Model(&Node{}).Count(&count) if count != 0 { t.Fatalf("count=%d", count) } } func TestGetRejectsMissingOrMalformedID(t *testing.T) { service, _, _ := edgeNodeTestService(t) if _, err := service.Get(""); !errors.Is(err, ErrInvalidQuery) { t.Fatalf("err=%v", err) } if _, err := service.Get("missing"); !errors.Is(err, ErrNotFound) { t.Fatalf("err=%v", err) } } func TestListRejectsOversizedPage(t *testing.T) { service, _, _ := edgeNodeTestService(t) request := PageRequest{} request.PageSize = 101 if _, err := service.List(request); !errors.Is(err, ErrInvalidQuery) { t.Fatalf("err=%v", err) } }