A community based topic aggregation platform built on atproto
1package integration
2
3import (
4 "context"
5 "fmt"
6 "strings"
7 "testing"
8
9 "Coves/internal/api/middleware"
10 "Coves/internal/atproto/identity"
11 "Coves/internal/core/communities"
12 "Coves/internal/core/posts"
13 "Coves/internal/core/users"
14 "Coves/internal/db/postgres"
15
16 "github.com/stretchr/testify/assert"
17 "github.com/stretchr/testify/require"
18)
19
20func TestPostCreation_Basic(t *testing.T) {
21 if testing.Short() {
22 t.Skip("Skipping integration test in short mode")
23 }
24
25 db := setupTestDB(t)
26 defer func() {
27 if err := db.Close(); err != nil {
28 t.Logf("Failed to close database: %v", err)
29 }
30 }()
31
32 // Setup: Initialize services
33 userRepo := postgres.NewUserRepository(db)
34 resolver := identity.NewResolver(db, identity.DefaultConfig())
35 userService := users.NewUserService(userRepo, resolver, "http://localhost:3001")
36
37 communityRepo := postgres.NewCommunityRepository(db)
38 // Note: Provisioner not needed for this test (we're not actually creating communities)
39 communityService := communities.NewCommunityService(
40 communityRepo,
41 "http://localhost:3001",
42 "did:web:test.coves.social",
43 "test.coves.social",
44 nil, // provisioner
45 )
46
47 postRepo := postgres.NewPostRepository(db)
48 postService := posts.NewPostService(postRepo, communityService, nil, "http://localhost:3001") // nil aggregatorService for user-only tests
49
50 ctx := context.Background()
51
52 // Cleanup: Remove any existing test data
53 _, _ = db.Exec("DELETE FROM posts WHERE community_did LIKE 'did:plc:test%'")
54 _, _ = db.Exec("DELETE FROM communities WHERE did LIKE 'did:plc:test%'")
55 _, _ = db.Exec("DELETE FROM users WHERE did LIKE 'did:plc:test%'")
56
57 // Setup: Create test user
58 testUserDID := generateTestDID("postauthor")
59 testUserHandle := "postauthor.test"
60
61 _, err := userService.CreateUser(ctx, users.CreateUserRequest{
62 DID: testUserDID,
63 Handle: testUserHandle,
64 PDSURL: "http://localhost:3001",
65 })
66 require.NoError(t, err, "Failed to create test user")
67
68 // Setup: Create test community (insert directly to DB for speed)
69 testCommunity := &communities.Community{
70 DID: generateTestDID("testcommunity"),
71 Handle: "testcommunity.community.test.coves.social", // Canonical atProto handle (no ! prefix, .community. format)
72 Name: "testcommunity",
73 DisplayName: "Test Community",
74 Description: "A community for testing posts",
75 Visibility: "public",
76 CreatedByDID: testUserDID,
77 HostedByDID: "did:web:test.coves.social",
78 PDSURL: "http://localhost:3001",
79 PDSAccessToken: "fake_token_for_test", // Won't actually call PDS in unit test
80 }
81
82 _, err = communityRepo.Create(ctx, testCommunity)
83 require.NoError(t, err, "Failed to create test community")
84
85 t.Run("Create text post successfully (with DID)", func(t *testing.T) {
86 // NOTE: This test validates the service layer logic only
87 // It will fail when trying to write to PDS because we're using a fake token
88 // For full E2E testing, you'd need a real PDS instance
89
90 content := "This is a test post"
91 title := "Test Post Title"
92
93 req := posts.CreatePostRequest{
94 Community: testCommunity.DID, // Using DID directly
95 Title: &title,
96 Content: &content,
97 AuthorDID: testUserDID,
98 }
99
100 // This will fail at token refresh step (expected for unit test)
101 // We're using a fake token that can't be parsed
102 authCtx := middleware.SetTestUserDID(ctx, testUserDID)
103 _, err := postService.CreatePost(authCtx, req)
104
105 // For now, we expect an error because token is fake
106 // In a full E2E test with real PDS, this would succeed
107 require.Error(t, err)
108 t.Logf("Expected error (fake token): %v", err)
109 // Verify the error is from token refresh or PDS, not validation
110 assert.Contains(t, err.Error(), "failed to refresh community credentials")
111 })
112
113 t.Run("Create text post with community handle", func(t *testing.T) {
114 // Test that we can use community handle instead of DID
115 // This validates at-identifier resolution per atProto best practices
116
117 content := "Post using handle instead of DID"
118 title := "Handle Test"
119
120 req := posts.CreatePostRequest{
121 Community: testCommunity.Handle, // Using canonical atProto handle
122 Title: &title,
123 Content: &content,
124 AuthorDID: testUserDID,
125 }
126
127 // Should resolve handle to DID and proceed
128 // Will still fail at token refresh (expected with fake token)
129 authCtx := middleware.SetTestUserDID(ctx, testUserDID)
130 _, err := postService.CreatePost(authCtx, req)
131 require.Error(t, err)
132 // Should fail at token refresh, not community resolution
133 assert.Contains(t, err.Error(), "failed to refresh community credentials")
134 })
135
136 t.Run("Create text post with ! prefix handle", func(t *testing.T) {
137 // Test that we can also use ! prefix with scoped format: !name@instance
138 // This is Coves-specific UX shorthand for name.community.instance
139
140 content := "Post using !-prefixed handle"
141 title := "Prefixed Handle Test"
142
143 // Extract name from handle: "gardening.community.coves.social" -> "gardening"
144 // Scoped format: !gardening@coves.social
145 handleParts := strings.Split(testCommunity.Handle, ".")
146 communityName := handleParts[0]
147 instanceDomain := strings.Join(handleParts[2:], ".") // Skip ".community."
148 scopedHandle := fmt.Sprintf("!%s@%s", communityName, instanceDomain)
149
150 req := posts.CreatePostRequest{
151 Community: scopedHandle, // !gardening@coves.social
152 Title: &title,
153 Content: &content,
154 AuthorDID: testUserDID,
155 }
156
157 // Should resolve handle to DID and proceed
158 // Will still fail at token refresh (expected with fake token)
159 authCtx := middleware.SetTestUserDID(ctx, testUserDID)
160 _, err := postService.CreatePost(authCtx, req)
161 require.Error(t, err)
162 // Should fail at token refresh, not community resolution
163 assert.Contains(t, err.Error(), "failed to refresh community credentials")
164 })
165
166 t.Run("Reject post with missing community", func(t *testing.T) {
167 content := "Post without community"
168
169 req := posts.CreatePostRequest{
170 Community: "", // Missing!
171 Content: &content,
172 AuthorDID: testUserDID,
173 }
174
175 authCtx := middleware.SetTestUserDID(ctx, testUserDID)
176 _, err := postService.CreatePost(authCtx, req)
177 require.Error(t, err)
178 assert.True(t, posts.IsValidationError(err))
179 })
180
181 t.Run("Reject post with non-existent community handle", func(t *testing.T) {
182 content := "Post with non-existent handle"
183
184 req := posts.CreatePostRequest{
185 Community: "nonexistent.community.test.coves.social", // Valid canonical handle format, but doesn't exist
186 Content: &content,
187 AuthorDID: testUserDID,
188 }
189
190 authCtx := middleware.SetTestUserDID(ctx, testUserDID)
191 _, err := postService.CreatePost(authCtx, req)
192 require.Error(t, err)
193 // Should fail with community not found (wrapped in error)
194 assert.Contains(t, err.Error(), "community not found")
195 })
196
197 t.Run("Reject post with missing author DID", func(t *testing.T) {
198 content := "Post without author"
199
200 req := posts.CreatePostRequest{
201 Community: testCommunity.DID,
202 Content: &content,
203 AuthorDID: "", // Missing!
204 }
205
206 authCtx := middleware.SetTestUserDID(ctx, testUserDID)
207 _, err := postService.CreatePost(authCtx, req)
208 require.Error(t, err)
209 assert.True(t, posts.IsValidationError(err))
210 assert.Contains(t, err.Error(), "authorDid")
211 })
212
213 t.Run("Reject post in non-existent community", func(t *testing.T) {
214 content := "Post in fake community"
215
216 req := posts.CreatePostRequest{
217 Community: "did:plc:nonexistent",
218 Content: &content,
219 AuthorDID: testUserDID,
220 }
221
222 authCtx := middleware.SetTestUserDID(ctx, testUserDID)
223 _, err := postService.CreatePost(authCtx, req)
224 require.Error(t, err)
225 assert.Equal(t, posts.ErrCommunityNotFound, err)
226 })
227
228 t.Run("Reject post with too-long content", func(t *testing.T) {
229 // Create content longer than 50k characters
230 longContent := string(make([]byte, 50001))
231
232 req := posts.CreatePostRequest{
233 Community: testCommunity.DID,
234 Content: &longContent,
235 AuthorDID: testUserDID,
236 }
237
238 authCtx := middleware.SetTestUserDID(ctx, testUserDID)
239 _, err := postService.CreatePost(authCtx, req)
240 require.Error(t, err)
241 assert.True(t, posts.IsValidationError(err))
242 assert.Contains(t, err.Error(), "too long")
243 })
244
245 t.Run("Reject post with invalid content label", func(t *testing.T) {
246 content := "Post with invalid label"
247
248 req := posts.CreatePostRequest{
249 Community: testCommunity.DID,
250 Content: &content,
251 Labels: &posts.SelfLabels{
252 Values: []posts.SelfLabel{
253 {Val: "invalid_label"}, // Not in known values!
254 },
255 },
256 AuthorDID: testUserDID,
257 }
258
259 authCtx := middleware.SetTestUserDID(ctx, testUserDID)
260 _, err := postService.CreatePost(authCtx, req)
261 require.Error(t, err)
262 assert.True(t, posts.IsValidationError(err))
263 assert.Contains(t, err.Error(), "unknown content label")
264 })
265
266 t.Run("Accept post with valid content labels", func(t *testing.T) {
267 content := "Post with valid labels"
268
269 req := posts.CreatePostRequest{
270 Community: testCommunity.DID,
271 Content: &content,
272 Labels: &posts.SelfLabels{
273 Values: []posts.SelfLabel{
274 {Val: "nsfw"},
275 {Val: "spoiler"},
276 },
277 },
278 AuthorDID: testUserDID,
279 }
280
281 // Will fail at token refresh (expected with fake token)
282 authCtx := middleware.SetTestUserDID(ctx, testUserDID)
283 _, err := postService.CreatePost(authCtx, req)
284 require.Error(t, err)
285 // Should fail at token refresh, not validation
286 assert.Contains(t, err.Error(), "failed to refresh community credentials")
287 })
288}
289
290// TestPostRepository_Create tests the repository layer
291func TestPostRepository_Create(t *testing.T) {
292 if testing.Short() {
293 t.Skip("Skipping integration test in short mode")
294 }
295
296 db := setupTestDB(t)
297 defer func() {
298 if err := db.Close(); err != nil {
299 t.Logf("Failed to close database: %v", err)
300 }
301 }()
302
303 // Cleanup first
304 _, _ = db.Exec("DELETE FROM posts WHERE community_did LIKE 'did:plc:test%'")
305 _, _ = db.Exec("DELETE FROM communities WHERE did LIKE 'did:plc:test%'")
306 _, _ = db.Exec("DELETE FROM users WHERE did LIKE 'did:plc:test%'")
307
308 // Setup: Create test user and community
309 ctx := context.Background()
310 userRepo := postgres.NewUserRepository(db)
311 communityRepo := postgres.NewCommunityRepository(db)
312
313 testUserDID := generateTestDID("postauthor2")
314 _, err := userRepo.Create(ctx, &users.User{
315 DID: testUserDID,
316 Handle: "postauthor2.test",
317 PDSURL: "http://localhost:3001",
318 })
319 require.NoError(t, err)
320
321 testCommunityDID := generateTestDID("testcommunity2")
322 _, err = communityRepo.Create(ctx, &communities.Community{
323 DID: testCommunityDID,
324 Handle: "testcommunity2.community.test.coves.social", // Canonical format (no ! prefix)
325 Name: "testcommunity2",
326 Visibility: "public",
327 CreatedByDID: testUserDID,
328 HostedByDID: "did:web:test.coves.social",
329 PDSURL: "http://localhost:3001",
330 })
331 require.NoError(t, err)
332
333 postRepo := postgres.NewPostRepository(db)
334
335 t.Run("Insert post successfully", func(t *testing.T) {
336 content := "Test post content"
337 title := "Test Title"
338
339 post := &posts.Post{
340 URI: "at://" + testCommunityDID + "/social.coves.community.post/test123",
341 CID: "bafy2test123",
342 RKey: "test123",
343 AuthorDID: testUserDID,
344 CommunityDID: testCommunityDID,
345 Title: &title,
346 Content: &content,
347 }
348
349 err := postRepo.Create(ctx, post)
350 require.NoError(t, err)
351 assert.NotZero(t, post.ID, "Post should have ID after insert")
352 assert.NotZero(t, post.IndexedAt, "Post should have IndexedAt timestamp")
353 })
354
355 t.Run("Reject duplicate post URI", func(t *testing.T) {
356 content := "Duplicate post"
357
358 post1 := &posts.Post{
359 URI: "at://" + testCommunityDID + "/social.coves.community.post/duplicate",
360 CID: "bafy2duplicate1",
361 RKey: "duplicate",
362 AuthorDID: testUserDID,
363 CommunityDID: testCommunityDID,
364 Content: &content,
365 }
366
367 err := postRepo.Create(ctx, post1)
368 require.NoError(t, err)
369
370 // Try to insert again with same URI
371 post2 := &posts.Post{
372 URI: "at://" + testCommunityDID + "/social.coves.community.post/duplicate",
373 CID: "bafy2duplicate2",
374 RKey: "duplicate",
375 AuthorDID: testUserDID,
376 CommunityDID: testCommunityDID,
377 Content: &content,
378 }
379
380 err = postRepo.Create(ctx, post2)
381 require.Error(t, err)
382 assert.Contains(t, err.Error(), "already indexed")
383 })
384}