Diffdown is a real-time collaborative Markdown editor/previewer built on the AT Protocol
diffdown.com
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}