Diffdown is a real-time collaborative Markdown editor/previewer built on the AT Protocol
diffdown.com
1package collaboration
2
3import (
4 "encoding/json"
5 "testing"
6 "time"
7)
8
9// stubClient creates a Client with a real send channel but no WebSocket conn.
10// It is usable for hub/room tests that don't exercise the network layer.
11func stubClient(hub *Hub, did, name, color, roomKey string) *Client {
12 return &Client{
13 hub: hub,
14 conn: nil, // not used in hub logic
15 send: make(chan []byte, 256),
16 DID: did,
17 Name: name,
18 Color: color,
19 roomKey: roomKey,
20 }
21}
22
23// drain reads all messages from a client's send channel within timeout.
24func drain(c *Client, timeout time.Duration) [][]byte {
25 deadline := time.After(timeout)
26 var msgs [][]byte
27 for {
28 select {
29 case msg, ok := <-c.send:
30 if !ok {
31 return msgs
32 }
33 msgs = append(msgs, msg)
34 case <-deadline:
35 return msgs
36 }
37 }
38}
39
40// waitForMessages blocks until n messages arrive on c.send or timeout.
41func waitForMessages(c *Client, n int, timeout time.Duration) [][]byte {
42 deadline := time.After(timeout)
43 var msgs [][]byte
44 for len(msgs) < n {
45 select {
46 case msg, ok := <-c.send:
47 if !ok {
48 return msgs
49 }
50 msgs = append(msgs, msg)
51 case <-deadline:
52 return msgs
53 }
54 }
55 return msgs
56}
57
58// --- Hub tests ---
59
60func TestHub_GetOrCreateRoom_CreatesNew(t *testing.T) {
61 hub := NewHub()
62 room := hub.GetOrCreateRoom("doc1")
63 if room == nil {
64 t.Fatal("expected non-nil room")
65 }
66}
67
68func TestHub_GetOrCreateRoom_ReturnsSame(t *testing.T) {
69 hub := NewHub()
70 r1 := hub.GetOrCreateRoom("doc1")
71 r2 := hub.GetOrCreateRoom("doc1")
72 if r1 != r2 {
73 t.Error("expected same room instance for same rkey")
74 }
75}
76
77func TestHub_GetOrCreateRoom_DifferentRooms(t *testing.T) {
78 hub := NewHub()
79 r1 := hub.GetOrCreateRoom("doc1")
80 r2 := hub.GetOrCreateRoom("doc2")
81 if r1 == r2 {
82 t.Error("expected different rooms for different rkeys")
83 }
84}
85
86func TestHub_GetRoom_NilForUnknown(t *testing.T) {
87 hub := NewHub()
88 if hub.GetRoom("nonexistent") != nil {
89 t.Error("expected nil for unknown room")
90 }
91}
92
93func TestHub_GetRoom_AfterCreate(t *testing.T) {
94 hub := NewHub()
95 hub.GetOrCreateRoom("doc1")
96 if hub.GetRoom("doc1") == nil {
97 t.Error("expected room to be retrievable after creation")
98 }
99}
100
101// --- Room presence tests ---
102
103func TestRoom_RegisterClient_AppearsInPresence(t *testing.T) {
104 hub := NewHub()
105 room := hub.GetOrCreateRoom("doc-presence")
106 c := stubClient(hub, "did:plc:alice", "Alice", "#ff0000", "doc-presence")
107
108 room.RegisterClient(c)
109
110 // Wait for presence broadcast (register → broadcastPresence)
111 msgs := waitForMessages(c, 1, 2*time.Second)
112 if len(msgs) == 0 {
113 t.Fatal("expected presence message after register, got none")
114 }
115
116 var pres PresenceMessage
117 if err := json.Unmarshal(msgs[0], &pres); err != nil {
118 t.Fatalf("unmarshal presence: %v", err)
119 }
120 if pres.Type != "presence" {
121 t.Errorf("expected type=presence, got %q", pres.Type)
122 }
123 if len(pres.Users) != 1 {
124 t.Fatalf("expected 1 user in presence, got %d", len(pres.Users))
125 }
126 if pres.Users[0].DID != "did:plc:alice" {
127 t.Errorf("expected DID did:plc:alice, got %q", pres.Users[0].DID)
128 }
129}
130
131func TestRoom_MultipleClients_PresenceContainsAll(t *testing.T) {
132 hub := NewHub()
133 room := hub.GetOrCreateRoom("doc-multi")
134
135 alice := stubClient(hub, "did:plc:alice", "Alice", "#ff0000", "doc-multi")
136 bob := stubClient(hub, "did:plc:bob", "Bob", "#0000ff", "doc-multi")
137
138 room.RegisterClient(alice)
139 // Drain alice's initial presence (just herself)
140 waitForMessages(alice, 1, time.Second)
141
142 room.RegisterClient(bob)
143 // Both get a new presence broadcast; wait for 1 more on each
144 aliceMsgs := waitForMessages(alice, 1, 2*time.Second)
145 bobMsgs := waitForMessages(bob, 1, 2*time.Second)
146
147 checkPresenceCount := func(name string, msgs [][]byte, want int) {
148 t.Helper()
149 if len(msgs) == 0 {
150 t.Fatalf("%s: expected presence message, got none", name)
151 }
152 var pres PresenceMessage
153 if err := json.Unmarshal(msgs[len(msgs)-1], &pres); err != nil {
154 t.Fatalf("%s: unmarshal: %v", name, err)
155 }
156 if len(pres.Users) != want {
157 t.Errorf("%s: expected %d users in presence, got %d", name, want, len(pres.Users))
158 }
159 }
160
161 checkPresenceCount("alice", aliceMsgs, 2)
162 checkPresenceCount("bob", bobMsgs, 2)
163}
164
165func TestRoom_UnregisterClient_RemovedFromPresence(t *testing.T) {
166 hub := NewHub()
167 room := hub.GetOrCreateRoom("doc-unreg")
168
169 alice := stubClient(hub, "did:plc:alice", "Alice", "#ff0000", "doc-unreg")
170 bob := stubClient(hub, "did:plc:bob", "Bob", "#0000ff", "doc-unreg")
171
172 room.RegisterClient(alice)
173 room.RegisterClient(bob)
174 // Drain initial presence messages
175 time.Sleep(100 * time.Millisecond)
176 drain(alice, 200*time.Millisecond)
177 drain(bob, 200*time.Millisecond)
178
179 // Now unregister alice
180 room.UnregisterClient(alice)
181
182 // Bob should receive a presence update with 1 user
183 msgs := waitForMessages(bob, 1, 2*time.Second)
184 if len(msgs) == 0 {
185 t.Fatal("expected presence update after unregister, got none")
186 }
187 var pres PresenceMessage
188 if err := json.Unmarshal(msgs[len(msgs)-1], &pres); err != nil {
189 t.Fatalf("unmarshal: %v", err)
190 }
191 if len(pres.Users) != 1 {
192 t.Errorf("expected 1 user after alice unregisters, got %d", len(pres.Users))
193 }
194 if pres.Users[0].DID != "did:plc:bob" {
195 t.Errorf("expected bob to remain, got %q", pres.Users[0].DID)
196 }
197}
198
199// --- Room broadcast tests ---
200
201func TestRoom_Broadcast_SendsToAll(t *testing.T) {
202 hub := NewHub()
203 room := hub.GetOrCreateRoom("doc-broadcast")
204
205 alice := stubClient(hub, "did:plc:alice", "Alice", "#ff0000", "doc-broadcast")
206 bob := stubClient(hub, "did:plc:bob", "Bob", "#0000ff", "doc-broadcast")
207
208 room.RegisterClient(alice)
209 room.RegisterClient(bob)
210 // Drain presence messages
211 time.Sleep(100 * time.Millisecond)
212 drain(alice, 200*time.Millisecond)
213 drain(bob, 200*time.Millisecond)
214
215 room.Broadcast([]byte(`{"type":"ping"}`))
216
217 aliceMsgs := waitForMessages(alice, 1, time.Second)
218 bobMsgs := waitForMessages(bob, 1, time.Second)
219
220 if len(aliceMsgs) == 0 {
221 t.Error("alice: expected broadcast message")
222 }
223 if len(bobMsgs) == 0 {
224 t.Error("bob: expected broadcast message")
225 }
226}
227
228func TestRoom_BroadcastExcept_SkipsSender(t *testing.T) {
229 hub := NewHub()
230 room := hub.GetOrCreateRoom("doc-except")
231
232 alice := stubClient(hub, "did:plc:alice", "Alice", "#ff0000", "doc-except")
233 bob := stubClient(hub, "did:plc:bob", "Bob", "#0000ff", "doc-except")
234
235 room.RegisterClient(alice)
236 room.RegisterClient(bob)
237 time.Sleep(100 * time.Millisecond)
238 drain(alice, 200*time.Millisecond)
239 drain(bob, 200*time.Millisecond)
240
241 room.BroadcastExcept([]byte(`{"type":"edit"}`), alice)
242
243 // Bob should receive it; alice should not
244 bobMsgs := waitForMessages(bob, 1, time.Second)
245 aliceMsgs := drain(alice, 300*time.Millisecond)
246
247 if len(bobMsgs) == 0 {
248 t.Error("bob: expected broadcast message")
249 }
250 if len(aliceMsgs) > 0 {
251 t.Errorf("alice: should not receive her own broadcast, got %d messages", len(aliceMsgs))
252 }
253}
254
255// --- GetPresence ---
256
257func TestRoom_GetPresence_Empty(t *testing.T) {
258 hub := NewHub()
259 room := hub.GetOrCreateRoom("doc-empty-presence")
260 users := room.GetPresence()
261 if len(users) != 0 {
262 t.Errorf("expected 0 users in new room, got %d", len(users))
263 }
264}