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