···
14
+
"Coves/internal/core/users"
15
+
"Coves/internal/db/postgres"
16
+
"Coves/internal/jetstream"
17
+
_ "github.com/lib/pq"
18
+
"github.com/pressly/goose/v3"
21
+
// TestE2E_UserSignup tests the full user signup flow:
22
+
// Third-party client → social.coves.actor.signup XRPC → PDS account creation → Jetstream → AppView indexing
24
+
// This tests the same code path that a third-party client or UI would use.
27
+
// - AppView running on localhost:8081
28
+
// - PDS running on localhost:3001
29
+
// - Jetstream running on localhost:6008 (consuming from PDS)
30
+
// - Test database on localhost:5434
33
+
// make e2e-up # Start infrastructure
34
+
// go run ./cmd/server & # Start AppView
35
+
// go test ./tests/integration -run TestE2E_UserSignup -v
36
+
func TestE2E_UserSignup(t *testing.T) {
37
+
if testing.Short() {
38
+
t.Skip("Skipping E2E test in short mode")
41
+
// Check if AppView is available
42
+
if !isAppViewAvailable(t) {
43
+
t.Skip("AppView not available at localhost:8081 - run 'go run ./cmd/server' first")
46
+
// Check if PDS is available
47
+
if !isPDSAvailable(t) {
48
+
t.Skip("PDS not available at localhost:3001 - run 'make e2e-up' first")
51
+
// Check if Jetstream is available
52
+
if !isJetstreamAvailable(t) {
53
+
t.Skip("Jetstream not available at localhost:6008 - run 'make e2e-up' first")
56
+
db := setupTestDB(t)
60
+
userRepo := postgres.NewUserRepository(db)
61
+
userService := users.NewUserService(userRepo, "http://localhost:3001")
63
+
// Start Jetstream consumer
64
+
consumer := jetstream.NewUserEventConsumer(
66
+
"ws://localhost:6008/subscribe",
67
+
"", // No PDS filter
70
+
ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
73
+
// Start consumer in background
74
+
consumerDone := make(chan error, 1)
76
+
consumerDone <- consumer.Start(ctx)
79
+
// Give Jetstream consumer a moment to connect (no need to wait long)
80
+
t.Log("Waiting for Jetstream consumer to connect...")
81
+
time.Sleep(500 * time.Millisecond)
83
+
// Test 1: Create account on PDS
84
+
t.Run("Create account on PDS and verify indexing", func(t *testing.T) {
85
+
handle := fmt.Sprintf("alice-%d.local.coves.dev", time.Now().Unix())
86
+
email := fmt.Sprintf("alice-%d@test.com", time.Now().Unix())
88
+
t.Logf("Creating account: %s", handle)
90
+
// Create account via UserService (what UI would call)
91
+
did, err := createPDSAccount(t, userService, handle, email, "test1234")
93
+
t.Fatalf("Failed to create PDS account: %v", err)
96
+
t.Logf("Account created with DID: %s", did)
98
+
// Wait for Jetstream to process and AppView to index (with retry)
99
+
t.Log("Waiting for Jetstream → AppView indexing...")
100
+
var user *users.User
101
+
deadline := time.Now().Add(10 * time.Second)
102
+
for time.Now().Before(deadline) {
103
+
user, err = userService.GetUserByDID(ctx, did)
105
+
break // Successfully indexed!
107
+
time.Sleep(500 * time.Millisecond)
110
+
t.Fatalf("User not indexed in AppView after 10s: %v", err)
113
+
if user.Handle != handle {
114
+
t.Errorf("Expected handle %s, got %s", handle, user.Handle)
117
+
if user.DID != did {
118
+
t.Errorf("Expected DID %s, got %s", did, user.DID)
121
+
t.Logf("✅ User successfully indexed: %s → %s", handle, did)
124
+
// Test 2: Idempotency
125
+
t.Run("Idempotent indexing on duplicate events", func(t *testing.T) {
126
+
handle := fmt.Sprintf("bob-%d.local.coves.dev", time.Now().Unix())
127
+
email := fmt.Sprintf("bob-%d@test.com", time.Now().Unix())
129
+
// Create account via UserService
130
+
did, err := createPDSAccount(t, userService, handle, email, "test1234")
132
+
t.Fatalf("Failed to create PDS account: %v", err)
135
+
// Wait for indexing (with retry)
136
+
var user1 *users.User
137
+
deadline := time.Now().Add(10 * time.Second)
138
+
for time.Now().Before(deadline) {
139
+
user1, err = userService.GetUserByDID(ctx, did)
143
+
time.Sleep(500 * time.Millisecond)
146
+
t.Fatalf("User not indexed after 10s: %v", err)
149
+
// Manually trigger duplicate indexing (simulates Jetstream replay)
150
+
_, err = userService.CreateUser(ctx, users.CreateUserRequest{
153
+
PDSURL: "http://localhost:3001",
156
+
t.Fatalf("Idempotent CreateUser should not error: %v", err)
159
+
// Verify still only one user
160
+
user2, err := userService.GetUserByDID(ctx, did)
162
+
t.Fatalf("Failed to get user after duplicate: %v", err)
165
+
if user1.CreatedAt != user2.CreatedAt {
166
+
t.Errorf("Duplicate indexing created new user (timestamps differ)")
169
+
t.Logf("✅ Idempotency verified: duplicate events handled gracefully")
172
+
// Test 3: Multiple users
173
+
t.Run("Index multiple users concurrently", func(t *testing.T) {
175
+
dids := make([]string, numUsers)
177
+
for i := 0; i < numUsers; i++ {
178
+
handle := fmt.Sprintf("user%d-%d.local.coves.dev", i, time.Now().Unix())
179
+
email := fmt.Sprintf("user%d-%d@test.com", i, time.Now().Unix())
181
+
did, err := createPDSAccount(t, userService, handle, email, "test1234")
183
+
t.Fatalf("Failed to create account %d: %v", i, err)
186
+
t.Logf("Created user %d: %s", i, did)
188
+
// Small delay between creations
189
+
time.Sleep(500 * time.Millisecond)
192
+
// Verify all indexed (with retry for each user)
193
+
t.Log("Waiting for all users to be indexed...")
194
+
for i, did := range dids {
195
+
var user *users.User
197
+
deadline := time.Now().Add(15 * time.Second)
198
+
for time.Now().Before(deadline) {
199
+
user, err = userService.GetUserByDID(ctx, did)
203
+
time.Sleep(500 * time.Millisecond)
206
+
t.Errorf("User %d not indexed after 15s: %v", i, err)
209
+
t.Logf("✅ User %d indexed: %s", i, user.Handle)
216
+
case err := <-consumerDone:
217
+
if err != nil && err != context.Canceled {
218
+
t.Logf("Consumer stopped with error: %v", err)
220
+
case <-time.After(5 * time.Second):
221
+
t.Log("Consumer shutdown timeout")
225
+
// generateInviteCode generates a single-use invite code via PDS admin API
226
+
func generateInviteCode(t *testing.T) (string, error) {
227
+
payload := map[string]int{
231
+
jsonData, err := json.Marshal(payload)
233
+
return "", fmt.Errorf("failed to marshal request: %w", err)
236
+
req, err := http.NewRequest(
238
+
"http://localhost:3001/xrpc/com.atproto.server.createInviteCode",
239
+
bytes.NewBuffer(jsonData),
242
+
return "", fmt.Errorf("failed to create request: %w", err)
245
+
// PDS admin authentication
246
+
req.SetBasicAuth("admin", "admin")
247
+
req.Header.Set("Content-Type", "application/json")
249
+
resp, err := http.DefaultClient.Do(req)
251
+
return "", fmt.Errorf("failed to create invite code: %w", err)
253
+
defer resp.Body.Close()
255
+
if resp.StatusCode != http.StatusOK {
256
+
var errorResp map[string]interface{}
257
+
json.NewDecoder(resp.Body).Decode(&errorResp)
258
+
return "", fmt.Errorf("PDS admin API returned status %d: %v", resp.StatusCode, errorResp)
261
+
var result struct {
262
+
Code string `json:"code"`
265
+
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
266
+
return "", fmt.Errorf("failed to decode response: %w", err)
269
+
t.Logf("Generated invite code: %s", result.Code)
270
+
return result.Code, nil
273
+
// createPDSAccount creates an account via the coves.user.signup XRPC endpoint
274
+
// This is the same code path that a third-party client or UI would use
275
+
func createPDSAccount(t *testing.T, userService users.UserService, handle, email, password string) (string, error) {
276
+
// Generate fresh invite code for each account
277
+
inviteCode, err := generateInviteCode(t)
279
+
return "", fmt.Errorf("failed to generate invite code: %w", err)
282
+
// Call our XRPC endpoint (what a third-party client would call)
283
+
payload := map[string]string{
286
+
"password": password,
287
+
"inviteCode": inviteCode,
290
+
jsonData, err := json.Marshal(payload)
292
+
return "", fmt.Errorf("failed to marshal request: %w", err)
295
+
resp, err := http.Post(
296
+
"http://localhost:8081/xrpc/social.coves.actor.signup",
297
+
"application/json",
298
+
bytes.NewBuffer(jsonData),
301
+
return "", fmt.Errorf("failed to call signup endpoint: %w", err)
303
+
defer resp.Body.Close()
305
+
if resp.StatusCode != http.StatusOK {
306
+
var errorResp map[string]interface{}
307
+
json.NewDecoder(resp.Body).Decode(&errorResp)
308
+
return "", fmt.Errorf("signup endpoint returned status %d: %v", resp.StatusCode, errorResp)
311
+
var result struct {
312
+
DID string `json:"did"`
313
+
Handle string `json:"handle"`
314
+
AccessJwt string `json:"accessJwt"`
315
+
RefreshJwt string `json:"refreshJwt"`
318
+
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
319
+
return "", fmt.Errorf("failed to decode response: %w", err)
322
+
t.Logf("Account created via XRPC endpoint: %s → %s", result.Handle, result.DID)
324
+
return result.DID, nil
327
+
// isPDSAvailable checks if PDS is running
328
+
func isPDSAvailable(t *testing.T) bool {
329
+
resp, err := http.Get("http://localhost:3001/xrpc/_health")
331
+
t.Logf("PDS not available: %v", err)
334
+
defer resp.Body.Close()
335
+
return resp.StatusCode == http.StatusOK
338
+
// isJetstreamAvailable checks if Jetstream is running
339
+
func isJetstreamAvailable(t *testing.T) bool {
340
+
// Use 127.0.0.1 instead of localhost to force IPv4
341
+
resp, err := http.Get("http://127.0.0.1:6009/metrics")
343
+
t.Logf("Jetstream not available: %v", err)
346
+
defer resp.Body.Close()
347
+
return resp.StatusCode == http.StatusOK
350
+
// isAppViewAvailable checks if AppView is running
351
+
func isAppViewAvailable(t *testing.T) bool {
352
+
resp, err := http.Get("http://localhost:8081/health")
354
+
t.Logf("AppView not available: %v", err)
357
+
defer resp.Body.Close()
358
+
return resp.StatusCode == http.StatusOK
361
+
// setupTestDB connects to test database and runs migrations
362
+
func setupTestDB(t *testing.T) *sql.DB {
363
+
// Build connection string from environment variables (set by .env.dev)
364
+
testUser := os.Getenv("POSTGRES_TEST_USER")
365
+
testPassword := os.Getenv("POSTGRES_TEST_PASSWORD")
366
+
testPort := os.Getenv("POSTGRES_TEST_PORT")
367
+
testDB := os.Getenv("POSTGRES_TEST_DB")
369
+
// Fallback to defaults if not set
370
+
if testUser == "" {
371
+
testUser = "test_user"
373
+
if testPassword == "" {
374
+
testPassword = "test_password"
376
+
if testPort == "" {
380
+
testDB = "coves_test"
383
+
dbURL := fmt.Sprintf("postgres://%s:%s@localhost:%s/%s?sslmode=disable",
384
+
testUser, testPassword, testPort, testDB)
386
+
db, err := sql.Open("postgres", dbURL)
388
+
t.Fatalf("Failed to connect to test database: %v", err)
391
+
if err := db.Ping(); err != nil {
392
+
t.Fatalf("Failed to ping test database: %v", err)
395
+
if err := goose.SetDialect("postgres"); err != nil {
396
+
t.Fatalf("Failed to set goose dialect: %v", err)
399
+
if err := goose.Up(db, "../../internal/db/migrations"); err != nil {
400
+
t.Fatalf("Failed to run migrations: %v", err)
403
+
// Clean up any existing test data
404
+
_, err = db.Exec("DELETE FROM users WHERE handle LIKE '%.test' OR handle LIKE '%.local.coves.dev'")
406
+
t.Logf("Warning: Failed to clean up test data: %v", err)