ops_error_where_test.go 6.5 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153
  1. package repository
  2. import (
  3. "strings"
  4. "testing"
  5. "github.com/Wei-Shaw/sub2api/internal/service"
  6. )
  7. func TestBuildOpsErrorLogsWhere_UserScopedFilters(t *testing.T) {
  8. uid := int64(42)
  9. kid := int64(7)
  10. filter := &service.OpsErrorLogFilter{
  11. UserID: &uid,
  12. APIKeyID: &kid,
  13. Model: "claude-sonnet-4-5",
  14. ExcludeCountTokens: true,
  15. ErrorPhasesAny: []string{"auth"},
  16. ErrorTypesAny: []string{"rate_limit_error"},
  17. View: "all",
  18. }
  19. where, args := buildOpsErrorLogsWhere(filter)
  20. for _, want := range []string{
  21. "e.user_id = $",
  22. "e.api_key_id = $",
  23. "COALESCE(e.requested_model, e.model, '') = $",
  24. "COALESCE(e.is_count_tokens, false) = false",
  25. "e.error_phase = ANY($",
  26. "e.error_type = ANY($",
  27. } {
  28. if !strings.Contains(where, want) {
  29. t.Fatalf("where missing %q\nfull: %s", want, where)
  30. }
  31. }
  32. if len(args) != 5 {
  33. t.Fatalf("expected 5 args, got %d", len(args))
  34. }
  35. }
  36. func TestBuildOpsErrorLogsWhere_ModelFuzzy(t *testing.T) {
  37. // 默认(ModelFuzzy=false)保持精确匹配
  38. exact := &service.OpsErrorLogFilter{Model: "claude"}
  39. whereExact, _ := buildOpsErrorLogsWhere(exact)
  40. if !strings.Contains(whereExact, "COALESCE(e.requested_model, e.model, '') = $") {
  41. t.Fatalf("default should be exact match, got: %s", whereExact)
  42. }
  43. // ModelFuzzy=true → ILIKE
  44. fuzzy := &service.OpsErrorLogFilter{Model: "claude", ModelFuzzy: true}
  45. whereFuzzy, args := buildOpsErrorLogsWhere(fuzzy)
  46. if !strings.Contains(whereFuzzy, "COALESCE(e.requested_model, e.model, '') ILIKE $") {
  47. t.Fatalf("ModelFuzzy should use ILIKE, got: %s", whereFuzzy)
  48. }
  49. if len(args) != 1 || args[0] != "%claude%" {
  50. t.Fatalf("expected arg \"%%claude%%\", got %v", args)
  51. }
  52. // 通配符转义:输入含 % 应被转义为字面量
  53. esc := &service.OpsErrorLogFilter{Model: "50%off", ModelFuzzy: true}
  54. _, escArgs := buildOpsErrorLogsWhere(esc)
  55. if len(escArgs) != 1 || escArgs[0] != `%50\%off%` {
  56. t.Fatalf("expected escaped arg, got %v", escArgs)
  57. }
  58. esc2 := &service.OpsErrorLogFilter{Model: "gpt_4o", ModelFuzzy: true}
  59. _, escArgs2 := buildOpsErrorLogsWhere(esc2)
  60. if len(escArgs2) != 1 || escArgs2[0] != `%gpt\_4o%` {
  61. t.Fatalf("underscore should be escaped, got %v", escArgs2)
  62. }
  63. }
  64. // TestBuildOpsErrorLogsWhere_CyberPolicyStatusExemption verifies that streaming
  65. // cyber_policy hits (status_code=200) remain visible in admin + user error-request
  66. // lists. The repository filter must emit an OR exemption for error_type='cyber_policy'
  67. // so that stream-path cyber rows (upstream delivers 200 with a failed SSE event) are
  68. // not silently excluded by the COALESCE(status_code,0) >= 400 guard.
  69. func TestBuildOpsErrorLogsWhere_CyberPolicyStatusExemption(t *testing.T) {
  70. // Default filter (no phase) must include the cyber_policy exemption.
  71. where, _ := buildOpsErrorLogsWhere(&service.OpsErrorLogFilter{})
  72. if !strings.Contains(where, "e.error_type = 'cyber_policy'") {
  73. t.Fatalf("default filter must exempt cyber_policy from status >= 400 guard\nfull: %s", where)
  74. }
  75. if !strings.Contains(where, "COALESCE(e.status_code, 0) >= 400") {
  76. t.Fatalf("default filter must still include the status >= 400 guard for non-cyber rows\nfull: %s", where)
  77. }
  78. // phase=upstream WITHOUT the recovered-upstream opt-in keeps the status guard:
  79. // request-error list endpoints filter by phase=upstream as a plain condition.
  80. whereUpstream, _ := buildOpsErrorLogsWhere(&service.OpsErrorLogFilter{Phase: "upstream"})
  81. if !strings.Contains(whereUpstream, "COALESCE(e.status_code, 0) >= 400") {
  82. t.Fatalf("upstream phase without IncludeRecoveredUpstream must keep the status guard\nfull: %s", whereUpstream)
  83. }
  84. if !strings.Contains(whereUpstream, "e.error_phase = $") {
  85. t.Fatalf("upstream phase filter must emit the error_phase condition\nfull: %s", whereUpstream)
  86. }
  87. // phase=upstream WITH IncludeRecoveredUpstream (ops 上游列表) skips the guard,
  88. // exposing recovered (<400) upstream rows.
  89. whereRecovered, _ := buildOpsErrorLogsWhere(&service.OpsErrorLogFilter{Phase: "upstream", IncludeRecoveredUpstream: true})
  90. if strings.Contains(whereRecovered, "status_code") {
  91. t.Fatalf("upstream phase with IncludeRecoveredUpstream must not add any status_code clause\nfull: %s", whereRecovered)
  92. }
  93. // account_auth uses the same explicit provider-health opt-in but remains a
  94. // distinct phase from inference upstream errors.
  95. whereAccountAuth, _ := buildOpsErrorLogsWhere(&service.OpsErrorLogFilter{Phase: "account_auth", IncludeRecoveredUpstream: true})
  96. if strings.Contains(whereAccountAuth, "status_code") {
  97. t.Fatalf("account_auth phase with IncludeRecoveredUpstream must expose recovered rows\nfull: %s", whereAccountAuth)
  98. }
  99. if !strings.Contains(whereAccountAuth, "e.error_phase = $") {
  100. t.Fatalf("account_auth recovered filter must retain its explicit phase\nfull: %s", whereAccountAuth)
  101. }
  102. whereProviderHealth, _ := buildOpsErrorLogsWhere(&service.OpsErrorLogFilter{
  103. ErrorPhasesAny: []string{"upstream", "account_auth"},
  104. IncludeRecoveredUpstream: true,
  105. })
  106. if strings.Contains(whereProviderHealth, "status_code") {
  107. t.Fatalf("provider-health ANY filter must expose recovered inference and credential rows\nfull: %s", whereProviderHealth)
  108. }
  109. if !strings.Contains(whereProviderHealth, "e.error_phase = ANY($") {
  110. t.Fatalf("provider-health filter must preserve distinct phase values\nfull: %s", whereProviderHealth)
  111. }
  112. whereUserAccountAuth, _ := buildOpsErrorLogsWhere(&service.OpsErrorLogFilter{ErrorPhasesAny: []string{"account_auth"}})
  113. if !strings.Contains(whereUserAccountAuth, "COALESCE(e.status_code, 0) >= 400") {
  114. t.Fatalf("request-error account_auth filters must exclude recovered successes\nfull: %s", whereUserAccountAuth)
  115. }
  116. whereMixed, _ := buildOpsErrorLogsWhere(&service.OpsErrorLogFilter{
  117. ErrorPhasesAny: []string{"account_auth", "request"},
  118. IncludeRecoveredUpstream: true,
  119. })
  120. if !strings.Contains(whereMixed, "COALESCE(e.status_code, 0) >= 400") {
  121. t.Fatalf("recovered opt-in must not bypass the guard for non-provider phases\nfull: %s", whereMixed)
  122. }
  123. }
  124. func TestBuildOpsErrorLogsWhere_UserOwnershipIsDirectOnly(t *testing.T) {
  125. uid := int64(42)
  126. filter := &service.OpsErrorLogFilter{UserID: &uid}
  127. where, args := buildOpsErrorLogsWhere(filter)
  128. if !strings.Contains(where, "e.user_id = $1") {
  129. t.Fatalf("user scope should match user_id exactly, got: %s", where)
  130. }
  131. if len(args) != 1 || args[0] != uid {
  132. t.Fatalf("expected user id arg %d, got %v", uid, args)
  133. }
  134. if strings.Contains(where, "deleted_key_owner_user_id") {
  135. t.Fatalf("user ownership must not depend on deleted-key attribution: %s", where)
  136. }
  137. }