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