A community based topic aggregation platform built on atproto
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}