Files
sub2api/backend/internal/service/crs_sync_helpers_test.go
T
Brisbanehuang b0f5007f04 feat(billing-probe): optionally sync account rate from upstream declared rate
Successful upstream billing probes already persist the upstream-declared
rate as a display-only snapshot. Add a per-account opt-in that writes
that declared rate back to the account's rate_multiplier, so the account
cost basis follows upstream repricing automatically instead of drifting
until an operator notices.

- new per-account flag upstream_billing_rate_sync_enabled stored next to
  the probe flag in account extra: enabling sync force-enables the
  probe, disabling the probe cascades sync off, and eligibility follows
  IsUpstreamBillingProbeIdentity (tightened from any non-empty platform
  to an explicit whitelist of the five supported API-key platforms so
  future platforms do not silently inherit probe/sync semantics)
- only a successful probe whose declared rate survives validation
  (finite, within bounds, not rounded to zero at the rate_multiplier
  decimal(10,4) scale) writes back; failed/unsupported/invalid probes
  leave rate_multiplier unchanged
- the writeback rides the existing snapshot CAS transaction:
  UpdateUpstreamBillingProbeSnapshot takes an optional rateMultiplier
  and applies it atomically with the snapshot under the same
  identity/snapshot compare-and-swap, so a probe result observed on a
  stale account cannot clobber a concurrent admin edit
- admin edit goes through UpdateWithAccountBillingSettings, which
  applies the form without overwriting a rate that a probe synchronized
  after the edit form was loaded (nil rateMultiplier = not edited);
  once sync is enabled the edit form shows the rate as managed
- bulk update rejects a manual rate_multiplier change when any target
  account has rate sync enabled (whole batch fails with a dedicated
  error so partial writes cannot bypass the sync ownership)
- frontend: sync toggle with hints in the edit modal (probe/sync
  enable/disable coupling enforced in the form), synced-rate tooltip on
  the rate cell, bulk edit modal warns and blocks rate edits that hit
  sync-enabled accounts; en/zh copy updated
- tests: service unit tests for sync gating/validation/cascade, sqlmock
  repo tests for the extended CAS, real-PostgreSQL integration tests
  (rate written only for successful+enabled accounts, manual rate
  protected after sync disabled, admin edit preserved across concurrent
  probe sync), handler/API contract updates, frontend specs for modal
  coupling, bulk rejection and rate cell
2026-08-01 22:11:09 +08:00

207 lines
6.6 KiB
Go

