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