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