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