package service
import (
"testing"
"github.com/stretchr/testify/require"
)
func TestBuildSelectedSet(t *testing.T) {
tests := []struct {
name string
ids []string
wantNil bool
wantSize int
}{
{
name: "nil input returns nil (backward compatible: create all)",
ids: nil,
wantNil: true,
},
{
name: "empty slice returns empty map (create none)",
ids: []string{},
wantNil: false,
wantSize: 0,
},
{
name: "single ID",
ids: []string{"abc-123"},
wantNil: false,
wantSize: 1,
},
{
name: "multiple IDs",
ids: []string{"a", "b", "c"},
wantNil: false,
wantSize: 3,
},
{
name: "duplicate IDs are deduplicated",
ids: []string{"a", "a", "b"},
wantNil: false,
wantSize: 2,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := buildSelectedSet(tt.ids)
if tt.wantNil {
if got != nil {
t.Errorf("buildSelectedSet(%v) = %v, want nil", tt.ids, got)
}
return
}
if got == nil {
t.Fatalf("buildSelectedSet(%v) = nil, want non-nil map", tt.ids)
}
if len(got) != tt.wantSize {
t.Errorf("buildSelectedSet(%v) has %d entries, want %d", tt.ids, len(got), tt.wantSize)
}
// Verify all unique IDs are present
for _, id := range tt.ids {
if _, ok := got[id]; !ok {
t.Errorf("buildSelectedSet(%v) missing key %q", tt.ids, id)
}
}
})
}
}
func TestShouldCreateAccount(t *testing.T) {
tests := []struct {
name string
crsID string
selectedSet map[string]struct{}
want bool
}{
{
name: "nil set allows all (backward compatible)",
crsID: "any-id",
selectedSet: nil,
want: true,
},
{
name: "empty set blocks all",
crsID: "any-id",
selectedSet: map[string]struct{}{},
want: false,
},
{
name: "ID in set is allowed",
crsID: "abc-123",
selectedSet: map[string]struct{}{"abc-123": {}, "def-456": {}},
want: true,
},
{
name: "ID not in set is blocked",
crsID: "xyz-789",
selectedSet: map[string]struct{}{"abc-123": {}, "def-456": {}},
want: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := shouldCreateAccount(tt.crsID, tt.selectedSet)
if got != tt.want {
t.Errorf("shouldCreateAccount(%q, %v) = %v, want %v",
tt.crsID, tt.selectedSet, got, tt.want)
}
})
}
}
func TestReconcileCRSUpstreamBillingProbeExtra(t *testing.T) {
remote := map[string]any{
"crs_account_id": "remote-1",
UpstreamBillingProbeEnabledExtraKey: true,
UpstreamBillingRateSyncEnabledExtraKey: true,
UpstreamBillingProbeExtraKey: map[string]any{"status": "remote"},
}
t.Run("create drops remote managed fields", func(t *testing.T) {
extra := mergeMap(nil, remote)
reconcileCRSUpstreamBillingProbeExtra(nil, PlatformOpenAI, AccountTypeAPIKey, map[string]any{"api_key": "new"}, extra)
require.NotContains(t, extra, UpstreamBillingProbeEnabledExtraKey)
require.NotContains(t, extra, UpstreamBillingRateSyncEnabledExtraKey)
require.NotContains(t, extra, UpstreamBillingProbeExtraKey)
})
existing := &Account{
Platform: PlatformOpenAI,
Type: AccountTypeAPIKey,
Credentials: map[string]any{"api_key": "local", "base_url": "http://127.0.0.1:8080"},
Extra: map[string]any{
UpstreamBillingProbeEnabledExtraKey: false,
UpstreamBillingRateSyncEnabledExtraKey: false,
UpstreamBillingProbeExtraKey: map[string]any{"status": "local"},
},
}
t.Run("same identity keeps local state", func(t *testing.T) {
extra := mergeMap(existing.Extra, remote)
reconcileCRSUpstreamBillingProbeExtra(existing, existing.Platform, existing.Type, mergeMap(existing.Credentials, nil), extra)
require.Equal(t, false, extra[UpstreamBillingProbeEnabledExtraKey])
require.Equal(t, false, extra[UpstreamBillingRateSyncEnabledExtraKey])
require.Equal(t, map[string]any{"status": "local"}, extra[UpstreamBillingProbeExtraKey])
})
t.Run("same identity preserves enabled rate sync", func(t *testing.T) {
enabled := *existing
enabled.Extra = mergeMap(existing.Extra, map[string]any{
UpstreamBillingProbeEnabledExtraKey: true,
UpstreamBillingRateSyncEnabledExtraKey: true,
})
extra := mergeMap(enabled.Extra, remote)
reconcileCRSUpstreamBillingProbeExtra(&enabled, enabled.Platform, enabled.Type, mergeMap(enabled.Credentials, nil), extra)
require.Equal(t, true, extra[UpstreamBillingProbeEnabledExtraKey])
require.Equal(t, true, extra[UpstreamBillingRateSyncEnabledExtraKey])
})
t.Run("identity change keeps enabled and clears snapshot", func(t *testing.T) {
extra := mergeMap(existing.Extra, remote)
reconcileCRSUpstreamBillingProbeExtra(existing, PlatformOpenAI, AccountTypeAPIKey, map[string]any{"api_key": "changed"}, extra)
require.Equal(t, false, extra[UpstreamBillingProbeEnabledExtraKey])
require.Equal(t, false, extra[UpstreamBillingRateSyncEnabledExtraKey])
require.NotContains(t, extra, UpstreamBillingProbeExtraKey)
})
// API-key 平台间切换:探测资格保留(放宽后不再限 OpenAI),开关沿用本地值,
// 但平台属于探测身份,快照必须作废。
for _, target := range []struct {
name string
platform string
}{
{name: "anthropic api key", platform: PlatformAnthropic},
{name: "gemini api key", platform: PlatformGemini},
} {
t.Run(target.name+" keeps enabled and clears snapshot", func(t *testing.T) {
extra := mergeMap(existing.Extra, remote)
reconcileCRSUpstreamBillingProbeExtra(existing, target.platform, AccountTypeAPIKey, existing.Credentials, extra)
require.Equal(t, false, extra[UpstreamBillingProbeEnabledExtraKey])
require.Equal(t, false, extra[UpstreamBillingRateSyncEnabledExtraKey])
require.NotContains(t, extra, UpstreamBillingProbeExtraKey)
})
}
for _, target := range []struct {
name string
platform string
typeName string
}{
{name: "anthropic oauth", platform: PlatformAnthropic, typeName: AccountTypeOAuth},
{name: "openai oauth", platform: PlatformOpenAI, typeName: AccountTypeOAuth},
{name: "gemini oauth", platform: PlatformGemini, typeName: AccountTypeOAuth},
} {
t.Run(target.name+" removes inapplicable state", func(t *testing.T) {
extra := mergeMap(existing.Extra, remote)
reconcileCRSUpstreamBillingProbeExtra(existing, target.platform, target.typeName, existing.Credentials, extra)
require.NotContains(t, extra, UpstreamBillingProbeEnabledExtraKey)
require.NotContains(t, extra, UpstreamBillingRateSyncEnabledExtraKey)
require.NotContains(t, extra, UpstreamBillingProbeExtraKey)
})
}
}