package hub import ( "context" "fmt" "sync" "sync/atomic" "testing" "time" "commilitia.net/cdrop/internal/db" ) // fakeLister returns a fixed device list and counts calls. type fakeLister struct { devices []db.Device calls atomic.Int32 } func (f *fakeLister) ListDevicesByUser(_ context.Context, _ string) ([]db.Device, error) { f.calls.Add(1) return f.devices, nil } func waitForEvent(t *testing.T, c *Client, want string, timeout time.Duration) Event { t.Helper() select { case ev, ok := <-c.Events(): if !ok { t.Fatalf("event channel closed; wanted type=%q", want) } if want != "" && ev.Type != want { t.Fatalf("event type: got %q, want %q", ev.Type, want) } return ev case <-time.After(timeout): t.Fatalf("timeout waiting for event type=%q", want) } return Event{} } func TestConnectFiresPresenceEvent(t *testing.T) { lister := &fakeLister{ devices: []db.Device{{UserID: "alice", DeviceID: "dev_1", Name: "tab-1", Type: "browser", LastSeen: 1}}, } h := New(lister) defer h.Close() c := h.Connect(context.Background(), "alice", "dev_1", "tab-1", "browser") defer h.Disconnect(c) ev := waitForEvent(t, c, "presence", time.Second) data, _ := ev.Data.(map[string]any) devs, _ := data["devices"].([]PresenceDevice) if len(devs) != 1 || devs[0].Name != "tab-1" || devs[0].DeviceID != "dev_1" || !devs[0].Online { t.Errorf("presence devices: %+v", devs) } } func TestSecondClientSeesFirstAsOnline(t *testing.T) { lister := &fakeLister{ devices: []db.Device{ {UserID: "alice", DeviceID: "dev_1", Name: "tab-1", Type: "browser", LastSeen: 1}, {UserID: "alice", DeviceID: "dev_2", Name: "tab-2", Type: "browser", LastSeen: 2}, }, } h := New(lister) defer h.Close() c1 := h.Connect(context.Background(), "alice", "dev_1", "tab-1", "browser") defer h.Disconnect(c1) _ = waitForEvent(t, c1, "presence", time.Second) c2 := h.Connect(context.Background(), "alice", "dev_2", "tab-2", "browser") defer h.Disconnect(c2) // c2 receives its own presence (announced on Connect). ev := waitForEvent(t, c2, "presence", time.Second) devs, _ := ev.Data.(map[string]any)["devices"].([]PresenceDevice) if len(devs) != 2 { t.Fatalf("expected 2 devices in presence, got %+v", devs) } online := map[string]bool{} for _, d := range devs { online[d.Name] = d.Online } if !online["tab-1"] || !online["tab-2"] { t.Errorf("both tabs should be online: %+v", online) } // c1 also gets presence diff for c2's arrival. ev = waitForEvent(t, c1, "presence", time.Second) devs, _ = ev.Data.(map[string]any)["devices"].([]PresenceDevice) if len(devs) != 2 { t.Errorf("c1 presence after tab-2 arrival: got %d devices, want 2", len(devs)) } } // A live device with no devices-table row (global-SSO browser / broker-auth desktop) and no // device_id must still appear in presence as online, labelled by its declared name + type. func TestLiveOnlyDeviceAppearsInPresence(t *testing.T) { h := New(&fakeLister{}) // empty managed-device set defer h.Close() c := h.Connect(context.Background(), "alice", "", "macbook", "macos") defer h.Disconnect(c) ev := waitForEvent(t, c, "presence", time.Second) devs, _ := ev.Data.(map[string]any)["devices"].([]PresenceDevice) if len(devs) != 1 || devs[0].Name != "macbook" || devs[0].Type != "macos" || !devs[0].Online { t.Errorf("live-only device should appear online in presence: %+v", devs) } } func TestSendToRoutesEventByName(t *testing.T) { lister := &fakeLister{ devices: []db.Device{{UserID: "alice", DeviceID: "dev_1", Name: "tab-1", Type: "browser"}}, } h := New(lister) defer h.Close() c := h.Connect(context.Background(), "alice", "dev_1", "tab-1", "browser") defer h.Disconnect(c) _ = waitForEvent(t, c, "presence", time.Second) // Peer addressing is by name; the hub resolves it to the device_id-keyed client. if !h.SendTo("alice", "tab-1", Event{Type: "signal", Data: map[string]any{"x": 1}}) { t.Fatal("SendTo by name to a live client should report true") } ev := waitForEvent(t, c, "signal", time.Second) if ev.Data.(map[string]any)["x"] != 1 { t.Errorf("payload: %+v", ev.Data) } // Addressing by the stable device_id also works (direct map hit). if !h.SendTo("alice", "dev_1", Event{Type: "signal"}) { t.Fatal("SendTo by device_id should report true") } _ = waitForEvent(t, c, "signal", time.Second) } func TestSendToOfflineDeviceReportsFalse(t *testing.T) { h := New(&fakeLister{}) defer h.Close() if h.SendTo("ghost-user", "ghost-device", Event{Type: "signal"}) { t.Error("SendTo to offline target should report false") } } // TestRenameKeepsEntryAndUpdatesRouting is the decoupling guarantee: a renamed device keeps // its single hub entry (keyed by the stable device_id), so presence shows the new name with no // duplicate and the device stays online and reachable by its NEW name — no reconnect, no token. func TestRenameKeepsEntryAndUpdatesRouting(t *testing.T) { lister := &fakeLister{ devices: []db.Device{{UserID: "alice", DeviceID: "dev_1", Name: "iPhone", Type: "ios"}}, } h := New(lister) defer h.Close() c := h.Connect(context.Background(), "alice", "dev_1", "iPhone", "ios") defer h.Disconnect(c) _ = waitForEvent(t, c, "presence", time.Second) // Simulate the rename: the DB row's name changes, then the hub is told. lister.devices[0].Name = "My iPhone" h.Rename("alice", "dev_1", "My iPhone") h.PublishPresence(context.Background(), "alice") ev := waitForEvent(t, c, "presence", time.Second) devs := ev.Data.(map[string]any)["devices"].([]PresenceDevice) if len(devs) != 1 { t.Fatalf("rename must not duplicate the device: got %+v", devs) } if devs[0].Name != "My iPhone" || devs[0].DeviceID != "dev_1" || !devs[0].Online { t.Errorf("presence after rename: %+v", devs[0]) } // Reachable by the new name; the old name no longer resolves. if !h.SendTo("alice", "My iPhone", Event{Type: "signal"}) { t.Error("renamed device should be reachable by its new name") } _ = waitForEvent(t, c, "signal", time.Second) if h.SendTo("alice", "iPhone", Event{Type: "signal"}) { t.Error("old name should no longer resolve after rename") } } func TestReconnectClosesOldChannel(t *testing.T) { h := New(&fakeLister{}) defer h.Close() old := h.Connect(context.Background(), "alice", "dev_1", "tab-1", "browser") // drain the initial presence so we can detect close <-old.Events() _ = h.Connect(context.Background(), "alice", "dev_1", "tab-1", "browser") select { case _, ok := <-old.Events(): if ok { t.Error("old channel should be closed on reconnect from same device_id") } case <-time.After(time.Second): t.Fatal("timeout waiting for old channel close") } } // A reconnect of the SAME device under a DIFFERENT name (e.g. mid-rename) still replaces its // own entry rather than orphaning the old one — because the key is the stable device_id. func TestReconnectAfterRenameReplacesSameEntry(t *testing.T) { h := New(&fakeLister{}) defer h.Close() old := h.Connect(context.Background(), "alice", "dev_1", "iPhone", "ios") <-old.Events() _ = h.Connect(context.Background(), "alice", "dev_1", "My iPhone", "ios") select { case _, ok := <-old.Events(): if ok { t.Error("old channel should be closed even when the name changed (same device_id)") } case <-time.After(time.Second): t.Fatal("timeout waiting for old channel close") } } func TestDisconnectRemovesFromOnlineSet(t *testing.T) { h := New(&fakeLister{ devices: []db.Device{{UserID: "alice", DeviceID: "dev_1", Name: "tab-1", Type: "browser"}}, }) defer h.Close() c := h.Connect(context.Background(), "alice", "dev_1", "tab-1", "browser") if !h.Online("alice", "dev_1") { t.Fatal("client should be online after Connect") } h.Disconnect(c) if h.Online("alice", "dev_1") { t.Error("client should not be online after Disconnect") } } // TestConcurrentKickAndPresenceNoPanic hammers Kick (which closes channels under the write // lock) against publishPresence / Broadcast / SendTo (non-blocking sends under the read lock). // Before the close-vs-send fix this raced into a "send on closed channel" panic that crashed // the whole process; now close and send are mutually exclusive. Run with -race. func TestConcurrentKickAndPresenceNoPanic(t *testing.T) { h := New(&fakeLister{ devices: []db.Device{{UserID: "u", DeviceID: "dev_0", Name: "d", Type: "browser"}}, }) defer h.Close() const workers = 16 const iters = 200 var wg sync.WaitGroup for i := 0; i < workers; i += 1 { wg.Add(1) go func(n int) { defer wg.Done() id := fmt.Sprintf("dev_%d", n) name := fmt.Sprintf("d%d", n) for j := 0; j < iters; j += 1 { h.Connect(context.Background(), "u", id, name, "browser") h.PublishPresence(context.Background(), "u") h.Broadcast("u", Event{Type: "x"}) h.SendTo("u", name, Event{Type: "y"}) h.Rename("u", id, name+"-r") h.Kick("u", id, name) } }(i) } wg.Wait() } // TestGracePeriod uses a tiny grace so the test runs fast. func TestGracePeriodDelaysOfflinePresence(t *testing.T) { h := New(&fakeLister{ devices: []db.Device{ {UserID: "alice", DeviceID: "dev_1", Name: "tab-1", Type: "browser"}, {UserID: "alice", DeviceID: "dev_2", Name: "tab-2", Type: "browser"}, }, }) h.grace = 100 * time.Millisecond defer h.Close() c1 := h.Connect(context.Background(), "alice", "dev_1", "tab-1", "browser") defer h.Disconnect(c1) c2 := h.Connect(context.Background(), "alice", "dev_2", "tab-2", "browser") // Drain initial presence frames _ = waitForEvent(t, c1, "presence", time.Second) _ = waitForEvent(t, c1, "presence", time.Second) _ = waitForEvent(t, c2, "presence", time.Second) h.Disconnect(c2) // Grace 100ms: c1 should receive presence after the grace window with tab-2 offline. select { case ev := <-c1.Events(): if ev.Type != "presence" { t.Fatalf("expected presence after grace, got %q", ev.Type) } devs := ev.Data.(map[string]any)["devices"].([]PresenceDevice) var tab2Online bool for _, d := range devs { if d.Name == "tab-2" { tab2Online = d.Online } } if tab2Online { t.Error("tab-2 should be offline in post-grace presence") } case <-time.After(time.Second): t.Fatal("timeout waiting for post-grace presence") } }