package controlplane import ( "testing" "github.com/cedar2025/xboard-node/internal/model" ) func TestNodeMailboxLatestStateAndNotifyCollapse(t *testing.T) { mb := NewNodeMailbox() cfg1 := &model.NodeSpec{Protocol: "vmess", ServerPort: 10010} cfg2 := &model.NodeSpec{Protocol: "vless", ServerPort: 10020} mb.Apply(Event{Type: EventSyncConfig, Config: cfg1}) mb.Apply(Event{Type: EventSyncConfig, Config: cfg2}) select { case <-mb.NotifyCh(): default: t.Fatal("expected collapsed notify signal") } mb.MarkReady() state := mb.DrainIfReady() if !state.HasConfig || state.Config == nil { t.Fatal("expected config state") } if state.Config.Protocol != "vless" || state.Config.ServerPort != 10020 { t.Fatalf("unexpected config: %+v", state.Config) } } func TestNodeMailboxFullUsersOverwriteAndDeltaBeforeBaseline(t *testing.T) { mb := NewNodeMailbox() mb.Apply(Event{ Type: EventSyncUserDelta, DeltaAction: "add", DeltaUsers: []model.UserSpec{{ID: 1, UUID: "u1"}}, }) mb.MarkReady() state := mb.DrainIfReady() if !state.NeedsReconcile { t.Fatal("expected reconcile when delta arrives before baseline") } mb.Apply(Event{Type: EventSyncUsers, Users: []model.UserSpec{{ID: 1, UUID: "u1"}}}) mb.Apply(Event{Type: EventSyncUsers, Users: []model.UserSpec{{ID: 2, UUID: "u2"}}}) state = mb.DrainIfReady() if !state.HasUsers { t.Fatal("expected users snapshot") } if len(state.Users) != 1 || state.Users[0].ID != 2 { t.Fatalf("unexpected users: %+v", state.Users) } } func TestNodeMailboxDevicesLatestWins(t *testing.T) { mb := NewNodeMailbox() mb.Apply(Event{Type: EventSyncDevices, DeviceUsers: map[int][]string{1: {"1.1.1.1"}}}) mb.Apply(Event{Type: EventSyncDevices, DeviceUsers: map[int][]string{1: {"2.2.2.2"}}}) mb.MarkReady() state := mb.DrainIfReady() if !state.HasDevices { t.Fatal("expected devices state") } if got := state.DeviceUsers[1][0]; got != "2.2.2.2" { t.Fatalf("unexpected device state: %v", state.DeviceUsers) } } func TestNodeMailboxReconcileNotifies(t *testing.T) { mb := NewNodeMailbox() mb.MarkReady() // Drain the MarkReady notification select { case <-mb.NotifyCh(): default: } // Delta without baseline should trigger reconcile AND notify mb.Apply(Event{ Type: EventSyncUserDelta, DeltaAction: "add", DeltaUsers: []model.UserSpec{{ID: 1, UUID: "u1"}}, }) select { case <-mb.NotifyCh(): default: t.Fatal("expected notify on reconcile path") } state := mb.DrainIfReady() if !state.NeedsReconcile { t.Fatal("expected NeedsReconcile") } } func TestNodeMailboxSeedBaselineEnablesDelta(t *testing.T) { mb := NewNodeMailbox() mb.SeedBaseline([]model.UserSpec{{ID: 1, UUID: "u1"}, {ID: 2, UUID: "u2"}}, nil) mb.MarkReady() // Drain MarkReady notify select { case <-mb.NotifyCh(): default: } // Delta should now apply incrementally, not trigger reconcile mb.Apply(Event{ Type: EventSyncUserDelta, DeltaAction: "add", DeltaUsers: []model.UserSpec{{ID: 3, UUID: "u3"}}, }) select { case <-mb.NotifyCh(): default: t.Fatal("expected notify after delta") } state := mb.DrainIfReady() if state.NeedsReconcile { t.Fatal("should not need reconcile after seeded baseline") } if !state.HasUsers { t.Fatal("expected users") } if len(state.Users) != 3 { t.Fatalf("expected 3 users, got %d: %+v", len(state.Users), state.Users) } } func TestNodeMailboxDeltaFailedApplyNotifies(t *testing.T) { mb := NewNodeMailbox() mb.SeedBaseline([]model.UserSpec{{ID: 1, UUID: "u1"}}, nil) mb.MarkReady() // Drain MarkReady notify select { case <-mb.NotifyCh(): default: } // Invalid action should trigger reconcile and still notify mb.Apply(Event{ Type: EventSyncUserDelta, DeltaAction: "invalid_action", DeltaUsers: []model.UserSpec{{ID: 2, UUID: "u2"}}, }) select { case <-mb.NotifyCh(): default: t.Fatal("expected notify on failed delta apply") } state := mb.DrainIfReady() if !state.NeedsReconcile { t.Fatal("expected NeedsReconcile for invalid delta action") } }