feat(realtime): implement SSE fallback, dual-transport hub & proxy resilience (resolves FLAW-03)
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 59s
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 59s
This commit is contained in:
1 parent
80bb74b5b2
commit
6343d62bab
10 files changed
+1402
-240
No files matched your search
@@ -2,6 +2,7 @@ package api
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
@@ -9,6 +10,7 @@ import (
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/sandrodach/mindsift/internal/auth"
|
||||
"github.com/sandrodach/mindsift/internal/config"
|
||||
@@ -908,3 +910,108 @@ func TestDocumentCRDTEndpoints(t *testing.T) {
|
||||
t.Fatalf("expected state 'AQIDBA==', got '%s'", crdtResp.State)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSSEAndAwarenessEndpoints(t *testing.T) {
|
||||
dbPath := filepath.Join(t.TempDir(), "test_sse.db")
|
||||
store, err := storage.NewStore(dbPath)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to init store: %v", err)
|
||||
}
|
||||
defer store.Close()
|
||||
|
||||
hub := ws.NewHub()
|
||||
hub.SetCRDTStore(store)
|
||||
cfg := &config.Config{
|
||||
JWTSecret: "test-jwt-secret-key",
|
||||
AllowRegistration: false,
|
||||
}
|
||||
srv := NewServer(cfg, store, hub, nil)
|
||||
router := srv.Router()
|
||||
|
||||
// 1. Setup user
|
||||
setupBody := []byte(`{
|
||||
"email": "sse_user@example.com",
|
||||
"password": "secretpassword",
|
||||
"displayName": "SSE User",
|
||||
"instanceName": "MindSift SSE",
|
||||
"allowRegistration": false,
|
||||
"seedSampleData": false
|
||||
}`)
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/auth/setup", bytes.NewReader(setupBody))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
rec := httptest.NewRecorder()
|
||||
router.ServeHTTP(rec, req)
|
||||
if rec.Code != http.StatusCreated {
|
||||
t.Fatalf("setup failed: %s", rec.Body.String())
|
||||
}
|
||||
var authResp struct {
|
||||
Token string `json:"token"`
|
||||
User *domain.User `json:"user"`
|
||||
}
|
||||
_ = json.Unmarshal(rec.Body.Bytes(), &authResp)
|
||||
token := authResp.Token
|
||||
|
||||
// 2. Create Document
|
||||
doc, err := store.CreateDocument(authResp.User.ID, "", "SSE Collaborative Note", "📄", "", nil, nil, false)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create document: %v", err)
|
||||
}
|
||||
|
||||
// 3. Test Awareness POST and DELETE
|
||||
awarenessBody := []byte(`{"userId":"` + authResp.User.ID + `","activeBlockId":"blk_123"}`)
|
||||
req = httptest.NewRequest(http.MethodPost, "/api/v1/documents/"+doc.ID+"/awareness", bytes.NewReader(awarenessBody))
|
||||
req.Header.Set("Authorization", "Bearer "+token)
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
rec = httptest.NewRecorder()
|
||||
router.ServeHTTP(rec, req)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("expected 200 OK from POST awareness, got %d: %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
|
||||
req = httptest.NewRequest(http.MethodDelete, "/api/v1/documents/"+doc.ID+"/awareness", nil)
|
||||
req.Header.Set("Authorization", "Bearer "+token)
|
||||
rec = httptest.NewRecorder()
|
||||
router.ServeHTTP(rec, req)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("expected 200 OK from DELETE awareness, got %d", rec.Code)
|
||||
}
|
||||
|
||||
// 4. Test Delta CRDT Update via PUT /api/v1/documents/{id}/crdt
|
||||
updateBody := []byte(`{"update":"AQIDBA=="}`)
|
||||
req = httptest.NewRequest(http.MethodPut, "/api/v1/documents/"+doc.ID+"/crdt", bytes.NewReader(updateBody))
|
||||
req.Header.Set("Authorization", "Bearer "+token)
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
rec = httptest.NewRecorder()
|
||||
router.ServeHTTP(rec, req)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("expected 200 OK from PUT crdt update, got %d: %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
|
||||
// 5. Test SSE Connection GET /api/v1/events
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
|
||||
req = httptest.NewRequest(http.MethodGet, "/api/v1/events?token="+token+"&docId="+doc.ID, nil).WithContext(ctx)
|
||||
rec = httptest.NewRecorder()
|
||||
|
||||
go func() {
|
||||
// Cancel context after short moment to terminate stream cleanly
|
||||
time.Sleep(50 * time.Millisecond)
|
||||
cancel()
|
||||
}()
|
||||
|
||||
router.ServeHTTP(rec, req)
|
||||
|
||||
contentType := rec.Header().Get("Content-Type")
|
||||
if !strings.Contains(contentType, "text/event-stream") {
|
||||
t.Fatalf("expected Content-Type text/event-stream, got %s", contentType)
|
||||
}
|
||||
if rec.Header().Get("X-Accel-Buffering") != "no" {
|
||||
t.Fatalf("expected X-Accel-Buffering: no, got %s", rec.Header().Get("X-Accel-Buffering"))
|
||||
}
|
||||
bodyStr := rec.Body.String()
|
||||
if !strings.Contains(bodyStr, "SSE_CONNECTED") {
|
||||
t.Fatalf("expected SSE_CONNECTED event in body, got: %s", bodyStr)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user