package handler_test import ( "bytes" "context" "encoding/json" "net/http" "net/http/httptest" "os" "testing" "github.com/limeleaf/diffdown/internal/auth" "github.com/limeleaf/diffdown/internal/collaboration" "github.com/limeleaf/diffdown/internal/db" "github.com/limeleaf/diffdown/internal/handler" "github.com/limeleaf/diffdown/internal/model" ) func setupHandler(t *testing.T) (*handler.Handler, *db.DB) { t.Helper() f, err := os.CreateTemp("", "diffdown-handler-*.db") if err != nil { t.Fatalf("create temp db: %v", err) } t.Cleanup(func() { os.Remove(f.Name()) }) f.Close() database, err := db.Open(f.Name()) if err != nil { t.Fatalf("open db: %v", err) } db.SetMigrationsDir("../../migrations") if err := database.Migrate(); err != nil { t.Fatalf("migrate: %v", err) } auth.InitStore("test-secret") hub := collaboration.NewHub() h := handler.New(database, nil, "http://localhost:8080", hub) return h, database } func createTestUser(t *testing.T, d *db.DB) *model.User { t.Helper() user := &model.User{ DID: "did:plc:testuser123", } if err := d.CreateUser(user); err != nil { t.Fatalf("create user: %v", err) } return user } func withAuth(r *http.Request, userID string) *http.Request { ctx := context.WithValue(r.Context(), auth.UserIDKey, userID) return r.WithContext(ctx) } // TestGetSteps_Unauthenticated verifies that unauthenticated requests return 401. func TestGetSteps_Unauthenticated(t *testing.T) { h, _ := setupHandler(t) req := httptest.NewRequest("GET", "/api/docs/rkey1/steps?since=0", nil) req.SetPathValue("rkey", "rkey1") rr := httptest.NewRecorder() h.GetSteps(rr, req) if rr.Code != http.StatusUnauthorized { t.Errorf("expected 401 without auth, got %d", rr.Code) } } // TestGetSteps_EmptyDoc verifies that an authenticated request for a document // with no steps returns version 0 and an empty steps array. func TestGetSteps_EmptyDoc(t *testing.T) { h, d := setupHandler(t) user := createTestUser(t, d) req := httptest.NewRequest("GET", "/api/docs/rkey1/steps?since=0", nil) req.SetPathValue("rkey", "rkey1") req = withAuth(req, user.ID) rr := httptest.NewRecorder() h.GetSteps(rr, req) if rr.Code != http.StatusOK { t.Fatalf("expected 200, got %d", rr.Code) } var resp map[string]interface{} if err := json.NewDecoder(rr.Body).Decode(&resp); err != nil { t.Fatalf("decode response: %v", err) } if version, ok := resp["version"].(float64); !ok || int(version) != 0 { t.Errorf("expected version 0, got %v", resp["version"]) } steps, ok := resp["steps"].([]interface{}) if !ok { t.Fatalf("expected steps array, got %T", resp["steps"]) } if len(steps) != 0 { t.Errorf("expected empty steps array, got %d steps", len(steps)) } } // TestSubmitSteps_Unauthenticated verifies that unauthenticated requests return 401. func TestSubmitSteps_Unauthenticated(t *testing.T) { h, _ := setupHandler(t) body, _ := json.Marshal(map[string]interface{}{ "clientVersion": 0, "steps": []string{`{"stepType":"replace"}`}, "clientID": "did:plc:test", }) req := httptest.NewRequest("POST", "/api/docs/rkey1/steps", bytes.NewReader(body)) req.SetPathValue("rkey", "rkey1") req.Header.Set("Content-Type", "application/json") rr := httptest.NewRecorder() h.SubmitSteps(rr, req) if rr.Code != http.StatusUnauthorized { t.Errorf("expected 401, got %d", rr.Code) } } // TestSubmitSteps_Success verifies that an authenticated user can submit steps // and receive the new version number. func TestSubmitSteps_Success(t *testing.T) { h, d := setupHandler(t) user := createTestUser(t, d) body, _ := json.Marshal(map[string]interface{}{ "clientVersion": 0, "steps": []string{`{"stepType":"replace","from":0,"to":0,"slice":{"content":[{"type":"text","text":"hello"}]}}`}, "clientID": "did:plc:test", }) req := httptest.NewRequest("POST", "/api/docs/rkey1/steps", bytes.NewReader(body)) req.SetPathValue("rkey", "rkey1") req.Header.Set("Content-Type", "application/json") req = withAuth(req, user.ID) rr := httptest.NewRecorder() h.SubmitSteps(rr, req) if rr.Code != http.StatusOK { t.Fatalf("expected 200, got %d: %s", rr.Code, rr.Body.String()) } var resp map[string]interface{} if err := json.NewDecoder(rr.Body).Decode(&resp); err != nil { t.Fatalf("decode response: %v", err) } if version, ok := resp["version"].(float64); !ok || int(version) != 1 { t.Errorf("expected version 1, got %v", resp["version"]) } } // TestSubmitSteps_NoSteps verifies that submitting an empty steps array returns 400. func TestSubmitSteps_NoSteps(t *testing.T) { h, d := setupHandler(t) user := createTestUser(t, d) body, _ := json.Marshal(map[string]interface{}{ "clientVersion": 0, "steps": []string{}, "clientID": "did:plc:test", }) req := httptest.NewRequest("POST", "/api/docs/rkey1/steps", bytes.NewReader(body)) req.SetPathValue("rkey", "rkey1") req.Header.Set("Content-Type", "application/json") req = withAuth(req, user.ID) rr := httptest.NewRecorder() h.SubmitSteps(rr, req) if rr.Code != http.StatusBadRequest { t.Errorf("expected 400 for empty steps, got %d", rr.Code) } } // TestSubmitSteps_VersionConflict verifies that submitting steps with an outdated // client version returns 409 with the missed steps. func TestSubmitSteps_VersionConflict(t *testing.T) { h, d := setupHandler(t) user := createTestUser(t, d) // First submission: v0 -> v1 body1, _ := json.Marshal(map[string]interface{}{ "clientVersion": 0, "steps": []string{`{"stepType":"replace","from":0,"to":0,"slice":{"content":[{"type":"text","text":"first"}]}}`}, "clientID": "did:plc:user1", }) req1 := httptest.NewRequest("POST", "/api/docs/rkey1/steps", bytes.NewReader(body1)) req1.SetPathValue("rkey", "rkey1") req1.Header.Set("Content-Type", "application/json") req1 = withAuth(req1, user.ID) rr1 := httptest.NewRecorder() h.SubmitSteps(rr1, req1) if rr1.Code != http.StatusOK { t.Fatalf("first submit failed: %d: %s", rr1.Code, rr1.Body.String()) } // Second submission: also v0 -> v1 (conflict) body2, _ := json.Marshal(map[string]interface{}{ "clientVersion": 0, "steps": []string{`{"stepType":"replace","from":0,"to":0,"slice":{"content":[{"type":"text","text":"second"}]}}`}, "clientID": "did:plc:user2", }) req2 := httptest.NewRequest("POST", "/api/docs/rkey1/steps", bytes.NewReader(body2)) req2.SetPathValue("rkey", "rkey1") req2.Header.Set("Content-Type", "application/json") req2 = withAuth(req2, user.ID) rr2 := httptest.NewRecorder() h.SubmitSteps(rr2, req2) if rr2.Code != http.StatusConflict { t.Fatalf("expected 409 conflict, got %d: %s", rr2.Code, rr2.Body.String()) } var resp map[string]interface{} if err := json.NewDecoder(rr2.Body).Decode(&resp); err != nil { t.Fatalf("decode conflict response: %v", err) } // Should return current version (1) and the missed steps if version, ok := resp["version"].(float64); !ok || int(version) != 1 { t.Errorf("expected version 1 in conflict response, got %v", resp["version"]) } steps, ok := resp["steps"].([]interface{}) if !ok { t.Fatalf("expected steps array in conflict response, got %T", resp["steps"]) } if len(steps) != 1 { t.Errorf("expected 1 missed step, got %d", len(steps)) } }