A community based topic aggregation platform built on atproto
1package integration
2
3import (
4 "Coves/internal/atproto/auth"
5 "Coves/internal/core/users"
6 "bytes"
7 "context"
8 "database/sql"
9 "encoding/base64"
10 "encoding/json"
11 "fmt"
12 "io"
13 "net/http"
14 "os"
15 "strings"
16 "testing"
17 "time"
18
19 "github.com/golang-jwt/jwt/v5"
20)
21
22// getTestPDSURL returns the PDS URL for testing from env var or default
23func getTestPDSURL() string {
24 pdsURL := os.Getenv("PDS_URL")
25 if pdsURL == "" {
26 pdsURL = "http://localhost:3001"
27 }
28 return pdsURL
29}
30
31// getTestInstanceDID returns the instance DID for testing from env var or default
32func getTestInstanceDID() string {
33 instanceDID := os.Getenv("INSTANCE_DID")
34 if instanceDID == "" {
35 instanceDID = "did:web:test.coves.social"
36 }
37 return instanceDID
38}
39
40// createTestUser creates a test user in the database for use in integration tests
41// Returns the created user or fails the test
42func createTestUser(t *testing.T, db *sql.DB, handle, did string) *users.User {
43 t.Helper()
44
45 ctx := context.Background()
46
47 // Create user directly in DB for speed
48 query := `
49 INSERT INTO users (did, handle, pds_url, created_at, updated_at)
50 VALUES ($1, $2, $3, NOW(), NOW())
51 RETURNING did, handle, pds_url, created_at, updated_at
52 `
53
54 user := &users.User{}
55 err := db.QueryRowContext(ctx, query, did, handle, getTestPDSURL()).Scan(
56 &user.DID,
57 &user.Handle,
58 &user.PDSURL,
59 &user.CreatedAt,
60 &user.UpdatedAt,
61 )
62 if err != nil {
63 t.Fatalf("Failed to create test user: %v", err)
64 }
65
66 return user
67}
68
69// contains checks if string s contains substring substr
70// Helper for error message assertions
71func contains(s, substr string) bool {
72 return strings.Contains(s, substr)
73}
74
75// authenticateWithPDS authenticates with PDS to get access token and DID
76// Used for setting up test environments that need PDS credentials
77func authenticateWithPDS(pdsURL, handle, password string) (string, string, error) {
78 // Call com.atproto.server.createSession
79 sessionReq := map[string]string{
80 "identifier": handle,
81 "password": password,
82 }
83
84 reqBody, marshalErr := json.Marshal(sessionReq)
85 if marshalErr != nil {
86 return "", "", fmt.Errorf("failed to marshal session request: %w", marshalErr)
87 }
88 resp, err := http.Post(
89 pdsURL+"/xrpc/com.atproto.server.createSession",
90 "application/json",
91 bytes.NewBuffer(reqBody),
92 )
93 if err != nil {
94 return "", "", fmt.Errorf("failed to create session: %w", err)
95 }
96 defer func() { _ = resp.Body.Close() }()
97
98 if resp.StatusCode != http.StatusOK {
99 body, readErr := io.ReadAll(resp.Body)
100 if readErr != nil {
101 return "", "", fmt.Errorf("PDS auth failed (status %d, failed to read body: %w)", resp.StatusCode, readErr)
102 }
103 return "", "", fmt.Errorf("PDS auth failed (status %d): %s", resp.StatusCode, string(body))
104 }
105
106 var sessionResp struct {
107 AccessJwt string `json:"accessJwt"`
108 DID string `json:"did"`
109 }
110
111 if err := json.NewDecoder(resp.Body).Decode(&sessionResp); err != nil {
112 return "", "", fmt.Errorf("failed to decode session response: %w", err)
113 }
114
115 return sessionResp.AccessJwt, sessionResp.DID, nil
116}
117
118// createSimpleTestJWT creates a minimal JWT for testing (Phase 1 - no signature)
119// In production, this would be a real OAuth token from PDS with proper signatures
120func createSimpleTestJWT(userDID string) string {
121 // Create minimal JWT claims using RegisteredClaims
122 // Use userDID as issuer since we don't have a proper PDS DID for testing
123 claims := auth.Claims{
124 RegisteredClaims: jwt.RegisteredClaims{
125 Subject: userDID,
126 Issuer: userDID, // Use DID as issuer for testing (valid per atProto)
127 Audience: jwt.ClaimStrings{getTestInstanceDID()},
128 IssuedAt: jwt.NewNumericDate(time.Now()),
129 ExpiresAt: jwt.NewNumericDate(time.Now().Add(1 * time.Hour)),
130 },
131 Scope: "com.atproto.access",
132 }
133
134 // For Phase 1 testing, we create an unsigned JWT
135 // The middleware is configured with skipVerify=true for testing
136 header := map[string]interface{}{
137 "alg": "none",
138 "typ": "JWT",
139 }
140
141 headerJSON, _ := json.Marshal(header)
142 claimsJSON, _ := json.Marshal(claims)
143
144 // Base64url encode (without padding)
145 headerB64 := base64.RawURLEncoding.EncodeToString(headerJSON)
146 claimsB64 := base64.RawURLEncoding.EncodeToString(claimsJSON)
147
148 // For "alg: none", signature is empty
149 return headerB64 + "." + claimsB64 + "."
150}
151
152// generateTID generates a simple timestamp-based identifier for testing
153// In production, PDS generates proper TIDs
154func generateTID() string {
155 return fmt.Sprintf("3k%d", time.Now().UnixNano()/1000)
156}
157
158// createPDSAccount creates a new account on PDS and returns access token + DID
159// This is used for E2E tests that need real PDS accounts
160func createPDSAccount(pdsURL, handle, email, password string) (accessToken, did string, err error) {
161 // Call com.atproto.server.createAccount
162 reqBody := map[string]string{
163 "handle": handle,
164 "email": email,
165 "password": password,
166 }
167
168 reqJSON, marshalErr := json.Marshal(reqBody)
169 if marshalErr != nil {
170 return "", "", fmt.Errorf("failed to marshal account request: %w", marshalErr)
171 }
172
173 resp, httpErr := http.Post(
174 pdsURL+"/xrpc/com.atproto.server.createAccount",
175 "application/json",
176 bytes.NewBuffer(reqJSON),
177 )
178 if httpErr != nil {
179 return "", "", fmt.Errorf("failed to create account: %w", httpErr)
180 }
181 defer func() { _ = resp.Body.Close() }()
182
183 if resp.StatusCode != http.StatusOK {
184 body, readErr := io.ReadAll(resp.Body)
185 if readErr != nil {
186 return "", "", fmt.Errorf("account creation failed (status %d, failed to read body: %w)", resp.StatusCode, readErr)
187 }
188 return "", "", fmt.Errorf("account creation failed (status %d): %s", resp.StatusCode, string(body))
189 }
190
191 var accountResp struct {
192 AccessJwt string `json:"accessJwt"`
193 DID string `json:"did"`
194 }
195
196 if decodeErr := json.NewDecoder(resp.Body).Decode(&accountResp); decodeErr != nil {
197 return "", "", fmt.Errorf("failed to decode account response: %w", decodeErr)
198 }
199
200 return accountResp.AccessJwt, accountResp.DID, nil
201}
202
203// writePDSRecord writes a record to PDS via com.atproto.repo.createRecord
204// Returns the AT-URI and CID of the created record
205func writePDSRecord(pdsURL, accessToken, repo, collection, rkey string, record interface{}) (uri, cid string, err error) {
206 reqBody := map[string]interface{}{
207 "repo": repo,
208 "collection": collection,
209 "record": record,
210 }
211
212 // If rkey is provided, include it
213 if rkey != "" {
214 reqBody["rkey"] = rkey
215 }
216
217 reqJSON, marshalErr := json.Marshal(reqBody)
218 if marshalErr != nil {
219 return "", "", fmt.Errorf("failed to marshal record request: %w", marshalErr)
220 }
221
222 req, reqErr := http.NewRequest("POST", pdsURL+"/xrpc/com.atproto.repo.createRecord", bytes.NewBuffer(reqJSON))
223 if reqErr != nil {
224 return "", "", fmt.Errorf("failed to create request: %w", reqErr)
225 }
226
227 req.Header.Set("Content-Type", "application/json")
228 req.Header.Set("Authorization", "Bearer "+accessToken)
229
230 resp, httpErr := http.DefaultClient.Do(req)
231 if httpErr != nil {
232 return "", "", fmt.Errorf("failed to write record: %w", httpErr)
233 }
234 defer func() { _ = resp.Body.Close() }()
235
236 if resp.StatusCode != http.StatusOK {
237 body, readErr := io.ReadAll(resp.Body)
238 if readErr != nil {
239 return "", "", fmt.Errorf("record creation failed (status %d, failed to read body: %w)", resp.StatusCode, readErr)
240 }
241 return "", "", fmt.Errorf("record creation failed (status %d): %s", resp.StatusCode, string(body))
242 }
243
244 var recordResp struct {
245 URI string `json:"uri"`
246 CID string `json:"cid"`
247 }
248
249 if decodeErr := json.NewDecoder(resp.Body).Decode(&recordResp); decodeErr != nil {
250 return "", "", fmt.Errorf("failed to decode record response: %w", decodeErr)
251 }
252
253 return recordResp.URI, recordResp.CID, nil
254}
255
256// createFeedTestCommunity creates a test community for feed tests
257// Returns the community DID or an error
258func createFeedTestCommunity(db *sql.DB, ctx context.Context, name, ownerHandle string) (string, error) {
259 // Get configuration from env vars
260 pdsURL := getTestPDSURL()
261 instanceDID := getTestInstanceDID()
262
263 // Create owner user first (directly insert to avoid service dependencies)
264 ownerDID := fmt.Sprintf("did:plc:%s", ownerHandle)
265 _, err := db.ExecContext(ctx, `
266 INSERT INTO users (did, handle, pds_url, created_at)
267 VALUES ($1, $2, $3, NOW())
268 ON CONFLICT (did) DO NOTHING
269 `, ownerDID, ownerHandle, pdsURL)
270 if err != nil {
271 return "", err
272 }
273
274 // Create community
275 communityDID := fmt.Sprintf("did:plc:community-%s", name)
276 _, err = db.ExecContext(ctx, `
277 INSERT INTO communities (did, name, owner_did, created_by_did, hosted_by_did, handle, pds_url, created_at)
278 VALUES ($1, $2, $3, $4, $5, $6, $7, NOW())
279 ON CONFLICT (did) DO NOTHING
280 `, communityDID, name, ownerDID, ownerDID, instanceDID, fmt.Sprintf("%s.coves.social", name), pdsURL)
281
282 return communityDID, err
283}
284
285// createTestPost creates a test post and returns its URI
286func createTestPost(t *testing.T, db *sql.DB, communityDID, authorDID, title string, score int, createdAt time.Time) string {
287 t.Helper()
288
289 ctx := context.Background()
290
291 // Create author user if not exists (directly insert to avoid service dependencies)
292 _, _ = db.ExecContext(ctx, `
293 INSERT INTO users (did, handle, pds_url, created_at)
294 VALUES ($1, $2, $3, NOW())
295 ON CONFLICT (did) DO NOTHING
296 `, authorDID, fmt.Sprintf("%s.bsky.social", authorDID), getTestPDSURL())
297
298 // Generate URI
299 rkey := fmt.Sprintf("post-%d", time.Now().UnixNano())
300 uri := fmt.Sprintf("at://%s/social.coves.community.post/%s", communityDID, rkey)
301
302 // Insert post
303 _, err := db.ExecContext(ctx, `
304 INSERT INTO posts (uri, cid, rkey, author_did, community_did, title, created_at, score, upvote_count)
305 VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)
306 `, uri, "bafytest", rkey, authorDID, communityDID, title, createdAt, score, score)
307 if err != nil {
308 t.Fatalf("Failed to create test post: %v", err)
309 }
310
311 return uri
312}