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}