feat(workspace): tabula-rasa outliner architecture, SQLite blocks/block_tags schema & echo-protected live room sync
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 1m12s
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 1m12s
This commit is contained in:
1 parent
f3b3f07e04
commit
86867b0d6f
17 files changed
+3409
-7178
No files matched your search
+25
-225
@@ -3,7 +3,6 @@ package api
|
||||
import (
|
||||
"bytes"
|
||||
"crypto/tls"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
@@ -32,7 +31,7 @@ type Server struct {
|
||||
|
||||
func NewServer(cfg *config.Config, store *storage.Store, wsHub *ws.Hub, staticFS fs.FS) *Server {
|
||||
if wsHub != nil && store != nil {
|
||||
wsHub.SetCRDTStore(store)
|
||||
wsHub.SetDocStore(store)
|
||||
}
|
||||
srv := &Server{
|
||||
cfg: cfg,
|
||||
@@ -70,8 +69,6 @@ func (s *Server) Router() http.Handler {
|
||||
r.Get("/api/v1/ws/spaces/{spaceId}", s.handleWebSocket)
|
||||
r.Get("/api/v1/ws/lists/{listId}", s.handleWebSocket)
|
||||
r.Get("/api/v1/ws/docs/{id}", s.handleDocWebSocket)
|
||||
r.Get("/api/v1/sync/docs/{id}/stream", s.handleDocSyncStream)
|
||||
r.Post("/api/v1/sync/docs/{id}/update", s.handleDocSyncUpdate)
|
||||
r.Get("/api/v1/events", s.handleSSE)
|
||||
r.Post("/api/v1/events/rooms", s.handleSSERooms)
|
||||
|
||||
@@ -144,18 +141,6 @@ func (s *Server) Router() http.Handler {
|
||||
r.Post("/api/v1/documents/{id}/shares", s.handleCreateDocShare)
|
||||
r.Delete("/api/v1/documents/{id}/shares/{userId}", s.handleDeleteDocShare)
|
||||
|
||||
// Document Locking
|
||||
r.Get("/api/v1/documents/{id}/lock", s.handleGetDocLock)
|
||||
r.Post("/api/v1/documents/{id}/lock", s.handleLockDoc)
|
||||
r.Post("/api/v1/documents/{id}/lock/heartbeat", s.handleHeartbeatDocLock)
|
||||
r.Delete("/api/v1/documents/{id}/lock", s.handleUnlockDoc)
|
||||
|
||||
// Document CRDT Realtime Sync
|
||||
r.Get("/api/v1/documents/{id}/crdt", s.handleGetDocCRDT)
|
||||
r.Put("/api/v1/documents/{id}/crdt", s.handleSaveDocCRDT)
|
||||
r.Post("/api/v1/documents/{id}/awareness", s.handlePostDocAwareness)
|
||||
r.Delete("/api/v1/documents/{id}/awareness", s.handleDeleteDocAwareness)
|
||||
|
||||
// Shared Lists & Realtime Collaboration
|
||||
r.Get("/api/v1/lists", s.handleGetAllLists)
|
||||
r.Post("/api/v1/lists", s.handleCreateList)
|
||||
@@ -864,6 +849,18 @@ func (s *Server) handleGetDocument(w http.ResponseWriter, r *http.Request) {
|
||||
writeError(w, http.StatusNotFound, "Dokument nicht gefunden")
|
||||
return
|
||||
}
|
||||
if s.wsHub != nil {
|
||||
if liveTitle, liveBlocks, ok := s.wsHub.GetActiveDocState(docID); ok {
|
||||
if liveTitle != "" {
|
||||
doc.Title = liveTitle
|
||||
}
|
||||
if len(liveBlocks) > 0 {
|
||||
doc.Blocks = liveBlocks
|
||||
doc.MarkdownCache = domain.CompileBlocksToMarkdown(liveBlocks)
|
||||
doc.Content = doc.MarkdownCache
|
||||
}
|
||||
}
|
||||
}
|
||||
writeJSON(w, http.StatusOK, doc)
|
||||
}
|
||||
|
||||
@@ -889,6 +886,9 @@ func (s *Server) handleUpdateDocument(w http.ResponseWriter, r *http.Request) {
|
||||
writeError(w, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
if s.wsHub != nil && (req.Title != nil || req.Blocks != nil) {
|
||||
s.wsHub.SyncActiveDocRoom(docID, doc.Title, doc.Blocks)
|
||||
}
|
||||
if req.Title != nil || req.Icon != nil || req.FolderPath != nil || req.Blocks != nil || req.Tags != nil {
|
||||
s.wsHub.BroadcastDocEvent(docID, "DOC_UPDATED", doc)
|
||||
s.broadcastDocToAuthorizedUsers(docID, "DOC_UPDATED")
|
||||
@@ -911,6 +911,9 @@ func (s *Server) handlePatchDocumentBlocks(w http.ResponseWriter, r *http.Reques
|
||||
writeError(w, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
if s.wsHub != nil {
|
||||
s.wsHub.SyncActiveDocRoom(docID, doc.Title, doc.Blocks)
|
||||
}
|
||||
|
||||
patchEvent := domain.DocBlocksPatchEvent{
|
||||
DocID: docID,
|
||||
@@ -933,6 +936,9 @@ func (s *Server) handlePatchDocumentBlocks(w http.ResponseWriter, r *http.Reques
|
||||
func (s *Server) handleDeleteDocument(w http.ResponseWriter, r *http.Request) {
|
||||
userID := auth.GetUserID(r.Context())
|
||||
docID := chi.URLParam(r, "id")
|
||||
if s.wsHub != nil {
|
||||
s.wsHub.CloseDocRoom(docID)
|
||||
}
|
||||
if err := s.store.DeleteDocument(userID, docID); err != nil {
|
||||
writeError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
@@ -1307,30 +1313,7 @@ func (s *Server) handleGetCollaborators(w http.ResponseWriter, r *http.Request)
|
||||
writeJSON(w, http.StatusOK, users)
|
||||
}
|
||||
|
||||
// --- DOCUMENT LOCKING (Neutralized - Yjs CRDT Pessimistic Locks Removed) ---
|
||||
|
||||
func (s *Server) handleGetDocLock(w http.ResponseWriter, r *http.Request) {
|
||||
writeJSON(w, http.StatusOK, map[string]interface{}{"locked": false})
|
||||
}
|
||||
|
||||
func (s *Server) handleLockDoc(w http.ResponseWriter, r *http.Request) {
|
||||
docID := chi.URLParam(r, "id")
|
||||
writeJSON(w, http.StatusOK, map[string]interface{}{
|
||||
"locked": false,
|
||||
"acquired": true,
|
||||
"docId": docID,
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Server) handleHeartbeatDocLock(w http.ResponseWriter, r *http.Request) {
|
||||
writeJSON(w, http.StatusOK, map[string]bool{"extended": true})
|
||||
}
|
||||
|
||||
func (s *Server) handleUnlockDoc(w http.ResponseWriter, r *http.Request) {
|
||||
writeJSON(w, http.StatusOK, map[string]bool{"unlocked": true})
|
||||
}
|
||||
|
||||
// --- DOCUMENT CRDT REALTIME SYNC & DUAL TRANSPORT ---
|
||||
// --- DOCUMENT REALTIME WEBSOCKET ROOM ---
|
||||
|
||||
func (s *Server) authenticateUserFromReq(r *http.Request) string {
|
||||
token := r.URL.Query().Get("token")
|
||||
@@ -1379,192 +1362,9 @@ func (s *Server) handleDocWebSocket(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
}
|
||||
|
||||
clientID := r.URL.Query().Get("clientId")
|
||||
room := s.wsHub.GetOrCreateDocRoom(docID)
|
||||
room.ServeDocWS(w, r, userID)
|
||||
}
|
||||
|
||||
// GET /api/v1/sync/docs/{id}/stream
|
||||
func (s *Server) handleDocSyncStream(w http.ResponseWriter, r *http.Request) {
|
||||
docID := chi.URLParam(r, "id")
|
||||
if docID == "" {
|
||||
writeError(w, http.StatusBadRequest, "docId erforderlich")
|
||||
return
|
||||
}
|
||||
|
||||
userID := s.authenticateUserFromReq(r)
|
||||
if userID == "" {
|
||||
userID = auth.GetUserID(r.Context())
|
||||
}
|
||||
if userID == "" {
|
||||
userID = "anonymous"
|
||||
}
|
||||
|
||||
if userID != "anonymous" {
|
||||
if _, err := s.store.GetDocumentByID(userID, docID); err != nil {
|
||||
writeError(w, http.StatusForbidden, "Kein Zugriff auf dieses Dokument")
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
room := s.wsHub.GetOrCreateDocRoom(docID)
|
||||
room.ServeDocSSE(w, r, userID)
|
||||
}
|
||||
|
||||
// POST /api/v1/sync/docs/{id}/update
|
||||
func (s *Server) handleDocSyncUpdate(w http.ResponseWriter, r *http.Request) {
|
||||
docID := chi.URLParam(r, "id")
|
||||
if docID == "" {
|
||||
writeError(w, http.StatusBadRequest, "docId erforderlich")
|
||||
return
|
||||
}
|
||||
|
||||
userID := s.authenticateUserFromReq(r)
|
||||
if userID == "" {
|
||||
userID = auth.GetUserID(r.Context())
|
||||
}
|
||||
if userID == "" {
|
||||
writeError(w, http.StatusUnauthorized, "Nicht autorisiert")
|
||||
return
|
||||
}
|
||||
|
||||
doc, err := s.store.GetDocumentByID(userID, docID)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusNotFound, "Dokument nicht gefunden")
|
||||
return
|
||||
}
|
||||
if !doc.IsOwner && doc.Permission != "write" {
|
||||
writeError(w, http.StatusForbidden, "Keine Schreibberechtigung")
|
||||
return
|
||||
}
|
||||
|
||||
contentType := r.Header.Get("Content-Type")
|
||||
var updateBytes []byte
|
||||
|
||||
if strings.Contains(contentType, "application/octet-stream") {
|
||||
body, err := io.ReadAll(io.LimitReader(r.Body, 8*1024*1024))
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadRequest, "Fehler beim Lesen des Payloads")
|
||||
return
|
||||
}
|
||||
updateBytes = body
|
||||
} else {
|
||||
var req struct {
|
||||
Update string `json:"update"`
|
||||
State string `json:"state"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "Ungültiges JSON")
|
||||
return
|
||||
}
|
||||
if req.State != "" {
|
||||
if raw, err := base64.StdEncoding.DecodeString(req.State); err == nil && len(raw) > 0 {
|
||||
room := s.wsHub.GetOrCreateDocRoom(docID)
|
||||
room.CompactState(raw, userID)
|
||||
}
|
||||
}
|
||||
if req.Update != "" {
|
||||
raw, err := base64.StdEncoding.DecodeString(req.Update)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadRequest, "Ungültiges Base64 für Update")
|
||||
return
|
||||
}
|
||||
updateBytes = raw
|
||||
}
|
||||
}
|
||||
|
||||
if len(updateBytes) > 0 {
|
||||
room := s.wsHub.GetOrCreateDocRoom(docID)
|
||||
room.ApplyUpdate(updateBytes, userID, nil, nil)
|
||||
}
|
||||
|
||||
writeJSON(w, http.StatusOK, map[string]interface{}{"success": true, "docId": docID})
|
||||
}
|
||||
|
||||
func (s *Server) handleGetDocCRDT(w http.ResponseWriter, r *http.Request) {
|
||||
userID := auth.GetUserID(r.Context())
|
||||
docID := chi.URLParam(r, "id")
|
||||
if _, err := s.store.GetDocumentByID(userID, docID); err != nil {
|
||||
writeError(w, http.StatusNotFound, "Dokument nicht gefunden")
|
||||
return
|
||||
}
|
||||
room := s.wsHub.GetOrCreateDocRoom(docID)
|
||||
b64State, b64Updates := room.GetInitialSnapshot()
|
||||
writeJSON(w, http.StatusOK, map[string]interface{}{
|
||||
"docId": docID,
|
||||
"state": b64State,
|
||||
"updates": b64Updates,
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Server) handleSaveDocCRDT(w http.ResponseWriter, r *http.Request) {
|
||||
userID := auth.GetUserID(r.Context())
|
||||
docID := chi.URLParam(r, "id")
|
||||
doc, err := s.store.GetDocumentByID(userID, docID)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusNotFound, "Dokument nicht gefunden")
|
||||
return
|
||||
}
|
||||
if !doc.IsOwner && doc.Permission != "write" {
|
||||
writeError(w, http.StatusForbidden, "Keine Schreibberechtigung")
|
||||
return
|
||||
}
|
||||
var req struct {
|
||||
State string `json:"state"`
|
||||
Update string `json:"update"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "Ungültiger Request-Body")
|
||||
return
|
||||
}
|
||||
room := s.wsHub.GetOrCreateDocRoom(docID)
|
||||
if req.Update != "" {
|
||||
raw, err := base64.StdEncoding.DecodeString(req.Update)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadRequest, "Ungültiges Base64 Format für Update")
|
||||
return
|
||||
}
|
||||
room.ApplyUpdate(raw, userID, nil, nil)
|
||||
}
|
||||
if req.State != "" {
|
||||
raw, err := base64.StdEncoding.DecodeString(req.State)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadRequest, "Ungültiges Base64 Format")
|
||||
return
|
||||
}
|
||||
room.CompactState(raw, userID)
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]bool{"success": true})
|
||||
}
|
||||
|
||||
func (s *Server) handlePostDocAwareness(w http.ResponseWriter, r *http.Request) {
|
||||
userID := auth.GetUserID(r.Context())
|
||||
docID := chi.URLParam(r, "id")
|
||||
doc, err := s.store.GetDocumentByID(userID, docID)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusNotFound, "Dokument nicht gefunden")
|
||||
return
|
||||
}
|
||||
_ = doc
|
||||
|
||||
var payload json.RawMessage
|
||||
if err := json.NewDecoder(r.Body).Decode(&payload); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "Ungültiges JSON")
|
||||
return
|
||||
}
|
||||
|
||||
s.wsHub.BroadcastRoom("doc:"+docID, "CRDT_AWARENESS", payload, nil)
|
||||
writeJSON(w, http.StatusOK, map[string]bool{"success": true})
|
||||
}
|
||||
|
||||
func (s *Server) handleDeleteDocAwareness(w http.ResponseWriter, r *http.Request) {
|
||||
userID := auth.GetUserID(r.Context())
|
||||
docID := chi.URLParam(r, "id")
|
||||
|
||||
s.wsHub.BroadcastRoom("doc:"+docID, "CRDT_AWARENESS_LEAVE", map[string]interface{}{
|
||||
"docId": docID,
|
||||
"userId": userID,
|
||||
}, nil)
|
||||
writeJSON(w, http.StatusOK, map[string]bool{"success": true})
|
||||
room.ServeDocWS(w, r, userID, clientID)
|
||||
}
|
||||
|
||||
// --- REALTIME LISTS & SHARING ---
|
||||
|
||||
+50
-155
@@ -3,7 +3,6 @@ package api
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
@@ -842,9 +841,9 @@ func TestWebhookTriageBadgeAndIngestion(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestDocumentCRDTEndpoints(t *testing.T) {
|
||||
func TestDocRoomLiveStateAndDebouncedFlush(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
dbPath := filepath.Join(tmpDir, "crdt_api_test.db")
|
||||
dbPath := filepath.Join(tmpDir, "docroom_test.db")
|
||||
|
||||
store, err := storage.NewStore(dbPath)
|
||||
if err != nil {
|
||||
@@ -860,59 +859,76 @@ func TestDocumentCRDTEndpoints(t *testing.T) {
|
||||
srv := NewServer(cfg, store, hub, nil)
|
||||
router := srv.Router()
|
||||
|
||||
user, err := store.CreateUser("crdt_api@example.com", "hash", "CRDT API User", "member")
|
||||
user, err := store.CreateUser("outliner_ws@example.com", "hash", "WS Outliner User", "member")
|
||||
if err != nil {
|
||||
t.Fatalf("CreateUser failed: %v", err)
|
||||
}
|
||||
token, _ := auth.GenerateToken(cfg.JWTSecret, user.ID, user.Email, user.Role)
|
||||
|
||||
doc, err := store.CreateDocument(user.ID, "", "API CRDT Doc", "📄", "", nil, nil, false)
|
||||
doc, err := store.CreateDocument(user.ID, "", "Initial Title", "📄", "", nil, nil, false)
|
||||
if err != nil {
|
||||
t.Fatalf("CreateDocument failed: %v", err)
|
||||
}
|
||||
|
||||
// 1. Initial GET /api/v1/documents/{id}/crdt
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/v1/documents/"+doc.ID+"/crdt", nil)
|
||||
// 1. Mutate in-memory DocRoom before 1.5s flush fires
|
||||
room := hub.GetOrCreateDocRoom(doc.ID)
|
||||
newContent := "Live RAM Block #ramtag"
|
||||
newLevel := 1
|
||||
room.HandleEvent(ws.DocSyncEvent{
|
||||
Type: "title_updated",
|
||||
ClientID: "cli_1",
|
||||
Title: "RAM Updated Title",
|
||||
}, nil)
|
||||
room.HandleEvent(ws.DocSyncEvent{
|
||||
Type: "block_created",
|
||||
ClientID: "cli_1",
|
||||
AfterBlockID: doc.Blocks[0].ID,
|
||||
Block: &domain.DocumentBlock{
|
||||
ID: "blk_ram_2",
|
||||
Type: "text",
|
||||
Content: newContent,
|
||||
Level: newLevel,
|
||||
},
|
||||
}, nil)
|
||||
|
||||
// 2. Verify GET /api/v1/documents/{id} immediately returns the un-flushed in-memory state
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/v1/documents/"+doc.ID, 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 GET crdt, got %d: %s", rec.Code, rec.Body.String())
|
||||
t.Fatalf("expected 200 OK from GET document, got %d: %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
var liveDoc domain.Document
|
||||
_ = json.Unmarshal(rec.Body.Bytes(), &liveDoc)
|
||||
if liveDoc.Title != "RAM Updated Title" {
|
||||
t.Fatalf("expected live RAM title 'RAM Updated Title', got '%s'", liveDoc.Title)
|
||||
}
|
||||
if len(liveDoc.Blocks) != 2 || liveDoc.Blocks[1].Content != newContent {
|
||||
t.Fatalf("expected 2 live blocks with RAM content, got %+v", liveDoc.Blocks)
|
||||
}
|
||||
|
||||
// 2. PUT /api/v1/documents/{id}/crdt with base64 state
|
||||
savePayload := []byte(`{"state":"AQIDBA=="}`)
|
||||
req = httptest.NewRequest(http.MethodPut, "/api/v1/documents/"+doc.ID+"/crdt", bytes.NewReader(savePayload))
|
||||
req.Header.Set("Authorization", "Bearer "+token)
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
rec = httptest.NewRecorder()
|
||||
router.ServeHTTP(rec, req)
|
||||
// 3. Flush room to SQLite and verify persistence + tag extraction + markdown_cache
|
||||
room.Flush()
|
||||
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("expected 200 OK from PUT crdt, got %d: %s", rec.Code, rec.Body.String())
|
||||
persistedDoc, err := store.GetDocumentByID(user.ID, doc.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("GetDocumentByID failed: %v", err)
|
||||
}
|
||||
|
||||
// 3. Verify state persisted
|
||||
req = httptest.NewRequest(http.MethodGet, "/api/v1/documents/"+doc.ID+"/crdt", 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 GET crdt, got %d", rec.Code)
|
||||
if persistedDoc.Title != "RAM Updated Title" {
|
||||
t.Fatalf("expected persisted title 'RAM Updated Title', got '%s'", persistedDoc.Title)
|
||||
}
|
||||
var crdtResp struct {
|
||||
DocID string `json:"docId"`
|
||||
State string `json:"state"`
|
||||
if !strings.Contains(persistedDoc.MarkdownCache, " - Live RAM Block #ramtag") {
|
||||
t.Fatalf("expected compiled markdown_cache to contain indented bullet, got:\n%s", persistedDoc.MarkdownCache)
|
||||
}
|
||||
_ = json.Unmarshal(rec.Body.Bytes(), &crdtResp)
|
||||
if crdtResp.State != "AQIDBA==" {
|
||||
t.Fatalf("expected state 'AQIDBA==', got '%s'", crdtResp.State)
|
||||
tags, _ := store.GetWorkspaceTags(user.ID)
|
||||
if len(tags) != 1 || tags[0].Tag != "ramtag" {
|
||||
t.Fatalf("expected extracted #ramtag in block_tags, got %+v", tags)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSSEAndAwarenessEndpoints(t *testing.T) {
|
||||
func TestSSEGlobalEndpoint(t *testing.T) {
|
||||
dbPath := filepath.Join(t.TempDir(), "test_sse.db")
|
||||
store, err := storage.NewStore(dbPath)
|
||||
if err != nil {
|
||||
@@ -921,7 +937,6 @@ func TestSSEAndAwarenessEndpoints(t *testing.T) {
|
||||
defer store.Close()
|
||||
|
||||
hub := ws.NewHub()
|
||||
hub.SetCRDTStore(store)
|
||||
cfg := &config.Config{
|
||||
JWTSecret: "test-jwt-secret-key",
|
||||
AllowRegistration: false,
|
||||
@@ -929,7 +944,6 @@ func TestSSEAndAwarenessEndpoints(t *testing.T) {
|
||||
srv := NewServer(cfg, store, hub, nil)
|
||||
router := srv.Router()
|
||||
|
||||
// 1. Setup user
|
||||
setupBody := []byte(`{
|
||||
"email": "sse_user@example.com",
|
||||
"password": "secretpassword",
|
||||
@@ -952,51 +966,13 @@ func TestSSEAndAwarenessEndpoints(t *testing.T) {
|
||||
_ = 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)
|
||||
req = httptest.NewRequest(http.MethodGet, "/api/v1/events?token="+token, nil).WithContext(ctx)
|
||||
rec = httptest.NewRecorder()
|
||||
|
||||
go func() {
|
||||
// Cancel context after short moment to terminate stream cleanly
|
||||
time.Sleep(50 * time.Millisecond)
|
||||
cancel()
|
||||
}()
|
||||
@@ -1007,92 +983,11 @@ func TestSSEAndAwarenessEndpoints(t *testing.T) {
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDocRoomDualTransportAndDebouncedPersistence(t *testing.T) {
|
||||
dbPath := filepath.Join(t.TempDir(), "test_dual_transport.db")
|
||||
store, err := storage.NewStore(dbPath)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to init store: %v", err)
|
||||
}
|
||||
defer store.Close()
|
||||
|
||||
cfg := &config.Config{
|
||||
JWTSecret: "test-secret-dual-transport",
|
||||
AllowRegistration: true,
|
||||
}
|
||||
hub := ws.NewHub()
|
||||
srv := NewServer(cfg, store, hub, nil)
|
||||
router := srv.Router()
|
||||
|
||||
user, _ := store.CreateUser("dual@test.local", "hash", "Dual User", "member")
|
||||
token, _ := auth.GenerateToken(cfg.JWTSecret, user.ID, user.Email, user.Role)
|
||||
|
||||
doc, _ := store.CreateDocument(user.ID, "", "Dual Transport Doc", "📄", "", nil, nil, false)
|
||||
|
||||
// 1. Test POST /api/v1/sync/docs/{id}/update with JSON base64
|
||||
upd1 := base64.StdEncoding.EncodeToString([]byte{0x01, 0x02, 0x03})
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/sync/docs/"+doc.ID+"/update", bytes.NewReader([]byte(`{"update":"`+upd1+`"}`)))
|
||||
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 sync update, got %d: %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
|
||||
// 2. Test POST /api/v1/sync/docs/{id}/update with octet-stream binary
|
||||
binaryUpd := []byte{0x04, 0x05, 0x06}
|
||||
req = httptest.NewRequest(http.MethodPost, "/api/v1/sync/docs/"+doc.ID+"/update", bytes.NewReader(binaryUpd))
|
||||
req.Header.Set("Authorization", "Bearer "+token)
|
||||
req.Header.Set("Content-Type", "application/octet-stream")
|
||||
rec = httptest.NewRecorder()
|
||||
router.ServeHTTP(rec, req)
|
||||
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("expected 200 OK from binary sync update, got %d: %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
|
||||
// 3. Test GET /api/v1/sync/docs/{id}/stream SSE connection
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
|
||||
req = httptest.NewRequest(http.MethodGet, "/api/v1/sync/docs/"+doc.ID+"/stream?token="+token, nil).WithContext(ctx)
|
||||
rec = httptest.NewRecorder()
|
||||
|
||||
go func() {
|
||||
time.Sleep(50 * time.Millisecond)
|
||||
cancel()
|
||||
}()
|
||||
|
||||
router.ServeHTTP(rec, req)
|
||||
|
||||
if !strings.Contains(rec.Header().Get("Content-Type"), "text/event-stream") {
|
||||
t.Fatalf("expected text/event-stream, got %s", rec.Header().Get("Content-Type"))
|
||||
}
|
||||
bodyStr := rec.Body.String()
|
||||
if !strings.Contains(bodyStr, "CRDT_INIT") {
|
||||
t.Fatalf("expected CRDT_INIT in SSE stream, got %s", bodyStr)
|
||||
}
|
||||
|
||||
// 4. Test Lock Neutrality: document update and block patch work without 423 Locked
|
||||
updateReq := httptest.NewRequest(http.MethodPut, "/api/v1/documents/"+doc.ID, bytes.NewReader([]byte(`{"title":"Updated Without Locks"}`)))
|
||||
updateReq.Header.Set("Authorization", "Bearer "+token)
|
||||
updateReq.Header.Set("Content-Type", "application/json")
|
||||
updateRec := httptest.NewRecorder()
|
||||
router.ServeHTTP(updateRec, updateReq)
|
||||
|
||||
if updateRec.Code != http.StatusOK {
|
||||
t.Fatalf("expected 200 OK from document update, got %d: %s", updateRec.Code, updateRec.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in new issue
Block a user