channel_monitor_duplicate_test.go 6.0 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170
  1. //go:build unit
  2. package admin
  3. import (
  4. "context"
  5. "net/http"
  6. "net/http/httptest"
  7. "strings"
  8. "testing"
  9. middleware2 "github.com/Wei-Shaw/sub2api/internal/server/middleware"
  10. "github.com/Wei-Shaw/sub2api/internal/service"
  11. "github.com/gin-gonic/gin"
  12. "github.com/stretchr/testify/require"
  13. )
  14. type duplicateChannelMonitorHandlerRepoStub struct {
  15. service.ChannelMonitorRepository
  16. source *service.ChannelMonitor
  17. byOperation map[string]*service.ChannelMonitor
  18. createCalls int
  19. }
  20. func (r *duplicateChannelMonitorHandlerRepoStub) GetByID(_ context.Context, id int64) (*service.ChannelMonitor, error) {
  21. if r.source == nil || r.source.ID != id {
  22. return nil, service.ErrChannelMonitorNotFound
  23. }
  24. return r.source, nil
  25. }
  26. func (r *duplicateChannelMonitorHandlerRepoStub) Create(_ context.Context, monitor *service.ChannelMonitor) error {
  27. r.createCalls++
  28. monitor.ID = int64(100 + r.createCalls)
  29. stored := *monitor
  30. if stored.DuplicateOperationID != "" {
  31. if r.byOperation == nil {
  32. r.byOperation = make(map[string]*service.ChannelMonitor)
  33. }
  34. r.byOperation[stored.DuplicateOperationID] = &stored
  35. }
  36. return nil
  37. }
  38. func (r *duplicateChannelMonitorHandlerRepoStub) FindByDuplicateOperationID(_ context.Context, operationID string) (*service.ChannelMonitor, error) {
  39. monitor := r.byOperation[operationID]
  40. if monitor == nil {
  41. return nil, nil
  42. }
  43. cloned := *monitor
  44. return &cloned, nil
  45. }
  46. type duplicateChannelMonitorHandlerEncryptor struct{}
  47. func (duplicateChannelMonitorHandlerEncryptor) Encrypt(plaintext string) (string, error) {
  48. return "ENC:" + plaintext, nil
  49. }
  50. func (duplicateChannelMonitorHandlerEncryptor) Decrypt(ciphertext string) (string, error) {
  51. return strings.TrimPrefix(ciphertext, "ENC:"), nil
  52. }
  53. func setupDuplicateChannelMonitorRouter(t *testing.T) (*gin.Engine, *duplicateChannelMonitorHandlerRepoStub) {
  54. t.Helper()
  55. previousCoordinator := service.DefaultIdempotencyCoordinator()
  56. service.SetDefaultIdempotencyCoordinator(service.NewIdempotencyCoordinator(
  57. newMemoryIdempotencyRepoStub(),
  58. service.DefaultIdempotencyConfig(),
  59. ))
  60. t.Cleanup(func() { service.SetDefaultIdempotencyCoordinator(previousCoordinator) })
  61. repo := &duplicateChannelMonitorHandlerRepoStub{
  62. source: &service.ChannelMonitor{
  63. ID: 42,
  64. Name: "primary",
  65. Provider: service.MonitorProviderOpenAI,
  66. APIMode: service.MonitorAPIModeResponses,
  67. Endpoint: "https://api.example.com",
  68. APIKey: "ENC:top-secret",
  69. PrimaryModel: "gpt-5.4-mini",
  70. ExtraModels: []string{"gpt-5.4"},
  71. Enabled: true,
  72. IntervalSeconds: 60,
  73. BodyOverrideMode: service.MonitorBodyOverrideModeOff,
  74. },
  75. }
  76. monitorService := service.NewChannelMonitorService(repo, duplicateChannelMonitorHandlerEncryptor{})
  77. handler := NewChannelMonitorHandler(monitorService)
  78. gin.SetMode(gin.TestMode)
  79. router := gin.New()
  80. router.Use(func(c *gin.Context) {
  81. c.Set(string(middleware2.ContextKeyUser), middleware2.AuthSubject{UserID: 77})
  82. c.Next()
  83. })
  84. router.POST("/api/v1/admin/channel-monitors/:id/duplicate", handler.Duplicate)
  85. return router, repo
  86. }
  87. func TestDuplicateChannelMonitorHandlerRedactsKeyAndReplaysRetry(t *testing.T) {
  88. router, repo := setupDuplicateChannelMonitorRouter(t)
  89. request := httptest.NewRequest(http.MethodPost, "/api/v1/admin/channel-monitors/42/duplicate", nil)
  90. request.Header.Set("Idempotency-Key", "duplicate-channel-monitor-42")
  91. first := httptest.NewRecorder()
  92. router.ServeHTTP(first, request)
  93. require.Equal(t, http.StatusOK, first.Code)
  94. require.Equal(t, 1, repo.createCalls)
  95. require.Contains(t, first.Body.String(), `"name":"primary (Copy)"`)
  96. require.Contains(t, first.Body.String(), `"api_key_masked":"top-***"`)
  97. require.Contains(t, first.Body.String(), `"created_by":77`)
  98. require.Contains(t, first.Body.String(), `"enabled":false`)
  99. require.NotContains(t, first.Body.String(), "top-secret")
  100. retryRequest := httptest.NewRequest(http.MethodPost, "/api/v1/admin/channel-monitors/42/duplicate", nil)
  101. retryRequest.Header.Set("Idempotency-Key", "duplicate-channel-monitor-42")
  102. retry := httptest.NewRecorder()
  103. router.ServeHTTP(retry, retryRequest)
  104. require.Equal(t, http.StatusOK, retry.Code)
  105. require.Equal(t, "true", retry.Header().Get("X-Idempotency-Replayed"))
  106. require.Equal(t, 1, repo.createCalls)
  107. require.JSONEq(t, first.Body.String(), retry.Body.String())
  108. }
  109. func TestDuplicateChannelMonitorHandlerRejectsInvalidID(t *testing.T) {
  110. router, repo := setupDuplicateChannelMonitorRouter(t)
  111. request := httptest.NewRequest(http.MethodPost, "/api/v1/admin/channel-monitors/not-a-number/duplicate", nil)
  112. request.Header.Set("Idempotency-Key", "duplicate-channel-monitor-invalid")
  113. recorder := httptest.NewRecorder()
  114. router.ServeHTTP(recorder, request)
  115. require.Equal(t, http.StatusBadRequest, recorder.Code)
  116. require.Zero(t, repo.createCalls)
  117. }
  118. func TestDuplicateChannelMonitorHandlerRecoversAfterMarkSucceededFailure(t *testing.T) {
  119. router, repo := setupDuplicateChannelMonitorRouter(t)
  120. idempotencyRepo := &failOnceMarkSucceededRepo{
  121. memoryIdempotencyRepoStub: newMemoryIdempotencyRepoStub(),
  122. failNext: true,
  123. }
  124. service.SetDefaultIdempotencyCoordinator(service.NewIdempotencyCoordinator(
  125. idempotencyRepo,
  126. service.DefaultIdempotencyConfig(),
  127. ))
  128. call := func() *httptest.ResponseRecorder {
  129. request := httptest.NewRequest(http.MethodPost, "/api/v1/admin/channel-monitors/42/duplicate", nil)
  130. request.Header.Set("Idempotency-Key", "duplicate-channel-monitor-recovery")
  131. recorder := httptest.NewRecorder()
  132. router.ServeHTTP(recorder, request)
  133. return recorder
  134. }
  135. first := call()
  136. second := call()
  137. require.Equal(t, http.StatusOK, first.Code)
  138. require.Equal(t, http.StatusOK, second.Code)
  139. require.Equal(t, "true", first.Header().Get("X-Idempotency-Recovered"))
  140. require.Equal(t, "true", second.Header().Get("X-Idempotency-Recovered"))
  141. require.Equal(t, 1, repo.createCalls, "ambiguous retries must not repeat the create side effect")
  142. require.Contains(t, second.Body.String(), `"id":101`)
  143. require.Contains(t, second.Body.String(), `"api_key_masked":"top-***"`)
  144. require.NotContains(t, second.Body.String(), "top-secret")
  145. }