Files
sub2api/backend/internal/service/api_key_service_update_fields_test.go
T
shaw 86fb4781f4 refactor(repository): scope user/api-key updates to declared columns
UserRepository.Update and APIKeyRepository.Update rewrote the whole row on
every call, regardless of which fields the caller meant to change. Several
columns on those tables are maintained by dedicated atomic paths (balance
deduction, quota and rate-limit counters, limit adjustments, activity
timestamps), so a caller holding a slightly older snapshot could silently
roll them back - a lost update.

Both methods now take an explicit column mask and persist only the columns
the caller declares; everything else keeps its current database value.

- All user and API-key call sites declare exactly what they mutate, which
  turns admin edits and profile saves into genuine partial updates.
- Email uniqueness locking/lookup and allowed_groups sync only run when
  those fields are part of the update.
- UserUpdateFields deliberately has no balance/total_recharged members, so
  Update cannot touch them. New AdjustBalance/SetBalance apply the change in
  a single statement and return before/after values; admin balance
  adjustment uses them instead of read-modify-write.
- promo_codes.used_count is no longer written by Update; it is only ever
  incremented by the redemption path.
- The billing hot path that marks an API key quota-exhausted writes only
  status.
- Dropped a no-op row write in RevokeAllUserTokens: users has no
  token_version column, so it persisted nothing while still overwriting
  concurrently-updated columns.

Adds integration coverage that a stale snapshot cannot revert concurrent
atomic writes, and unit coverage pinning the column set each entry point
declares.
2026-07-28 17:21:32 +08:00

134 lines
4.0 KiB
Go

//go:build unit
package service
import (
"context"
"testing"
"github.com/stretchr/testify/require"
)
// api_keys 的 quota_used / usage_5h|1d|7d 由计费热路径原子递增。
// 编辑 Key(改名、换分组……)若整行回写,并发累计的用量就会被旧快照覆盖。
// 这些用例锁死"只声明请求真正要改的列"。
type updateFieldsAPIKeyRepoStub struct {
quotaBaseAPIKeyRepoStub
key *APIKey
updateFields []APIKeyUpdateFields
}
// IncrementQuotaUsed 模拟计费热路径上的原子递增:只动 quota_used。
func (s *updateFieldsAPIKeyRepoStub) IncrementQuotaUsed(_ context.Context, _ int64, amount float64) (float64, error) {
s.key.QuotaUsed += amount
return s.key.QuotaUsed, nil
}
func (s *updateFieldsAPIKeyRepoStub) GetByID(context.Context, int64) (*APIKey, error) {
clone := *s.key
return &clone, nil
}
func (s *updateFieldsAPIKeyRepoStub) Update(_ context.Context, _ *APIKey, fields APIKeyUpdateFields) error {
s.updateFields = append(s.updateFields, fields)
return nil
}
func newUpdateFieldsAPIKeyService(key *APIKey) (*APIKeyService, *updateFieldsAPIKeyRepoStub) {
repo := &updateFieldsAPIKeyRepoStub{key: key}
return &APIKeyService{apiKeyRepo: repo}, repo
}
func TestAPIKeyUpdate_OnlyDeclaresRequestedColumns(t *testing.T) {
name := "renamed"
quota := 500.0
rateLimit := 42.0
whitelist := []string{"10.0.0.1"}
tests := []struct {
name string
req UpdateAPIKeyRequest
want APIKeyUpdateFields
}{
{
name: "name only",
req: UpdateAPIKeyRequest{Name: &name},
want: APIKeyUpdateFields{Name: true},
},
{
name: "quota only",
req: UpdateAPIKeyRequest{Quota: &quota},
want: APIKeyUpdateFields{Quota: true},
},
{
name: "rate limit threshold only",
req: UpdateAPIKeyRequest{RateLimit5h: &rateLimit},
want: APIKeyUpdateFields{RateLimits: true},
},
{
name: "ip whitelist only",
req: UpdateAPIKeyRequest{IPWhitelist: &whitelist},
want: APIKeyUpdateFields{IPRules: true},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
svc, repo := newUpdateFieldsAPIKeyService(&APIKey{
ID: 1,
UserID: 7,
Key: "sk-test",
Name: "before",
Status: StatusActive,
Quota: 100,
QuotaUsed: 30,
Usage5h: 12,
})
_, err := svc.Update(context.Background(), 1, 7, tt.req)
require.NoError(t, err)
require.Equal(t, []APIKeyUpdateFields{tt.want}, repo.updateFields)
})
}
}
// 显式重置仍需声明对应的列,避免收窄写入列时把功能改坏。
func TestAPIKeyUpdate_DeclaresUsageColumnsOnExplicitReset(t *testing.T) {
reset := true
svc, repo := newUpdateFieldsAPIKeyService(&APIKey{
ID: 1, UserID: 7, Key: "sk-test", Status: StatusActive, Quota: 100, QuotaUsed: 30, Usage5h: 12,
})
_, err := svc.Update(context.Background(), 1, 7, UpdateAPIKeyRequest{
ResetQuota: &reset,
ResetRateLimitUsage: &reset,
})
require.NoError(t, err)
require.Equal(t, []APIKeyUpdateFields{{QuotaUsed: true, RateLimitUsage: true}}, repo.updateFields)
}
// 配额扩容会顺带把 quota_exhausted 复活为 active,此时必须声明 status。
func TestAPIKeyUpdate_DeclaresStatusWhenReactivated(t *testing.T) {
quota := 500.0
svc, repo := newUpdateFieldsAPIKeyService(&APIKey{
ID: 1, UserID: 7, Key: "sk-test", Status: StatusAPIKeyQuotaExhausted, Quota: 100, QuotaUsed: 100,
})
_, err := svc.Update(context.Background(), 1, 7, UpdateAPIKeyRequest{Quota: &quota})
require.NoError(t, err)
require.Equal(t, []APIKeyUpdateFields{{Quota: true, Status: true}}, repo.updateFields)
}
// 计费热路径把 Key 标记为配额耗尽时只写 status,
// 否则会把刚原子递增的 quota_used 按快照覆盖掉。
func TestUpdateQuotaUsed_ExhaustedMarkOnlyDeclaresStatus(t *testing.T) {
repo := &updateFieldsAPIKeyRepoStub{key: &APIKey{
ID: 1, UserID: 7, Key: "sk-test", Status: StatusActive, Quota: 10, QuotaUsed: 10,
}}
svc := &APIKeyService{apiKeyRepo: repo}
require.NoError(t, svc.UpdateQuotaUsed(context.Background(), 1, 5))
require.Equal(t, []APIKeyUpdateFields{{Status: true}}, repo.updateFields)
}