A community based topic aggregation platform built on atproto
1package integration
2
3import (
4 "context"
5 "database/sql"
6 "encoding/json"
7 "fmt"
8 "net/http"
9 "net/http/httptest"
10 "os"
11 "strings"
12 "testing"
13
14 "github.com/go-chi/chi/v5"
15 _ "github.com/lib/pq"
16 "github.com/pressly/goose/v3"
17
18 "Coves/internal/api/routes"
19 "Coves/internal/atproto/identity"
20 "Coves/internal/core/users"
21 "Coves/internal/db/postgres"
22)
23
24func setupTestDB(t *testing.T) *sql.DB {
25 // Build connection string from environment variables (set by .env.dev)
26 testUser := os.Getenv("POSTGRES_TEST_USER")
27 testPassword := os.Getenv("POSTGRES_TEST_PASSWORD")
28 testPort := os.Getenv("POSTGRES_TEST_PORT")
29 testDB := os.Getenv("POSTGRES_TEST_DB")
30
31 // Fallback to defaults if not set
32 if testUser == "" {
33 testUser = "test_user"
34 }
35 if testPassword == "" {
36 testPassword = "test_password"
37 }
38 if testPort == "" {
39 testPort = "5434"
40 }
41 if testDB == "" {
42 testDB = "coves_test"
43 }
44
45 dbURL := fmt.Sprintf("postgres://%s:%s@localhost:%s/%s?sslmode=disable",
46 testUser, testPassword, testPort, testDB)
47
48 db, err := sql.Open("postgres", dbURL)
49 if err != nil {
50 t.Fatalf("Failed to connect to test database: %v", err)
51 }
52
53 if err := db.Ping(); err != nil {
54 t.Fatalf("Failed to ping test database: %v", err)
55 }
56
57 if err := goose.SetDialect("postgres"); err != nil {
58 t.Fatalf("Failed to set goose dialect: %v", err)
59 }
60
61 if err := goose.Up(db, "../../internal/db/migrations"); err != nil {
62 t.Fatalf("Failed to run migrations: %v", err)
63 }
64
65 // Clean up any existing test data
66 _, err = db.Exec("DELETE FROM users WHERE handle LIKE '%.test'")
67 if err != nil {
68 t.Logf("Warning: Failed to clean up test data: %v", err)
69 }
70
71 return db
72}
73
74func TestUserCreationAndRetrieval(t *testing.T) {
75 db := setupTestDB(t)
76 defer db.Close()
77
78 // Wire up dependencies
79 userRepo := postgres.NewUserRepository(db)
80 resolver := identity.NewResolver(db, identity.DefaultConfig())
81 userService := users.NewUserService(userRepo, resolver, "http://localhost:3001")
82
83 ctx := context.Background()
84
85 // Test 1: Create a user
86 t.Run("Create User", func(t *testing.T) {
87 req := users.CreateUserRequest{
88 DID: "did:plc:test123456",
89 Handle: "alice.test",
90 PDSURL: "http://localhost:3001",
91 }
92
93 user, err := userService.CreateUser(ctx, req)
94 if err != nil {
95 t.Fatalf("Failed to create user: %v", err)
96 }
97
98 if user.DID != req.DID {
99 t.Errorf("Expected DID %s, got %s", req.DID, user.DID)
100 }
101
102 if user.Handle != req.Handle {
103 t.Errorf("Expected handle %s, got %s", req.Handle, user.Handle)
104 }
105
106 if user.CreatedAt.IsZero() {
107 t.Error("CreatedAt should not be zero")
108 }
109 })
110
111 // Test 2: Retrieve user by DID
112 t.Run("Get User By DID", func(t *testing.T) {
113 user, err := userService.GetUserByDID(ctx, "did:plc:test123456")
114 if err != nil {
115 t.Fatalf("Failed to get user by DID: %v", err)
116 }
117
118 if user.Handle != "alice.test" {
119 t.Errorf("Expected handle alice.test, got %s", user.Handle)
120 }
121 })
122
123 // Test 3: Retrieve user by handle
124 t.Run("Get User By Handle", func(t *testing.T) {
125 user, err := userService.GetUserByHandle(ctx, "alice.test")
126 if err != nil {
127 t.Fatalf("Failed to get user by handle: %v", err)
128 }
129
130 if user.DID != "did:plc:test123456" {
131 t.Errorf("Expected DID did:plc:test123456, got %s", user.DID)
132 }
133 })
134
135 // Test 4: Resolve handle to DID (using real handle)
136 t.Run("Resolve Handle to DID", func(t *testing.T) {
137 // Test with a real atProto handle
138 did, err := userService.ResolveHandleToDID(ctx, "bretton.dev")
139 if err != nil {
140 t.Fatalf("Failed to resolve handle bretton.dev: %v", err)
141 }
142
143 if did == "" {
144 t.Error("Expected non-empty DID")
145 }
146
147 t.Logf("✅ Resolved bretton.dev → %s", did)
148 })
149}
150
151func TestGetProfileEndpoint(t *testing.T) {
152 db := setupTestDB(t)
153 defer db.Close()
154
155 // Wire up dependencies
156 userRepo := postgres.NewUserRepository(db)
157 resolver := identity.NewResolver(db, identity.DefaultConfig())
158 userService := users.NewUserService(userRepo, resolver, "http://localhost:3001")
159
160 // Create test user directly in service
161 ctx := context.Background()
162 _, err := userService.CreateUser(ctx, users.CreateUserRequest{
163 DID: "did:plc:endpoint123",
164 Handle: "bob.test",
165 PDSURL: "http://localhost:3001",
166 })
167 if err != nil {
168 t.Fatalf("Failed to create test user: %v", err)
169 }
170
171 // Set up HTTP router
172 r := chi.NewRouter()
173 routes.RegisterUserRoutes(r, userService)
174
175 // Test 1: Get profile by DID
176 t.Run("Get Profile By DID", func(t *testing.T) {
177 req := httptest.NewRequest("GET", "/xrpc/social.coves.actor.getProfile?actor=did:plc:endpoint123", nil)
178 w := httptest.NewRecorder()
179 r.ServeHTTP(w, req)
180
181 if w.Code != http.StatusOK {
182 t.Errorf("Expected status %d, got %d. Response: %s", http.StatusOK, w.Code, w.Body.String())
183 return
184 }
185
186 var response map[string]interface{}
187 if err := json.NewDecoder(w.Body).Decode(&response); err != nil {
188 t.Fatalf("Failed to decode response: %v", err)
189 }
190
191 if response["did"] != "did:plc:endpoint123" {
192 t.Errorf("Expected DID did:plc:endpoint123, got %v", response["did"])
193 }
194 })
195
196 // Test 2: Get profile by handle
197 t.Run("Get Profile By Handle", func(t *testing.T) {
198 req := httptest.NewRequest("GET", "/xrpc/social.coves.actor.getProfile?actor=bob.test", nil)
199 w := httptest.NewRecorder()
200 r.ServeHTTP(w, req)
201
202 if w.Code != http.StatusOK {
203 t.Errorf("Expected status %d, got %d. Response: %s", http.StatusOK, w.Code, w.Body.String())
204 return
205 }
206
207 var response map[string]interface{}
208 if err := json.NewDecoder(w.Body).Decode(&response); err != nil {
209 t.Fatalf("Failed to decode response: %v", err)
210 }
211
212 profile := response["profile"].(map[string]interface{})
213 if profile["handle"] != "bob.test" {
214 t.Errorf("Expected handle bob.test, got %v", profile["handle"])
215 }
216 })
217
218 // Test 3: Missing actor parameter
219 t.Run("Missing Actor Parameter", func(t *testing.T) {
220 req := httptest.NewRequest("GET", "/xrpc/social.coves.actor.getProfile", nil)
221 w := httptest.NewRecorder()
222 r.ServeHTTP(w, req)
223
224 if w.Code != http.StatusBadRequest {
225 t.Errorf("Expected status %d, got %d", http.StatusBadRequest, w.Code)
226 }
227 })
228
229 // Test 4: User not found
230 t.Run("User Not Found", func(t *testing.T) {
231 req := httptest.NewRequest("GET", "/xrpc/social.coves.actor.getProfile?actor=nonexistent.test", nil)
232 w := httptest.NewRecorder()
233 r.ServeHTTP(w, req)
234
235 if w.Code != http.StatusNotFound {
236 t.Errorf("Expected status %d, got %d", http.StatusNotFound, w.Code)
237 }
238 })
239}
240
241// TestDuplicateCreation tests that duplicate DID/handle creation fails properly
242func TestDuplicateCreation(t *testing.T) {
243 db := setupTestDB(t)
244 defer db.Close()
245
246 userRepo := postgres.NewUserRepository(db)
247 resolver := identity.NewResolver(db, identity.DefaultConfig())
248 userService := users.NewUserService(userRepo, resolver, "http://localhost:3001")
249 ctx := context.Background()
250
251 // Create first user
252 _, err := userService.CreateUser(ctx, users.CreateUserRequest{
253 DID: "did:plc:duplicate123",
254 Handle: "duplicate.test",
255 PDSURL: "http://localhost:3001",
256 })
257 if err != nil {
258 t.Fatalf("Failed to create first user: %v", err)
259 }
260
261 // Test duplicate DID - now idempotent, returns existing user
262 t.Run("Duplicate DID - Idempotent", func(t *testing.T) {
263 user, err := userService.CreateUser(ctx, users.CreateUserRequest{
264 DID: "did:plc:duplicate123",
265 Handle: "different.test", // Different handle, same DID
266 PDSURL: "http://localhost:3001",
267 })
268
269 // Should return existing user, not error
270 if err != nil {
271 t.Fatalf("Expected idempotent behavior, got error: %v", err)
272 }
273
274 // Should return the original user (with original handle)
275 if user.Handle != "duplicate.test" {
276 t.Errorf("Expected original handle 'duplicate.test', got: %s", user.Handle)
277 }
278 })
279
280 // Test duplicate handle
281 t.Run("Duplicate Handle", func(t *testing.T) {
282 _, err := userService.CreateUser(ctx, users.CreateUserRequest{
283 DID: "did:plc:different456",
284 Handle: "duplicate.test",
285 PDSURL: "http://localhost:3001",
286 })
287
288 if err == nil {
289 t.Error("Expected error for duplicate handle, got nil")
290 }
291
292 if !strings.Contains(err.Error(), "handle already taken") {
293 t.Errorf("Expected 'handle already taken' error, got: %v", err)
294 }
295 })
296}
297
298// TestHandleValidation tests atProto handle validation rules
299func TestHandleValidation(t *testing.T) {
300 db := setupTestDB(t)
301 defer db.Close()
302
303 userRepo := postgres.NewUserRepository(db)
304 resolver := identity.NewResolver(db, identity.DefaultConfig())
305 userService := users.NewUserService(userRepo, resolver, "http://localhost:3001")
306 ctx := context.Background()
307
308 testCases := []struct {
309 name string
310 did string
311 handle string
312 pdsURL string
313 shouldError bool
314 errorMsg string
315 }{
316 {
317 name: "Valid handle with hyphen",
318 did: "did:plc:valid1",
319 handle: "alice-bob.test",
320 pdsURL: "http://localhost:3001",
321 shouldError: false,
322 },
323 {
324 name: "Valid handle with dots",
325 did: "did:plc:valid2",
326 handle: "alice.bob.test",
327 pdsURL: "http://localhost:3001",
328 shouldError: false,
329 },
330 {
331 name: "Invalid: no dot (not domain-like)",
332 did: "did:plc:invalid8",
333 handle: "alice",
334 pdsURL: "http://localhost:3001",
335 shouldError: true,
336 errorMsg: "invalid handle",
337 },
338 {
339 name: "Valid: consecutive hyphens (allowed per atProto spec)",
340 did: "did:plc:valid3",
341 handle: "alice--bob.test",
342 pdsURL: "http://localhost:3001",
343 shouldError: false,
344 },
345 {
346 name: "Invalid: starts with hyphen",
347 did: "did:plc:invalid2",
348 handle: "-alice.test",
349 pdsURL: "http://localhost:3001",
350 shouldError: true,
351 errorMsg: "invalid handle",
352 },
353 {
354 name: "Invalid: ends with hyphen",
355 did: "did:plc:invalid3",
356 handle: "alice-.test",
357 pdsURL: "http://localhost:3001",
358 shouldError: true,
359 errorMsg: "invalid handle",
360 },
361 {
362 name: "Invalid: special characters",
363 did: "did:plc:invalid4",
364 handle: "alice!bob.test",
365 pdsURL: "http://localhost:3001",
366 shouldError: true,
367 errorMsg: "invalid handle",
368 },
369 {
370 name: "Invalid: spaces",
371 did: "did:plc:invalid5",
372 handle: "alice bob.test",
373 pdsURL: "http://localhost:3001",
374 shouldError: true,
375 errorMsg: "invalid handle",
376 },
377 {
378 name: "Invalid: too long",
379 did: "did:plc:invalid6",
380 handle: strings.Repeat("a", 254) + ".test",
381 pdsURL: "http://localhost:3001",
382 shouldError: true,
383 errorMsg: "invalid handle",
384 },
385 {
386 name: "Invalid: missing DID prefix",
387 did: "plc:invalid7",
388 handle: "valid.test",
389 pdsURL: "http://localhost:3001",
390 shouldError: true,
391 errorMsg: "must start with 'did:'",
392 },
393 }
394
395 for _, tc := range testCases {
396 t.Run(tc.name, func(t *testing.T) {
397 _, err := userService.CreateUser(ctx, users.CreateUserRequest{
398 DID: tc.did,
399 Handle: tc.handle,
400 PDSURL: tc.pdsURL,
401 })
402
403 if tc.shouldError {
404 if err == nil {
405 t.Errorf("Expected error, got nil")
406 } else if !strings.Contains(err.Error(), tc.errorMsg) {
407 t.Errorf("Expected error containing '%s', got: %v", tc.errorMsg, err)
408 }
409 } else {
410 if err != nil {
411 t.Errorf("Expected no error, got: %v", err)
412 }
413 }
414 })
415 }
416}