proxy_integration_test.go
1package ysweet_test
2
3import (
4 "context"
5 "fmt"
6 "net"
7 "net/http"
8 "net/http/httptest"
9 "os"
10 "os/exec"
11 "strings"
12 "syscall"
13 "testing"
14 "time"
15
16 "git.kilimanjaro.io/ygo"
17 "git.kilimanjaro.io/ygo/ysweet"
18 "github.com/coder/websocket"
19)
20
21func skipIfNoIntegrationTests(t *testing.T) {
22 if os.Getenv("RUN_INTEGRATION_TESTS") == "" {
23 t.Skip("Skipping integration test: set RUN_INTEGRATION_TESTS=1 to run")
24 }
25}
26
27// getFreePort returns a random available port
28func getFreePort() (int, error) {
29 addr, err := net.ResolveTCPAddr("tcp", "localhost:0")
30 if err != nil {
31 return 0, err
32 }
33 l, err := net.ListenTCP("tcp", addr)
34 if err != nil {
35 return 0, err
36 }
37 defer l.Close()
38 return l.Addr().(*net.TCPAddr).Port, nil
39}
40
41// TestProxyHandler_Integration tests the proxy handler with a live y-sweet server.
42// It starts a y-sweet server, creates a proxy in front of it, and verifies that:
43// 1. Two clients can connect through the proxy to the same document
44// 2. Updates from one client are received by the other
45// 3. Hooks fire correctly on connect and disconnect
46func TestProxyHandler_Integration(t *testing.T) {
47 skipIfNoIntegrationTests(t)
48
49 // Get a random available port
50 port, err := getFreePort()
51 if err != nil {
52 t.Fatalf("failed to get free port: %v", err)
53 }
54
55 // Start y-sweet server on random port
56 ctx, cancel := context.WithCancel(context.Background())
57 defer cancel()
58
59 cmd := exec.CommandContext(ctx, "pnpx", "y-sweet@latest", "serve", fmt.Sprintf("--port=%d", port))
60 cmd.SysProcAttr = &syscall.SysProcAttr{
61 Setpgid: true,
62 }
63
64 if err := cmd.Start(); err != nil {
65 t.Fatalf("failed to start y-sweet server: %v", err)
66 }
67
68 // Give server time to start
69 time.Sleep(10 * time.Second)
70
71 // Create y-sweet client to create a document
72 client, err := ysweet.NewClient(fmt.Sprintf("http://127.0.0.1:%d", port))
73 if err != nil {
74 t.Fatalf("failed to create y-sweet client: %v", err)
75 }
76
77 docID, err := client.NewDoc("")
78 if err != nil {
79 t.Fatalf("failed to create document: %v", err)
80 }
81
82 // Track hook invocations
83 connectHookCalls := make(chan string, 10)
84 disconnectHookCalls := make(chan string, 10)
85 upgradeHookCalls := make(chan string, 10)
86
87 // Create proxy handler in front of y-sweet server
88 proxyHandler := ysweet.ProxyHandler(fmt.Sprintf("ws://127.0.0.1:%d", port),
89 ysweet.WithOnConnect(func(ctx context.Context, docID string, w http.ResponseWriter, r *http.Request) error {
90 connectHookCalls <- docID
91 return nil
92 }),
93 ysweet.WithOnWebsocketUpgrade(func(ctx context.Context, docID string, clientConn, upstreamConn *websocket.Conn) error {
94 upgradeHookCalls <- docID
95 return nil
96 }),
97 ysweet.WithOnDisconnect(func(ctx context.Context, docID string, reason ysweet.DisconnectReason) error {
98 disconnectHookCalls <- docID
99 return nil
100 }),
101 )
102
103 // Start proxy server
104 proxyServer := httptest.NewServer(proxyHandler)
105 defer proxyServer.Close()
106
107 // Convert http:// to ws:// for proxy URL
108 proxyURL := strings.Replace(proxyServer.URL, "http://", "ws://", 1)
109
110 // Create first client document
111 doc1, err := ygo.NewDoc()
112 if err != nil {
113 t.Fatalf("failed to create doc1: %v", err)
114 }
115 defer doc1.Destroy()
116
117 // Connect first client through proxy
118 syncClient1, err := ygo.NewSyncClient(doc1,
119 ygo.WithSyncEndpoint(proxyURL+"/d/"+docID+"/ws/"+docID),
120 )
121 if err != nil {
122 t.Fatalf("failed to create sync client 1: %v", err)
123 }
124
125 connectCtx1, cancel1 := context.WithTimeout(context.Background(), 5*time.Second)
126 defer cancel1()
127
128 if err := syncClient1.Connect(connectCtx1); err != nil {
129 t.Fatalf("failed to connect client 1: %v", err)
130 }
131 defer syncClient1.Close()
132
133 // Wait for first client's hooks to fire
134 select {
135 case receivedDocID := <-connectHookCalls:
136 if receivedDocID != docID {
137 t.Errorf("client 1 connect hook: expected docID %s, got %s", docID, receivedDocID)
138 }
139 t.Logf("✓ Client 1 connect hook fired for doc %s", receivedDocID)
140 case <-time.After(2 * time.Second):
141 t.Fatal("timeout waiting for client 1 connect hook")
142 }
143
144 select {
145 case receivedDocID := <-upgradeHookCalls:
146 if receivedDocID != docID {
147 t.Errorf("client 1 upgrade hook: expected docID %s, got %s", docID, receivedDocID)
148 }
149 t.Logf("✓ Client 1 upgrade hook fired for doc %s", receivedDocID)
150 case <-time.After(2 * time.Second):
151 t.Fatal("timeout waiting for client 1 upgrade hook")
152 }
153
154 // Create second client document
155 doc2, err := ygo.NewDoc()
156 if err != nil {
157 t.Fatalf("failed to create doc2: %v", err)
158 }
159 defer doc2.Destroy()
160
161 // Connect second client through proxy (same document)
162 syncClient2, err := ygo.NewSyncClient(doc2,
163 ygo.WithSyncEndpoint(proxyURL+"/d/"+docID+"/ws/"+docID),
164 )
165 if err != nil {
166 t.Fatalf("failed to create sync client 2: %v", err)
167 }
168
169 connectCtx2, cancel2 := context.WithTimeout(context.Background(), 5*time.Second)
170 defer cancel2()
171
172 if err := syncClient2.Connect(connectCtx2); err != nil {
173 t.Fatalf("failed to connect client 2: %v", err)
174 }
175 defer syncClient2.Close()
176
177 // Wait for second client's hooks to fire
178 select {
179 case receivedDocID := <-connectHookCalls:
180 if receivedDocID != docID {
181 t.Errorf("client 2 connect hook: expected docID %s, got %s", docID, receivedDocID)
182 }
183 t.Logf("✓ Client 2 connect hook fired for doc %s", receivedDocID)
184 case <-time.After(2 * time.Second):
185 t.Fatal("timeout waiting for client 2 connect hook")
186 }
187
188 select {
189 case receivedDocID := <-upgradeHookCalls:
190 if receivedDocID != docID {
191 t.Errorf("client 2 upgrade hook: expected docID %s, got %s", docID, receivedDocID)
192 }
193 t.Logf("✓ Client 2 upgrade hook fired for doc %s", receivedDocID)
194 case <-time.After(2 * time.Second):
195 t.Fatal("timeout waiting for client 2 upgrade hook")
196 }
197
198 // Give clients time to sync
199 time.Sleep(500 * time.Millisecond)
200
201 // Make an update on client 1
202 txt1, err := doc1.GetText("content")
203 if err != nil {
204 t.Fatalf("failed to get text from doc1: %v", err)
205 }
206
207 err = doc1.WithWriteTransaction(func(txn *ygo.Transaction) error {
208 return txt1.Insert(txn, 0, "Hello from client 1!")
209 })
210 if err != nil {
211 t.Fatalf("failed to insert text: %v", err)
212 }
213
214 // Wait for sync
215 time.Sleep(1 * time.Second)
216
217 // Verify client 2 received the update
218 txt2, err := doc2.GetText("content")
219 if err != nil {
220 t.Fatalf("failed to get text from doc2: %v", err)
221 }
222
223 var content2 string
224 err = doc2.WithReadTransaction(func(txn *ygo.Transaction) error {
225 var err error
226 content2, err = txt2.String(txn)
227 return err
228 })
229 if err != nil {
230 t.Fatalf("failed to get string from text: %v", err)
231 }
232
233 if content2 != "Hello from client 1!" {
234 t.Errorf("expected 'Hello from client 1!', got '%s'", content2)
235 }
236 t.Logf("✓ Client 2 received update from client 1: '%s'", content2)
237
238 // Make an update on client 2
239 err = doc2.WithWriteTransaction(func(txn *ygo.Transaction) error {
240 return txt2.Insert(txn, uint32(len(content2)), " And hello from client 2!")
241 })
242 if err != nil {
243 t.Fatalf("failed to insert text: %v", err)
244 }
245
246 // Wait for sync
247 time.Sleep(1 * time.Second)
248
249 // Verify client 1 received the update
250 var content1 string
251 err = doc1.WithReadTransaction(func(txn *ygo.Transaction) error {
252 var err error
253 content1, err = txt1.String(txn)
254 return err
255 })
256 if err != nil {
257 t.Fatalf("failed to get string from text: %v", err)
258 }
259
260 expectedContent := "Hello from client 1! And hello from client 2!"
261 if content1 != expectedContent {
262 t.Errorf("expected '%s', got '%s'", expectedContent, content1)
263 }
264 t.Logf("✓ Client 1 received update from client 2: '%s'", content1)
265
266 // Close first client and verify disconnect hook
267 syncClient1.Close()
268
269 select {
270 case receivedDocID := <-disconnectHookCalls:
271 if receivedDocID != docID {
272 t.Errorf("client 1 disconnect hook: expected docID %s, got %s", docID, receivedDocID)
273 }
274 t.Logf("✓ Client 1 disconnect hook fired for doc %s", receivedDocID)
275 case <-time.After(2 * time.Second):
276 t.Fatal("timeout waiting for client 1 disconnect hook")
277 }
278
279 // Close second client and verify disconnect hook
280 syncClient2.Close()
281
282 select {
283 case receivedDocID := <-disconnectHookCalls:
284 if receivedDocID != docID {
285 t.Errorf("client 2 disconnect hook: expected docID %s, got %s", docID, receivedDocID)
286 }
287 t.Logf("✓ Client 2 disconnect hook fired for doc %s", receivedDocID)
288 case <-time.After(2 * time.Second):
289 t.Fatal("timeout waiting for client 2 disconnect hook")
290 }
291
292 t.Logf("✓ Integration test completed successfully")
293
294 // Cleanup server
295 if err := syscall.Kill(-cmd.Process.Pid, syscall.SIGINT); err != nil {
296 t.Logf("warning: failed to kill server: %v", err)
297 }
298}
299
300// TestProxyHandler_IntegrationWithCustomURLPattern tests the proxy with a custom URL pattern.
301func TestProxyHandler_IntegrationWithCustomURLPattern(t *testing.T) {
302 skipIfNoIntegrationTests(t)
303
304 // Get a random available port
305 port, err := getFreePort()
306 if err != nil {
307 t.Fatalf("failed to get free port: %v", err)
308 }
309
310 // Start y-sweet server on random port
311 ctx, cancel := context.WithCancel(context.Background())
312 defer cancel()
313
314 cmd := exec.CommandContext(ctx, "pnpx", "y-sweet@latest", "serve", fmt.Sprintf("--port=%d", port))
315 cmd.SysProcAttr = &syscall.SysProcAttr{
316 Setpgid: true,
317 }
318
319 if err := cmd.Start(); err != nil {
320 t.Fatalf("failed to start y-sweet server: %v", err)
321 }
322
323 // Give server time to start
324 time.Sleep(10 * time.Second)
325
326 // Create y-sweet client to create a document
327 client, err := ysweet.NewClient(fmt.Sprintf("http://127.0.0.1:%d", port))
328 if err != nil {
329 t.Fatalf("failed to create y-sweet client: %v", err)
330 }
331
332 docID, err := client.NewDoc("")
333 if err != nil {
334 t.Fatalf("failed to create document: %v", err)
335 }
336
337 // Track hook invocations
338 connectHookCalls := make(chan string, 10)
339
340 // Create proxy handler with custom URL pattern: /api/doc/{docID}
341 proxyHandler := ysweet.ProxyHandler(fmt.Sprintf("ws://127.0.0.1:%d", port),
342 ysweet.WithDocIDFunc(func(r *http.Request) string {
343 // Extract docID from /api/doc/{docID}
344 path := r.URL.Path
345 prefix := "/api/doc/"
346 if strings.HasPrefix(path, prefix) {
347 docID := path[len(prefix):]
348 // Remove any trailing segments
349 if idx := strings.Index(docID, "/"); idx != -1 {
350 docID = docID[:idx]
351 }
352 return docID
353 }
354 return ""
355 }),
356 ysweet.WithOnConnect(func(ctx context.Context, receivedDocID string, w http.ResponseWriter, r *http.Request) error {
357 connectHookCalls <- receivedDocID
358 return nil
359 }),
360 )
361
362 // Start proxy server
363 proxyServer := httptest.NewServer(proxyHandler)
364 defer proxyServer.Close()
365
366 // Convert http:// to ws:// for proxy URL
367 proxyURL := strings.Replace(proxyServer.URL, "http://", "ws://", 1)
368
369 // Create client document
370 doc, err := ygo.NewDoc()
371 if err != nil {
372 t.Fatalf("failed to create doc: %v", err)
373 }
374 defer doc.Destroy()
375
376 // Connect through proxy using custom URL pattern
377 syncClient, err := ygo.NewSyncClient(doc,
378 ygo.WithSyncEndpoint(proxyURL+"/api/doc/"+docID),
379 )
380 if err != nil {
381 t.Fatalf("failed to create sync client: %v", err)
382 }
383
384 connectCtx, cancelConnect := context.WithTimeout(context.Background(), 5*time.Second)
385 defer cancelConnect()
386
387 if err := syncClient.Connect(connectCtx); err != nil {
388 t.Fatalf("failed to connect: %v", err)
389 }
390 defer syncClient.Close()
391
392 // Wait for connect hook to fire with correct docID
393 select {
394 case receivedDocID := <-connectHookCalls:
395 if receivedDocID != docID {
396 t.Errorf("connect hook: expected docID %s, got %s", docID, receivedDocID)
397 }
398 t.Logf("✓ Connect hook fired for doc %s (via custom URL pattern)", receivedDocID)
399 case <-time.After(2 * time.Second):
400 t.Fatal("timeout waiting for connect hook")
401 }
402
403 t.Logf("✓ Custom URL pattern integration test completed successfully")
404
405 // Cleanup server
406 if err := syscall.Kill(-cmd.Process.Pid, syscall.SIGINT); err != nil {
407 t.Logf("warning: failed to kill server: %v", err)
408 }
409}