group_handler_duplicate_test.go 5.6 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163
  1. //go:build unit
  2. package admin
  3. import (
  4. "context"
  5. "net/http"
  6. "net/http/httptest"
  7. "testing"
  8. middleware2 "github.com/Wei-Shaw/sub2api/internal/server/middleware"
  9. "github.com/Wei-Shaw/sub2api/internal/service"
  10. "github.com/gin-gonic/gin"
  11. "github.com/stretchr/testify/require"
  12. )
  13. type duplicateGroupAdminServiceStub struct {
  14. service.AdminService
  15. group *service.Group
  16. calls int
  17. recoverCalls int
  18. groupID int64
  19. actorScope string
  20. operationKey string
  21. recoverScope string
  22. recoverKey string
  23. created bool
  24. }
  25. func (s *duplicateGroupAdminServiceStub) DuplicateGroup(_ context.Context, groupID int64, actorScope, operationKey string) (*service.Group, error) {
  26. s.calls++
  27. s.groupID = groupID
  28. s.actorScope = actorScope
  29. s.operationKey = operationKey
  30. s.created = true
  31. return s.group, nil
  32. }
  33. func (s *duplicateGroupAdminServiceStub) RecoverDuplicateGroup(_ context.Context, _ int64, actorScope, operationKey string) (*service.Group, error) {
  34. s.recoverCalls++
  35. s.recoverScope = actorScope
  36. s.recoverKey = operationKey
  37. if !s.created {
  38. return nil, nil
  39. }
  40. return s.group, nil
  41. }
  42. func setupDuplicateGroupRouter(t *testing.T, svc service.AdminService) *gin.Engine {
  43. t.Helper()
  44. previousCoordinator := service.DefaultIdempotencyCoordinator()
  45. service.SetDefaultIdempotencyCoordinator(nil)
  46. t.Cleanup(func() { service.SetDefaultIdempotencyCoordinator(previousCoordinator) })
  47. gin.SetMode(gin.TestMode)
  48. router := gin.New()
  49. router.Use(func(c *gin.Context) {
  50. c.Set(string(middleware2.ContextKeyUser), middleware2.AuthSubject{UserID: 77})
  51. c.Next()
  52. })
  53. handler := NewGroupHandler(svc, nil, nil)
  54. router.POST("/api/v1/admin/groups/:id/duplicate", handler.Duplicate)
  55. return router
  56. }
  57. func duplicateGroupHandlerFixture() *service.Group {
  58. return &service.Group{
  59. ID: 43,
  60. Name: "primary (Copy)",
  61. Platform: service.PlatformAnthropic,
  62. Status: "inactive",
  63. RateMultiplier: 1,
  64. AccountCount: 3,
  65. ActiveAccountCount: 2,
  66. DuplicateOperationID: "internal-operation-must-not-leak",
  67. ModelRouting: map[string][]int64{"claude-*": {7}},
  68. }
  69. }
  70. func TestDuplicateGroupHandlerReturnsAdminDTOWithoutOperationMetadata(t *testing.T) {
  71. svc := &duplicateGroupAdminServiceStub{group: duplicateGroupHandlerFixture()}
  72. router := setupDuplicateGroupRouter(t, svc)
  73. recorder := httptest.NewRecorder()
  74. request := httptest.NewRequest(http.MethodPost, "/api/v1/admin/groups/42/duplicate", nil)
  75. router.ServeHTTP(recorder, request)
  76. require.Equal(t, http.StatusOK, recorder.Code)
  77. require.Equal(t, 1, svc.calls)
  78. require.Contains(t, recorder.Body.String(), `"name":"primary (Copy)"`)
  79. require.Contains(t, recorder.Body.String(), `"status":"inactive"`)
  80. require.Contains(t, recorder.Body.String(), `"account_count":3`)
  81. require.NotContains(t, recorder.Body.String(), "duplicate_operation_id")
  82. require.NotContains(t, recorder.Body.String(), "internal-operation-must-not-leak")
  83. }
  84. func TestDuplicateGroupHandlerRejectsInvalidID(t *testing.T) {
  85. for _, id := range []string{"not-a-number", "0", "-1"} {
  86. t.Run(id, func(t *testing.T) {
  87. svc := &duplicateGroupAdminServiceStub{}
  88. router := setupDuplicateGroupRouter(t, svc)
  89. recorder := httptest.NewRecorder()
  90. request := httptest.NewRequest(http.MethodPost, "/api/v1/admin/groups/"+id+"/duplicate", nil)
  91. router.ServeHTTP(recorder, request)
  92. require.Equal(t, http.StatusBadRequest, recorder.Code)
  93. require.Zero(t, svc.calls)
  94. })
  95. }
  96. }
  97. func TestDuplicateGroupHandlerReplaysSameIdempotencyKey(t *testing.T) {
  98. svc := &duplicateGroupAdminServiceStub{group: duplicateGroupHandlerFixture()}
  99. router := setupDuplicateGroupRouter(t, svc)
  100. service.SetDefaultIdempotencyCoordinator(service.NewIdempotencyCoordinator(newMemoryIdempotencyRepoStub(), service.DefaultIdempotencyConfig()))
  101. call := func() *httptest.ResponseRecorder {
  102. recorder := httptest.NewRecorder()
  103. request := httptest.NewRequest(http.MethodPost, "/api/v1/admin/groups/42/duplicate", nil)
  104. request.Header.Set("Idempotency-Key", "duplicate-group-42")
  105. router.ServeHTTP(recorder, request)
  106. return recorder
  107. }
  108. first := call()
  109. second := call()
  110. require.Equal(t, http.StatusOK, first.Code)
  111. require.Equal(t, http.StatusOK, second.Code)
  112. require.Equal(t, 1, svc.calls)
  113. require.Equal(t, int64(42), svc.groupID)
  114. require.Equal(t, "admin:77", svc.actorScope)
  115. require.Equal(t, "duplicate-group-42", svc.operationKey)
  116. require.Equal(t, "true", second.Header().Get("X-Idempotency-Replayed"))
  117. }
  118. func TestDuplicateGroupHandlerRecoversAfterMarkSucceededFailure(t *testing.T) {
  119. svc := &duplicateGroupAdminServiceStub{group: duplicateGroupHandlerFixture()}
  120. router := setupDuplicateGroupRouter(t, svc)
  121. repo := &failOnceMarkSucceededRepo{memoryIdempotencyRepoStub: newMemoryIdempotencyRepoStub(), failNext: true}
  122. service.SetDefaultIdempotencyCoordinator(service.NewIdempotencyCoordinator(repo, service.DefaultIdempotencyConfig()))
  123. call := func() *httptest.ResponseRecorder {
  124. recorder := httptest.NewRecorder()
  125. request := httptest.NewRequest(http.MethodPost, "/api/v1/admin/groups/42/duplicate", nil)
  126. request.Header.Set("Idempotency-Key", "duplicate-group-42-recovery")
  127. router.ServeHTTP(recorder, request)
  128. return recorder
  129. }
  130. first := call()
  131. second := call()
  132. require.Equal(t, http.StatusOK, first.Code)
  133. require.Equal(t, http.StatusOK, second.Code)
  134. require.Equal(t, "true", first.Header().Get("X-Idempotency-Recovered"))
  135. require.Equal(t, "true", second.Header().Get("X-Idempotency-Recovered"))
  136. require.Equal(t, 1, svc.calls, "ambiguous retries must not repeat the create side effect")
  137. require.Equal(t, 2, svc.recoverCalls)
  138. require.Equal(t, "admin:77", svc.recoverScope)
  139. require.Equal(t, "duplicate-group-42-recovery", svc.recoverKey)
  140. }