A community based topic aggregation platform built on atproto
at main 649 lines 20 kB view raw
1package integration 2 3import ( 4 "Coves/internal/api/routes" 5 "Coves/internal/atproto/pds" 6 "Coves/internal/core/blobs" 7 "Coves/internal/core/userblocks" 8 "Coves/internal/db/postgres" 9 "bytes" 10 "context" 11 "encoding/json" 12 "fmt" 13 "io" 14 "net/http" 15 "net/http/httptest" 16 "strings" 17 "sync" 18 "testing" 19 "time" 20 21 oauthlib "github.com/bluesky-social/indigo/atproto/auth/oauth" 22 "github.com/go-chi/chi/v5" 23) 24 25// mockPDSClient implements pds.Client for handler tests without a real PDS. 26// Tracks created and deleted records for verification in tests. 27type mockPDSClient struct { 28 did string 29 mu sync.Mutex 30 createdRecords map[string]bool 31 deletedRecords []string // collection/rkey pairs 32 createErr error // if set, CreateRecord returns this error 33} 34 35func newMockPDSClient(did string) *mockPDSClient { 36 return &mockPDSClient{ 37 did: did, 38 createdRecords: make(map[string]bool), 39 } 40} 41 42func (m *mockPDSClient) CreateRecord(_ context.Context, collection string, rkey string, _ any) (string, string, error) { 43 m.mu.Lock() 44 defer m.mu.Unlock() 45 if m.createErr != nil { 46 return "", "", m.createErr 47 } 48 uri := fmt.Sprintf("at://%s/%s/%s", m.did, collection, rkey) 49 m.createdRecords[uri] = true 50 return uri, "bafymockrecordcid", nil 51} 52 53func (m *mockPDSClient) DeleteRecord(_ context.Context, collection string, rkey string) error { 54 m.mu.Lock() 55 m.deletedRecords = append(m.deletedRecords, collection+"/"+rkey) 56 m.mu.Unlock() 57 return nil 58} 59 60func (m *mockPDSClient) ListRecords(_ context.Context, _ string, _ int, _ string) (*pds.ListRecordsResponse, error) { 61 return &pds.ListRecordsResponse{}, nil 62} 63 64func (m *mockPDSClient) GetRecord(_ context.Context, _ string, _ string) (*pds.RecordResponse, error) { 65 return &pds.RecordResponse{}, nil 66} 67 68func (m *mockPDSClient) PutRecord(_ context.Context, _ string, _ string, _ any, _ string) (string, string, error) { 69 return "", "", nil 70} 71 72func (m *mockPDSClient) UploadBlob(_ context.Context, _ []byte, _ string) (*blobs.BlobRef, error) { 73 return nil, nil 74} 75 76func (m *mockPDSClient) DID() string { 77 return m.did 78} 79 80func (m *mockPDSClient) HostURL() string { 81 return "http://localhost:3001" 82} 83 84// DeleteCallCount returns the number of DeleteRecord calls made. 85func (m *mockPDSClient) DeleteCallCount() int { 86 m.mu.Lock() 87 defer m.mu.Unlock() 88 return len(m.deletedRecords) 89} 90 91// mockPDSTracker manages per-session mock PDS clients so tests can inspect PDS interactions. 92type mockPDSTracker struct { 93 mu sync.Mutex 94 clients map[string]*mockPDSClient // DID -> client 95} 96 97func newMockPDSTracker() *mockPDSTracker { 98 return &mockPDSTracker{clients: make(map[string]*mockPDSClient)} 99} 100 101// Factory returns a PDSClientFactory that creates per-session mock clients. 102func (t *mockPDSTracker) Factory() userblocks.PDSClientFactory { 103 return func(_ context.Context, session *oauthlib.ClientSessionData) (pds.Client, error) { 104 did := session.AccountDID.String() 105 t.mu.Lock() 106 defer t.mu.Unlock() 107 if c, ok := t.clients[did]; ok { 108 return c, nil 109 } 110 c := newMockPDSClient(did) 111 t.clients[did] = c 112 return c, nil 113 } 114} 115 116// ClientFor returns the mock PDS client for the given DID. 117func (t *mockPDSTracker) ClientFor(did string) *mockPDSClient { 118 t.mu.Lock() 119 defer t.mu.Unlock() 120 return t.clients[did] 121} 122 123// userBlockTestEnv bundles all resources created by setupUserBlockTestServer. 124type userBlockTestEnv struct { 125 Server *httptest.Server 126 Auth *E2EOAuthMiddleware 127 Repo userblocks.Repository 128 PDSTracker *mockPDSTracker 129} 130 131// setupUserBlockTestServer creates a test HTTP server with userblock routes wired up. 132func setupUserBlockTestServer(t *testing.T) *userBlockTestEnv { 133 t.Helper() 134 135 db := setupTestDB(t) 136 t.Cleanup(func() { 137 // Clean up user_blocks before closing db 138 _, _ = db.Exec("DELETE FROM user_blocks") 139 if err := db.Close(); err != nil { 140 t.Logf("Failed to close database: %v", err) 141 } 142 }) 143 144 // Clean up user_blocks from any prior runs 145 _, _ = db.Exec("DELETE FROM user_blocks") 146 147 repo := postgres.NewUserBlockRepository(db) 148 tracker := newMockPDSTracker() 149 service := userblocks.NewServiceWithPDSFactory(repo, nil, tracker.Factory()) 150 151 e2eAuth := NewE2EOAuthMiddleware() 152 153 r := chi.NewRouter() 154 routes.RegisterUserBlockRoutes(r, service, e2eAuth.OAuthAuthMiddleware) 155 156 server := httptest.NewServer(r) 157 t.Cleanup(func() { server.Close() }) 158 159 return &userBlockTestEnv{ 160 Server: server, 161 Auth: e2eAuth, 162 Repo: repo, 163 PDSTracker: tracker, 164 } 165} 166 167// postXRPC sends a POST request to an XRPC endpoint and returns the response. 168// Fails the test on marshaling or request creation errors. 169func postXRPC(t *testing.T, serverURL, path, token string, body any) *http.Response { 170 t.Helper() 171 172 reqBody, err := json.Marshal(body) 173 if err != nil { 174 t.Fatalf("Failed to marshal request body: %v", err) 175 } 176 177 req, err := http.NewRequest(http.MethodPost, serverURL+path, bytes.NewBuffer(reqBody)) 178 if err != nil { 179 t.Fatalf("Failed to create request: %v", err) 180 } 181 req.Header.Set("Content-Type", "application/json") 182 if token != "" { 183 req.Header.Set("Authorization", "Bearer "+token) 184 } 185 186 resp, err := http.DefaultClient.Do(req) 187 if err != nil { 188 t.Fatalf("Failed to execute request to %s: %v", path, err) 189 } 190 191 return resp 192} 193 194// getXRPC sends a GET request to an XRPC endpoint and returns the response. 195func getXRPC(t *testing.T, serverURL, path, token string) *http.Response { 196 t.Helper() 197 198 req, err := http.NewRequest(http.MethodGet, serverURL+path, nil) 199 if err != nil { 200 t.Fatalf("Failed to create request: %v", err) 201 } 202 if token != "" { 203 req.Header.Set("Authorization", "Bearer "+token) 204 } 205 206 resp, err := http.DefaultClient.Do(req) 207 if err != nil { 208 t.Fatalf("Failed to execute request to %s: %v", path, err) 209 } 210 211 return resp 212} 213 214// TestUserBlockHandler_BlockUser tests the block user endpoint 215func TestUserBlockHandler_BlockUser(t *testing.T) { 216 env := setupUserBlockTestServer(t) 217 218 blockerDID := "did:plc:blocker123" 219 targetDID := "did:plc:target456" 220 token := env.Auth.AddUser(blockerDID) 221 222 resp := postXRPC(t, env.Server.URL, "/xrpc/social.coves.actor.blockUser", token, map[string]string{ 223 "subject": targetDID, 224 }) 225 defer func() { _ = resp.Body.Close() }() 226 227 if resp.StatusCode != http.StatusOK { 228 body, _ := io.ReadAll(resp.Body) 229 t.Fatalf("Expected 200, got %d: %s", resp.StatusCode, string(body)) 230 } 231 232 var blockResp struct { 233 Block struct { 234 RecordURI string `json:"recordUri"` 235 RecordCID string `json:"recordCid"` 236 } `json:"block"` 237 } 238 239 if decodeErr := json.NewDecoder(resp.Body).Decode(&blockResp); decodeErr != nil { 240 t.Fatalf("Failed to decode response: %v", decodeErr) 241 } 242 243 if blockResp.Block.RecordURI == "" { 244 t.Error("Expected non-empty recordUri in response") 245 } 246 if blockResp.Block.RecordCID == "" { 247 t.Error("Expected non-empty recordCid in response") 248 } 249 250 // Verify the record URI contains the blocker's actual DID (not a hardcoded mock DID) 251 if !strings.Contains(blockResp.Block.RecordURI, blockerDID) { 252 t.Errorf("Expected recordUri to contain blocker DID %q, got %q", blockerDID, blockResp.Block.RecordURI) 253 } 254 255 // Simulate Jetstream indexing by inserting directly into repo 256 // (In a real E2E test, the Jetstream consumer would handle this) 257 ctx := context.Background() 258 _, repoErr := env.Repo.BlockUser(ctx, &userblocks.UserBlock{ 259 BlockerDID: blockerDID, 260 BlockedDID: targetDID, 261 BlockedAt: time.Now(), 262 RecordURI: blockResp.Block.RecordURI, 263 RecordCID: blockResp.Block.RecordCID, 264 }) 265 if repoErr != nil { 266 t.Fatalf("Failed to index block in repo: %v", repoErr) 267 } 268 269 // Verify block exists in AppView 270 isBlocked, checkErr := env.Repo.IsBlocked(ctx, blockerDID, targetDID) 271 if checkErr != nil { 272 t.Fatalf("Failed to check block: %v", checkErr) 273 } 274 if !isBlocked { 275 t.Error("Expected user to be blocked after block request") 276 } 277} 278 279// TestUserBlockHandler_BlockUser_MissingSubject tests blocking with missing subject 280func TestUserBlockHandler_BlockUser_MissingSubject(t *testing.T) { 281 env := setupUserBlockTestServer(t) 282 283 blockerDID := "did:plc:blocker789" 284 token := env.Auth.AddUser(blockerDID) 285 286 resp := postXRPC(t, env.Server.URL, "/xrpc/social.coves.actor.blockUser", token, map[string]string{}) 287 defer func() { _ = resp.Body.Close() }() 288 289 if resp.StatusCode != http.StatusBadRequest { 290 body, _ := io.ReadAll(resp.Body) 291 t.Fatalf("Expected 400, got %d: %s", resp.StatusCode, string(body)) 292 } 293 294 var errResp struct { 295 Error string `json:"error"` 296 Message string `json:"message"` 297 } 298 if decodeErr := json.NewDecoder(resp.Body).Decode(&errResp); decodeErr != nil { 299 t.Fatalf("Failed to decode error response: %v", decodeErr) 300 } 301 302 if errResp.Error != "InvalidRequest" { 303 t.Errorf("Expected error code 'InvalidRequest', got %q", errResp.Error) 304 } 305 if !strings.Contains(errResp.Message, "subject") { 306 t.Errorf("Expected error message to mention 'subject', got %q", errResp.Message) 307 } 308} 309 310// TestUserBlockHandler_BlockUser_Unauthenticated tests blocking without auth 311func TestUserBlockHandler_BlockUser_Unauthenticated(t *testing.T) { 312 env := setupUserBlockTestServer(t) 313 314 // No token — unauthenticated request 315 resp := postXRPC(t, env.Server.URL, "/xrpc/social.coves.actor.blockUser", "", map[string]string{ 316 "subject": "did:plc:target", 317 }) 318 defer func() { _ = resp.Body.Close() }() 319 320 if resp.StatusCode != http.StatusUnauthorized { 321 body, _ := io.ReadAll(resp.Body) 322 t.Fatalf("Expected 401, got %d: %s", resp.StatusCode, string(body)) 323 } 324} 325 326// TestUserBlockHandler_BlockUser_SelfBlock tests that self-blocking returns 400 327func TestUserBlockHandler_BlockUser_SelfBlock(t *testing.T) { 328 env := setupUserBlockTestServer(t) 329 330 selfDID := "did:plc:selfblock" 331 token := env.Auth.AddUser(selfDID) 332 333 resp := postXRPC(t, env.Server.URL, "/xrpc/social.coves.actor.blockUser", token, map[string]string{ 334 "subject": selfDID, 335 }) 336 defer func() { _ = resp.Body.Close() }() 337 338 if resp.StatusCode != http.StatusBadRequest { 339 body, _ := io.ReadAll(resp.Body) 340 t.Fatalf("Expected 400, got %d: %s", resp.StatusCode, string(body)) 341 } 342 343 var errResp struct { 344 Error string `json:"error"` 345 Message string `json:"message"` 346 } 347 if decodeErr := json.NewDecoder(resp.Body).Decode(&errResp); decodeErr != nil { 348 t.Fatalf("Failed to decode error response: %v", decodeErr) 349 } 350 351 if !strings.Contains(errResp.Message, "block yourself") { 352 t.Errorf("Expected error message about self-blocking, got %q", errResp.Message) 353 } 354} 355 356// TestUserBlockHandler_UnblockUser tests the unblock user endpoint 357func TestUserBlockHandler_UnblockUser(t *testing.T) { 358 env := setupUserBlockTestServer(t) 359 360 blockerDID := "did:plc:unblocker1" 361 targetDID := "did:plc:unblockee1" 362 token := env.Auth.AddUser(blockerDID) 363 364 ctx := context.Background() 365 366 // First, create a block via the API 367 blockResp := postXRPC(t, env.Server.URL, "/xrpc/social.coves.actor.blockUser", token, map[string]string{ 368 "subject": targetDID, 369 }) 370 371 var blockResult struct { 372 Block struct { 373 RecordURI string `json:"recordUri"` 374 RecordCID string `json:"recordCid"` 375 } `json:"block"` 376 } 377 if decodeErr := json.NewDecoder(blockResp.Body).Decode(&blockResult); decodeErr != nil { 378 t.Fatalf("Failed to decode block response: %v", decodeErr) 379 } 380 _ = blockResp.Body.Close() 381 382 // Simulate Jetstream indexing: insert block record into AppView repo 383 _, repoErr := env.Repo.BlockUser(ctx, &userblocks.UserBlock{ 384 BlockerDID: blockerDID, 385 BlockedDID: targetDID, 386 BlockedAt: time.Now(), 387 RecordURI: blockResult.Block.RecordURI, 388 RecordCID: blockResult.Block.RecordCID, 389 }) 390 if repoErr != nil { 391 t.Fatalf("Failed to index block: %v", repoErr) 392 } 393 394 // Verify block exists before unblock 395 isBlocked, err := env.Repo.IsBlocked(ctx, blockerDID, targetDID) 396 if err != nil { 397 t.Fatalf("Failed to check block: %v", err) 398 } 399 if !isBlocked { 400 t.Fatal("Expected block to exist before unblock") 401 } 402 403 // Now unblock 404 unblockResp := postXRPC(t, env.Server.URL, "/xrpc/social.coves.actor.unblockUser", token, map[string]string{ 405 "subject": targetDID, 406 }) 407 defer func() { _ = unblockResp.Body.Close() }() 408 409 if unblockResp.StatusCode != http.StatusOK { 410 body, _ := io.ReadAll(unblockResp.Body) 411 t.Fatalf("Expected 200, got %d: %s", unblockResp.StatusCode, string(body)) 412 } 413 414 var unblockResult struct { 415 Success bool `json:"success"` 416 } 417 if decodeErr := json.NewDecoder(unblockResp.Body).Decode(&unblockResult); decodeErr != nil { 418 t.Fatalf("Failed to decode unblock response: %v", decodeErr) 419 } 420 421 if !unblockResult.Success { 422 t.Error("Expected success: true in unblock response") 423 } 424 425 // Verify PDS DeleteRecord was actually called 426 mockClient := env.PDSTracker.ClientFor(blockerDID) 427 if mockClient == nil { 428 t.Fatal("Expected mock PDS client to exist for blocker") 429 } 430 if mockClient.DeleteCallCount() == 0 { 431 t.Error("Expected PDS DeleteRecord to be called during unblock, but it was never called") 432 } 433 434 // Simulate Jetstream processing the delete and verify block removal from AppView. 435 // In production, the Jetstream consumer removes the block from the repo. 436 // Here we manually remove it to verify the full flow. 437 if removeErr := env.Repo.UnblockUser(ctx, blockerDID, targetDID); removeErr != nil { 438 t.Fatalf("Failed to remove block from repo: %v", removeErr) 439 } 440 441 isBlockedAfter, checkErr := env.Repo.IsBlocked(ctx, blockerDID, targetDID) 442 if checkErr != nil { 443 t.Fatalf("Failed to check block after unblock: %v", checkErr) 444 } 445 if isBlockedAfter { 446 t.Error("Expected block to be removed from AppView after unblock") 447 } 448} 449 450// TestUserBlockHandler_GetBlockedUsers tests listing blocked users 451func TestUserBlockHandler_GetBlockedUsers(t *testing.T) { 452 env := setupUserBlockTestServer(t) 453 454 blockerDID := "did:plc:lister1" 455 target1DID := "did:plc:listed1" 456 target2DID := "did:plc:listed2" 457 token := env.Auth.AddUser(blockerDID) 458 459 ctx := context.Background() 460 461 // Index two blocks directly in the repo (simulating Jetstream indexing) 462 _, err := env.Repo.BlockUser(ctx, &userblocks.UserBlock{ 463 BlockerDID: blockerDID, 464 BlockedDID: target1DID, 465 BlockedAt: time.Now().Add(-1 * time.Minute), 466 RecordURI: fmt.Sprintf("at://%s/social.coves.actor.block/tid1", blockerDID), 467 RecordCID: "bafyblock1", 468 }) 469 if err != nil { 470 t.Fatalf("Failed to create block 1: %v", err) 471 } 472 473 _, err = env.Repo.BlockUser(ctx, &userblocks.UserBlock{ 474 BlockerDID: blockerDID, 475 BlockedDID: target2DID, 476 BlockedAt: time.Now(), 477 RecordURI: fmt.Sprintf("at://%s/social.coves.actor.block/tid2", blockerDID), 478 RecordCID: "bafyblock2", 479 }) 480 if err != nil { 481 t.Fatalf("Failed to create block 2: %v", err) 482 } 483 484 resp := getXRPC(t, env.Server.URL, "/xrpc/social.coves.actor.getBlockedUsers?limit=10", token) 485 defer func() { _ = resp.Body.Close() }() 486 487 if resp.StatusCode != http.StatusOK { 488 body, _ := io.ReadAll(resp.Body) 489 t.Fatalf("Expected 200, got %d: %s", resp.StatusCode, string(body)) 490 } 491 492 var listResp struct { 493 Blocks []struct { 494 BlockedDID string `json:"blockedDid"` 495 RecordURI string `json:"recordUri"` 496 RecordCID string `json:"recordCid"` 497 } `json:"blocks"` 498 } 499 if decodeErr := json.NewDecoder(resp.Body).Decode(&listResp); decodeErr != nil { 500 t.Fatalf("Failed to decode list response: %v", decodeErr) 501 } 502 503 if len(listResp.Blocks) != 2 { 504 t.Fatalf("Expected 2 blocks, got %d", len(listResp.Blocks)) 505 } 506 507 // Verify both target DIDs are present 508 foundDIDs := make(map[string]bool) 509 for _, b := range listResp.Blocks { 510 foundDIDs[b.BlockedDID] = true 511 if b.RecordURI == "" { 512 t.Error("Expected non-empty recordUri") 513 } 514 if b.RecordCID == "" { 515 t.Error("Expected non-empty recordCid") 516 } 517 } 518 519 if !foundDIDs[target1DID] { 520 t.Errorf("Expected %s in blocked list", target1DID) 521 } 522 if !foundDIDs[target2DID] { 523 t.Errorf("Expected %s in blocked list", target2DID) 524 } 525} 526 527// TestUserBlockHandler_GetBlockedUsers_Unauthenticated tests that getBlockedUsers requires auth 528func TestUserBlockHandler_GetBlockedUsers_Unauthenticated(t *testing.T) { 529 env := setupUserBlockTestServer(t) 530 531 // No token — unauthenticated request 532 resp := getXRPC(t, env.Server.URL, "/xrpc/social.coves.actor.getBlockedUsers", "") 533 defer func() { _ = resp.Body.Close() }() 534 535 if resp.StatusCode != http.StatusUnauthorized { 536 body, _ := io.ReadAll(resp.Body) 537 t.Fatalf("Expected 401, got %d: %s", resp.StatusCode, string(body)) 538 } 539} 540 541// TestUserBlockHandler_BlockUser_DuplicateConflict tests that blocking a user who is 542// already blocked on PDS returns the existing block (via repo lookup) or 409 Conflict. 543func TestUserBlockHandler_BlockUser_DuplicateConflict(t *testing.T) { 544 env := setupUserBlockTestServer(t) 545 546 blockerDID := "did:plc:conflict-blocker" 547 targetDID := "did:plc:conflict-target" 548 token := env.Auth.AddUser(blockerDID) 549 550 ctx := context.Background() 551 552 // Pre-index an existing block in the AppView repo (as if Jetstream already processed it) 553 existingURI := fmt.Sprintf("at://%s/social.coves.actor.block/existing1", blockerDID) 554 existingCID := "bafyexistingcid" 555 _, err := env.Repo.BlockUser(ctx, &userblocks.UserBlock{ 556 BlockerDID: blockerDID, 557 BlockedDID: targetDID, 558 BlockedAt: time.Now(), 559 RecordURI: existingURI, 560 RecordCID: existingCID, 561 }) 562 if err != nil { 563 t.Fatalf("Failed to pre-index block: %v", err) 564 } 565 566 // Configure mock PDS to return conflict error (simulating duplicate record on PDS) 567 mockClient := env.PDSTracker.ClientFor(blockerDID) 568 if mockClient == nil { 569 // Trigger client creation by making any authenticated request first 570 _ = postXRPC(t, env.Server.URL, "/xrpc/social.coves.actor.blockUser", token, map[string]string{ 571 "subject": "did:plc:dummy", 572 }) 573 mockClient = env.PDSTracker.ClientFor(blockerDID) 574 } 575 mockClient.mu.Lock() 576 mockClient.createErr = fmt.Errorf("conflict: %w", pds.ErrConflict) 577 mockClient.mu.Unlock() 578 579 // Attempt to block the same user again — PDS returns conflict 580 resp := postXRPC(t, env.Server.URL, "/xrpc/social.coves.actor.blockUser", token, map[string]string{ 581 "subject": targetDID, 582 }) 583 defer func() { _ = resp.Body.Close() }() 584 585 // The service should find the existing block in the repo and return it 586 if resp.StatusCode != http.StatusOK { 587 body, _ := io.ReadAll(resp.Body) 588 t.Fatalf("Expected 200 (existing block returned), got %d: %s", resp.StatusCode, string(body)) 589 } 590 591 var blockResp struct { 592 Block struct { 593 RecordURI string `json:"recordUri"` 594 RecordCID string `json:"recordCid"` 595 } `json:"block"` 596 } 597 if decodeErr := json.NewDecoder(resp.Body).Decode(&blockResp); decodeErr != nil { 598 t.Fatalf("Failed to decode response: %v", decodeErr) 599 } 600 601 if blockResp.Block.RecordURI != existingURI { 602 t.Errorf("Expected existing RecordURI=%s, got %s", existingURI, blockResp.Block.RecordURI) 603 } 604 if blockResp.Block.RecordCID != existingCID { 605 t.Errorf("Expected existing RecordCID=%s, got %s", existingCID, blockResp.Block.RecordCID) 606 } 607} 608 609// TestUserBlockHandler_BlockUser_DuplicateConflict_NotIndexed tests the 409 path when 610// PDS returns conflict but the block hasn't been indexed in AppView yet. 611func TestUserBlockHandler_BlockUser_DuplicateConflict_NotIndexed(t *testing.T) { 612 env := setupUserBlockTestServer(t) 613 614 blockerDID := "did:plc:conflict-noindex-blocker" 615 targetDID := "did:plc:conflict-noindex-target" 616 token := env.Auth.AddUser(blockerDID) 617 618 // Force mock PDS client creation, then set conflict error 619 _ = postXRPC(t, env.Server.URL, "/xrpc/social.coves.actor.blockUser", token, map[string]string{ 620 "subject": "did:plc:warmup", 621 }) 622 mockClient := env.PDSTracker.ClientFor(blockerDID) 623 mockClient.mu.Lock() 624 mockClient.createErr = fmt.Errorf("conflict: %w", pds.ErrConflict) 625 mockClient.mu.Unlock() 626 627 // Block target — PDS says conflict, but repo has no block → should return 409 628 resp := postXRPC(t, env.Server.URL, "/xrpc/social.coves.actor.blockUser", token, map[string]string{ 629 "subject": targetDID, 630 }) 631 defer func() { _ = resp.Body.Close() }() 632 633 if resp.StatusCode != http.StatusConflict { 634 body, _ := io.ReadAll(resp.Body) 635 t.Fatalf("Expected 409, got %d: %s", resp.StatusCode, string(body)) 636 } 637 638 var errResp struct { 639 Error string `json:"error"` 640 Message string `json:"message"` 641 } 642 if decodeErr := json.NewDecoder(resp.Body).Decode(&errResp); decodeErr != nil { 643 t.Fatalf("Failed to decode error response: %v", decodeErr) 644 } 645 646 if errResp.Error != "AlreadyExists" { 647 t.Errorf("Expected error code 'AlreadyExists', got %q", errResp.Error) 648 } 649}