Diffdown is a real-time collaborative Markdown editor/previewer built on the AT Protocol diffdown.com
at main 242 lines 7.3 kB view raw
1package handler_test 2 3import ( 4 "bytes" 5 "context" 6 "encoding/json" 7 "net/http" 8 "net/http/httptest" 9 "os" 10 "testing" 11 12 "github.com/limeleaf/diffdown/internal/auth" 13 "github.com/limeleaf/diffdown/internal/collaboration" 14 "github.com/limeleaf/diffdown/internal/db" 15 "github.com/limeleaf/diffdown/internal/handler" 16 "github.com/limeleaf/diffdown/internal/model" 17) 18 19func setupHandler(t *testing.T) (*handler.Handler, *db.DB) { 20 t.Helper() 21 f, err := os.CreateTemp("", "diffdown-handler-*.db") 22 if err != nil { 23 t.Fatalf("create temp db: %v", err) 24 } 25 t.Cleanup(func() { os.Remove(f.Name()) }) 26 f.Close() 27 28 database, err := db.Open(f.Name()) 29 if err != nil { 30 t.Fatalf("open db: %v", err) 31 } 32 db.SetMigrationsDir("../../migrations") 33 if err := database.Migrate(); err != nil { 34 t.Fatalf("migrate: %v", err) 35 } 36 37 auth.InitStore("test-secret") 38 hub := collaboration.NewHub() 39 h := handler.New(database, nil, "http://localhost:8080", hub) 40 return h, database 41} 42 43func createTestUser(t *testing.T, d *db.DB) *model.User { 44 t.Helper() 45 user := &model.User{ 46 DID: "did:plc:testuser123", 47 } 48 if err := d.CreateUser(user); err != nil { 49 t.Fatalf("create user: %v", err) 50 } 51 return user 52} 53 54func withAuth(r *http.Request, userID string) *http.Request { 55 ctx := context.WithValue(r.Context(), auth.UserIDKey, userID) 56 return r.WithContext(ctx) 57} 58 59// TestGetSteps_Unauthenticated verifies that unauthenticated requests return 401. 60func TestGetSteps_Unauthenticated(t *testing.T) { 61 h, _ := setupHandler(t) 62 req := httptest.NewRequest("GET", "/api/docs/rkey1/steps?since=0", nil) 63 req.SetPathValue("rkey", "rkey1") 64 rr := httptest.NewRecorder() 65 66 h.GetSteps(rr, req) 67 68 if rr.Code != http.StatusUnauthorized { 69 t.Errorf("expected 401 without auth, got %d", rr.Code) 70 } 71} 72 73// TestGetSteps_EmptyDoc verifies that an authenticated request for a document 74// with no steps returns version 0 and an empty steps array. 75func TestGetSteps_EmptyDoc(t *testing.T) { 76 h, d := setupHandler(t) 77 user := createTestUser(t, d) 78 79 req := httptest.NewRequest("GET", "/api/docs/rkey1/steps?since=0", nil) 80 req.SetPathValue("rkey", "rkey1") 81 req = withAuth(req, user.ID) 82 rr := httptest.NewRecorder() 83 84 h.GetSteps(rr, req) 85 86 if rr.Code != http.StatusOK { 87 t.Fatalf("expected 200, got %d", rr.Code) 88 } 89 90 var resp map[string]interface{} 91 if err := json.NewDecoder(rr.Body).Decode(&resp); err != nil { 92 t.Fatalf("decode response: %v", err) 93 } 94 95 if version, ok := resp["version"].(float64); !ok || int(version) != 0 { 96 t.Errorf("expected version 0, got %v", resp["version"]) 97 } 98 99 steps, ok := resp["steps"].([]interface{}) 100 if !ok { 101 t.Fatalf("expected steps array, got %T", resp["steps"]) 102 } 103 if len(steps) != 0 { 104 t.Errorf("expected empty steps array, got %d steps", len(steps)) 105 } 106} 107 108// TestSubmitSteps_Unauthenticated verifies that unauthenticated requests return 401. 109func TestSubmitSteps_Unauthenticated(t *testing.T) { 110 h, _ := setupHandler(t) 111 112 body, _ := json.Marshal(map[string]interface{}{ 113 "clientVersion": 0, 114 "steps": []string{`{"stepType":"replace"}`}, 115 "clientID": "did:plc:test", 116 }) 117 req := httptest.NewRequest("POST", "/api/docs/rkey1/steps", bytes.NewReader(body)) 118 req.SetPathValue("rkey", "rkey1") 119 req.Header.Set("Content-Type", "application/json") 120 rr := httptest.NewRecorder() 121 122 h.SubmitSteps(rr, req) 123 124 if rr.Code != http.StatusUnauthorized { 125 t.Errorf("expected 401, got %d", rr.Code) 126 } 127} 128 129// TestSubmitSteps_Success verifies that an authenticated user can submit steps 130// and receive the new version number. 131func TestSubmitSteps_Success(t *testing.T) { 132 h, d := setupHandler(t) 133 user := createTestUser(t, d) 134 135 body, _ := json.Marshal(map[string]interface{}{ 136 "clientVersion": 0, 137 "steps": []string{`{"stepType":"replace","from":0,"to":0,"slice":{"content":[{"type":"text","text":"hello"}]}}`}, 138 "clientID": "did:plc:test", 139 }) 140 req := httptest.NewRequest("POST", "/api/docs/rkey1/steps", bytes.NewReader(body)) 141 req.SetPathValue("rkey", "rkey1") 142 req.Header.Set("Content-Type", "application/json") 143 req = withAuth(req, user.ID) 144 rr := httptest.NewRecorder() 145 146 h.SubmitSteps(rr, req) 147 148 if rr.Code != http.StatusOK { 149 t.Fatalf("expected 200, got %d: %s", rr.Code, rr.Body.String()) 150 } 151 152 var resp map[string]interface{} 153 if err := json.NewDecoder(rr.Body).Decode(&resp); err != nil { 154 t.Fatalf("decode response: %v", err) 155 } 156 157 if version, ok := resp["version"].(float64); !ok || int(version) != 1 { 158 t.Errorf("expected version 1, got %v", resp["version"]) 159 } 160} 161 162// TestSubmitSteps_NoSteps verifies that submitting an empty steps array returns 400. 163func TestSubmitSteps_NoSteps(t *testing.T) { 164 h, d := setupHandler(t) 165 user := createTestUser(t, d) 166 167 body, _ := json.Marshal(map[string]interface{}{ 168 "clientVersion": 0, 169 "steps": []string{}, 170 "clientID": "did:plc:test", 171 }) 172 req := httptest.NewRequest("POST", "/api/docs/rkey1/steps", bytes.NewReader(body)) 173 req.SetPathValue("rkey", "rkey1") 174 req.Header.Set("Content-Type", "application/json") 175 req = withAuth(req, user.ID) 176 rr := httptest.NewRecorder() 177 178 h.SubmitSteps(rr, req) 179 180 if rr.Code != http.StatusBadRequest { 181 t.Errorf("expected 400 for empty steps, got %d", rr.Code) 182 } 183} 184 185// TestSubmitSteps_VersionConflict verifies that submitting steps with an outdated 186// client version returns 409 with the missed steps. 187func TestSubmitSteps_VersionConflict(t *testing.T) { 188 h, d := setupHandler(t) 189 user := createTestUser(t, d) 190 191 // First submission: v0 -> v1 192 body1, _ := json.Marshal(map[string]interface{}{ 193 "clientVersion": 0, 194 "steps": []string{`{"stepType":"replace","from":0,"to":0,"slice":{"content":[{"type":"text","text":"first"}]}}`}, 195 "clientID": "did:plc:user1", 196 }) 197 req1 := httptest.NewRequest("POST", "/api/docs/rkey1/steps", bytes.NewReader(body1)) 198 req1.SetPathValue("rkey", "rkey1") 199 req1.Header.Set("Content-Type", "application/json") 200 req1 = withAuth(req1, user.ID) 201 rr1 := httptest.NewRecorder() 202 h.SubmitSteps(rr1, req1) 203 204 if rr1.Code != http.StatusOK { 205 t.Fatalf("first submit failed: %d: %s", rr1.Code, rr1.Body.String()) 206 } 207 208 // Second submission: also v0 -> v1 (conflict) 209 body2, _ := json.Marshal(map[string]interface{}{ 210 "clientVersion": 0, 211 "steps": []string{`{"stepType":"replace","from":0,"to":0,"slice":{"content":[{"type":"text","text":"second"}]}}`}, 212 "clientID": "did:plc:user2", 213 }) 214 req2 := httptest.NewRequest("POST", "/api/docs/rkey1/steps", bytes.NewReader(body2)) 215 req2.SetPathValue("rkey", "rkey1") 216 req2.Header.Set("Content-Type", "application/json") 217 req2 = withAuth(req2, user.ID) 218 rr2 := httptest.NewRecorder() 219 h.SubmitSteps(rr2, req2) 220 221 if rr2.Code != http.StatusConflict { 222 t.Fatalf("expected 409 conflict, got %d: %s", rr2.Code, rr2.Body.String()) 223 } 224 225 var resp map[string]interface{} 226 if err := json.NewDecoder(rr2.Body).Decode(&resp); err != nil { 227 t.Fatalf("decode conflict response: %v", err) 228 } 229 230 // Should return current version (1) and the missed steps 231 if version, ok := resp["version"].(float64); !ok || int(version) != 1 { 232 t.Errorf("expected version 1 in conflict response, got %v", resp["version"]) 233 } 234 235 steps, ok := resp["steps"].([]interface{}) 236 if !ok { 237 t.Fatalf("expected steps array in conflict response, got %T", resp["steps"]) 238 } 239 if len(steps) != 1 { 240 t.Errorf("expected 1 missed step, got %d", len(steps)) 241 } 242}