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// setupIdentityResolver creates an identity resolver configured for local PLC testing
74func setupIdentityResolver(db *sql.DB) interface{ Resolve(context.Context, string) (*identity.Identity, error) } {
75 plcURL := os.Getenv("PLC_DIRECTORY_URL")
76 if plcURL == "" {
77 plcURL = "http://localhost:3002" // Local PLC directory
78 }
79
80 config := identity.DefaultConfig()
81 config.PLCURL = plcURL
82 return identity.NewResolver(db, config)
83}
84
85// generateTestDID generates a unique test DID for integration tests
86// V2.0: No longer uses DID generator - just creates valid did:plc strings
87func generateTestDID(suffix string) string {
88 // Use a deterministic base + suffix for reproducible test DIDs
89 // Format matches did:plc but doesn't need PLC registration for unit/repo tests
90 return fmt.Sprintf("did:plc:test%s", suffix)
91}
92
93func TestUserCreationAndRetrieval(t *testing.T) {
94 db := setupTestDB(t)
95 defer func() {
96 if err := db.Close(); err != nil {
97 t.Logf("Failed to close database: %v", err)
98 }
99 }()
100
101 // Wire up dependencies
102 userRepo := postgres.NewUserRepository(db)
103 resolver := identity.NewResolver(db, identity.DefaultConfig())
104 userService := users.NewUserService(userRepo, resolver, "http://localhost:3001")
105
106 ctx := context.Background()
107
108 // Test 1: Create a user
109 t.Run("Create User", func(t *testing.T) {
110 req := users.CreateUserRequest{
111 DID: "did:plc:test123456",
112 Handle: "alice.test",
113 PDSURL: "http://localhost:3001",
114 }
115
116 user, err := userService.CreateUser(ctx, req)
117 if err != nil {
118 t.Fatalf("Failed to create user: %v", err)
119 }
120
121 if user.DID != req.DID {
122 t.Errorf("Expected DID %s, got %s", req.DID, user.DID)
123 }
124
125 if user.Handle != req.Handle {
126 t.Errorf("Expected handle %s, got %s", req.Handle, user.Handle)
127 }
128
129 if user.CreatedAt.IsZero() {
130 t.Error("CreatedAt should not be zero")
131 }
132 })
133
134 // Test 2: Retrieve user by DID
135 t.Run("Get User By DID", func(t *testing.T) {
136 user, err := userService.GetUserByDID(ctx, "did:plc:test123456")
137 if err != nil {
138 t.Fatalf("Failed to get user by DID: %v", err)
139 }
140
141 if user.Handle != "alice.test" {
142 t.Errorf("Expected handle alice.test, got %s", user.Handle)
143 }
144 })
145
146 // Test 3: Retrieve user by handle
147 t.Run("Get User By Handle", func(t *testing.T) {
148 user, err := userService.GetUserByHandle(ctx, "alice.test")
149 if err != nil {
150 t.Fatalf("Failed to get user by handle: %v", err)
151 }
152
153 if user.DID != "did:plc:test123456" {
154 t.Errorf("Expected DID did:plc:test123456, got %s", user.DID)
155 }
156 })
157
158 // Test 4: Resolve handle to DID (using real handle)
159 t.Run("Resolve Handle to DID", func(t *testing.T) {
160 // Test with a real atProto handle
161 did, err := userService.ResolveHandleToDID(ctx, "bretton.dev")
162 if err != nil {
163 t.Fatalf("Failed to resolve handle bretton.dev: %v", err)
164 }
165
166 if did == "" {
167 t.Error("Expected non-empty DID")
168 }
169
170 t.Logf("✅ Resolved bretton.dev → %s", did)
171 })
172}
173
174func TestGetProfileEndpoint(t *testing.T) {
175 db := setupTestDB(t)
176 defer func() {
177 if err := db.Close(); err != nil {
178 t.Logf("Failed to close database: %v", err)
179 }
180 }()
181
182 // Wire up dependencies
183 userRepo := postgres.NewUserRepository(db)
184 resolver := identity.NewResolver(db, identity.DefaultConfig())
185 userService := users.NewUserService(userRepo, resolver, "http://localhost:3001")
186
187 // Create test user directly in service
188 ctx := context.Background()
189 _, err := userService.CreateUser(ctx, users.CreateUserRequest{
190 DID: "did:plc:endpoint123",
191 Handle: "bob.test",
192 PDSURL: "http://localhost:3001",
193 })
194 if err != nil {
195 t.Fatalf("Failed to create test user: %v", err)
196 }
197
198 // Set up HTTP router
199 r := chi.NewRouter()
200 routes.RegisterUserRoutes(r, userService)
201
202 // Test 1: Get profile by DID
203 t.Run("Get Profile By DID", func(t *testing.T) {
204 req := httptest.NewRequest("GET", "/xrpc/social.coves.actor.getProfile?actor=did:plc:endpoint123", nil)
205 w := httptest.NewRecorder()
206 r.ServeHTTP(w, req)
207
208 if w.Code != http.StatusOK {
209 t.Errorf("Expected status %d, got %d. Response: %s", http.StatusOK, w.Code, w.Body.String())
210 return
211 }
212
213 var response map[string]interface{}
214 if err := json.NewDecoder(w.Body).Decode(&response); err != nil {
215 t.Fatalf("Failed to decode response: %v", err)
216 }
217
218 if response["did"] != "did:plc:endpoint123" {
219 t.Errorf("Expected DID did:plc:endpoint123, got %v", response["did"])
220 }
221 })
222
223 // Test 2: Get profile by handle
224 t.Run("Get Profile By Handle", func(t *testing.T) {
225 req := httptest.NewRequest("GET", "/xrpc/social.coves.actor.getProfile?actor=bob.test", nil)
226 w := httptest.NewRecorder()
227 r.ServeHTTP(w, req)
228
229 if w.Code != http.StatusOK {
230 t.Errorf("Expected status %d, got %d. Response: %s", http.StatusOK, w.Code, w.Body.String())
231 return
232 }
233
234 var response map[string]interface{}
235 if err := json.NewDecoder(w.Body).Decode(&response); err != nil {
236 t.Fatalf("Failed to decode response: %v", err)
237 }
238
239 profile := response["profile"].(map[string]interface{})
240 if profile["handle"] != "bob.test" {
241 t.Errorf("Expected handle bob.test, got %v", profile["handle"])
242 }
243 })
244
245 // Test 3: Missing actor parameter
246 t.Run("Missing Actor Parameter", func(t *testing.T) {
247 req := httptest.NewRequest("GET", "/xrpc/social.coves.actor.getProfile", nil)
248 w := httptest.NewRecorder()
249 r.ServeHTTP(w, req)
250
251 if w.Code != http.StatusBadRequest {
252 t.Errorf("Expected status %d, got %d", http.StatusBadRequest, w.Code)
253 }
254 })
255
256 // Test 4: User not found
257 t.Run("User Not Found", func(t *testing.T) {
258 req := httptest.NewRequest("GET", "/xrpc/social.coves.actor.getProfile?actor=nonexistent.test", nil)
259 w := httptest.NewRecorder()
260 r.ServeHTTP(w, req)
261
262 if w.Code != http.StatusNotFound {
263 t.Errorf("Expected status %d, got %d", http.StatusNotFound, w.Code)
264 }
265 })
266}
267
268// TestDuplicateCreation tests that duplicate DID/handle creation fails properly
269func TestDuplicateCreation(t *testing.T) {
270 db := setupTestDB(t)
271 defer func() {
272 if err := db.Close(); err != nil {
273 t.Logf("Failed to close database: %v", err)
274 }
275 }()
276
277 userRepo := postgres.NewUserRepository(db)
278 resolver := identity.NewResolver(db, identity.DefaultConfig())
279 userService := users.NewUserService(userRepo, resolver, "http://localhost:3001")
280 ctx := context.Background()
281
282 // Create first user
283 _, err := userService.CreateUser(ctx, users.CreateUserRequest{
284 DID: "did:plc:duplicate123",
285 Handle: "duplicate.test",
286 PDSURL: "http://localhost:3001",
287 })
288 if err != nil {
289 t.Fatalf("Failed to create first user: %v", err)
290 }
291
292 // Test duplicate DID - now idempotent, returns existing user
293 t.Run("Duplicate DID - Idempotent", func(t *testing.T) {
294 user, err := userService.CreateUser(ctx, users.CreateUserRequest{
295 DID: "did:plc:duplicate123",
296 Handle: "different.test", // Different handle, same DID
297 PDSURL: "http://localhost:3001",
298 })
299 // Should return existing user, not error
300 if err != nil {
301 t.Fatalf("Expected idempotent behavior, got error: %v", err)
302 }
303
304 // Should return the original user (with original handle)
305 if user.Handle != "duplicate.test" {
306 t.Errorf("Expected original handle 'duplicate.test', got: %s", user.Handle)
307 }
308 })
309
310 // Test duplicate handle
311 t.Run("Duplicate Handle", func(t *testing.T) {
312 _, err := userService.CreateUser(ctx, users.CreateUserRequest{
313 DID: "did:plc:different456",
314 Handle: "duplicate.test",
315 PDSURL: "http://localhost:3001",
316 })
317
318 if err == nil {
319 t.Error("Expected error for duplicate handle, got nil")
320 }
321
322 if !strings.Contains(err.Error(), "handle already taken") {
323 t.Errorf("Expected 'handle already taken' error, got: %v", err)
324 }
325 })
326}
327
328// TestHandleValidation tests atProto handle validation rules
329func TestHandleValidation(t *testing.T) {
330 db := setupTestDB(t)
331 defer func() {
332 if err := db.Close(); err != nil {
333 t.Logf("Failed to close database: %v", err)
334 }
335 }()
336
337 userRepo := postgres.NewUserRepository(db)
338 resolver := identity.NewResolver(db, identity.DefaultConfig())
339 userService := users.NewUserService(userRepo, resolver, "http://localhost:3001")
340 ctx := context.Background()
341
342 testCases := []struct {
343 name string
344 did string
345 handle string
346 pdsURL string
347 errorMsg string
348 shouldError bool
349 }{
350 {
351 name: "Valid handle with hyphen",
352 did: "did:plc:valid1",
353 handle: "alice-bob.test",
354 pdsURL: "http://localhost:3001",
355 shouldError: false,
356 },
357 {
358 name: "Valid handle with dots",
359 did: "did:plc:valid2",
360 handle: "alice.bob.test",
361 pdsURL: "http://localhost:3001",
362 shouldError: false,
363 },
364 {
365 name: "Invalid: no dot (not domain-like)",
366 did: "did:plc:invalid8",
367 handle: "alice",
368 pdsURL: "http://localhost:3001",
369 shouldError: true,
370 errorMsg: "invalid handle",
371 },
372 {
373 name: "Valid: consecutive hyphens (allowed per atProto spec)",
374 did: "did:plc:valid3",
375 handle: "alice--bob.test",
376 pdsURL: "http://localhost:3001",
377 shouldError: false,
378 },
379 {
380 name: "Invalid: starts with hyphen",
381 did: "did:plc:invalid2",
382 handle: "-alice.test",
383 pdsURL: "http://localhost:3001",
384 shouldError: true,
385 errorMsg: "invalid handle",
386 },
387 {
388 name: "Invalid: ends with hyphen",
389 did: "did:plc:invalid3",
390 handle: "alice-.test",
391 pdsURL: "http://localhost:3001",
392 shouldError: true,
393 errorMsg: "invalid handle",
394 },
395 {
396 name: "Invalid: special characters",
397 did: "did:plc:invalid4",
398 handle: "alice!bob.test",
399 pdsURL: "http://localhost:3001",
400 shouldError: true,
401 errorMsg: "invalid handle",
402 },
403 {
404 name: "Invalid: spaces",
405 did: "did:plc:invalid5",
406 handle: "alice bob.test",
407 pdsURL: "http://localhost:3001",
408 shouldError: true,
409 errorMsg: "invalid handle",
410 },
411 {
412 name: "Invalid: too long",
413 did: "did:plc:invalid6",
414 handle: strings.Repeat("a", 254) + ".test",
415 pdsURL: "http://localhost:3001",
416 shouldError: true,
417 errorMsg: "invalid handle",
418 },
419 {
420 name: "Invalid: missing DID prefix",
421 did: "plc:invalid7",
422 handle: "valid.test",
423 pdsURL: "http://localhost:3001",
424 shouldError: true,
425 errorMsg: "must start with 'did:'",
426 },
427 }
428
429 for _, tc := range testCases {
430 t.Run(tc.name, func(t *testing.T) {
431 _, err := userService.CreateUser(ctx, users.CreateUserRequest{
432 DID: tc.did,
433 Handle: tc.handle,
434 PDSURL: tc.pdsURL,
435 })
436
437 if tc.shouldError {
438 if err == nil {
439 t.Errorf("Expected error, got nil")
440 } else if !strings.Contains(err.Error(), tc.errorMsg) {
441 t.Errorf("Expected error containing '%s', got: %v", tc.errorMsg, err)
442 }
443 } else {
444 if err != nil {
445 t.Errorf("Expected no error, got: %v", err)
446 }
447 }
448 })
449 }
450}