Files
agent-radar 2ed3415fb7
Publish Claude Code Binaries / Stage and publish binaries (push) Canceled after 0s
Publish Tutti App Runtime / Build darwin-arm64 (push) Canceled after 0s
Publish Tutti App Runtime / Build darwin-amd64 (push) Canceled after 0s
Publish Tutti App Runtime / Build linux-amd64 (push) Canceled after 0s
Publish Tutti App Runtime / Build windows-amd64 (push) Canceled after 0s
Sync Tutti Agent Skills / sync (push) Canceled after 0s
Windows Agent Adapters / Test Windows agent adapters (push) Canceled after 0s
Windows Daemon Adapters / Test Windows daemon adapters (push) Canceled after 0s
Publish Tutti App Runtime / Publish runtime catalog (push) Canceled after 0s
源码快照 @ 2026-09-01 (source-only, 剔除非源码素材)
2026-09-01 20:17:27 +08:00

453 lines
13 KiB
Go

package commerce
import (
"context"
"errors"
"net/http"
"net/http/httptest"
"sync"
"sync/atomic"
"testing"
"time"
)
func TestProductSummaryNormalizesMembershipCreditsAndAuthorization(t *testing.T) {
var requestCookies sync.Map
server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) {
requestCookies.Store(request.URL.Path, request.Header.Get("Cookie"))
writer.Header().Set("Content-Type", "application/json")
switch request.URL.Path {
case "/v1/user-info":
_, _ = writer.Write([]byte(`{
"is_vip": true,
"vip_level": "basic",
"vip_billing_period": "month",
"vip_renew_at": "2026-08-01T00:00:00Z",
"vip_cancel_at_period_end": false,
"available_credits": "1200"
}`))
case "/v1/credits/overview":
_, _ = writer.Write([]byte(`{
"available_credits": "2450.52",
"expiring_credits_within_24h": "100.25",
"next_expire_at": "2026-07-07T00:00:00Z"
}`))
case "/v1/credits/login-claim":
_, _ = writer.Write([]byte(`{"first_login_claimed":false}`))
default:
http.NotFound(writer, request)
}
}))
defer server.Close()
store := &memoryRewardReceiptStore{}
service, err := NewService(Config{
BaseURL: server.URL,
Authorizer: RequestAuthorizerFunc(func(request *http.Request) error {
request.Header.Set("Cookie", "session_id=session-1")
return nil
}),
RewardReceiptStore: store,
})
if err != nil {
t.Fatal(err)
}
summary := service.ProductSummary(context.Background(), "user-1")
if summary.Membership == nil || summary.Membership.TierKey != "basic" ||
summary.Membership.DisplayName != "Basic" {
t.Fatalf("membership = %#v", summary.Membership)
}
if summary.MembershipAccess != MembershipAccessActive {
t.Fatalf("membership access = %q, want active", summary.MembershipAccess)
}
if summary.Credits == nil || summary.Credits.AvailableCredits == nil ||
*summary.Credits.AvailableCredits != "2450.52" {
t.Fatalf("credits = %#v", summary.Credits)
}
if summary.Credits.ExpiringCreditsWithin24h == nil ||
*summary.Credits.ExpiringCreditsWithin24h != "100.25" {
t.Fatalf("expiring credits = %#v", summary.Credits)
}
for _, path := range []string{"/v1/user-info", "/v1/credits/overview", "/v1/credits/login-claim"} {
cookie, ok := requestCookies.Load(path)
if !ok || cookie != "session_id=session-1" {
t.Fatalf("%s Cookie = %q, want session cookie", path, cookie)
}
}
if summary.PartialError != nil {
t.Fatalf("partial error = %#v, want nil", summary.PartialError)
}
}
func TestProductSummaryReturnsPartialErrorsWithoutDiscardingUsableData(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) {
switch request.URL.Path {
case "/v1/user-info":
http.Error(writer, "private upstream detail", http.StatusUnauthorized)
case "/v1/credits/overview":
_, _ = writer.Write([]byte(`{"available_credits":"12.5"}`))
case "/v1/credits/login-claim":
_, _ = writer.Write([]byte(`{"first_login_claimed":false}`))
default:
http.NotFound(writer, request)
}
}))
defer server.Close()
service := newTestService(t, Config{
BaseURL: server.URL,
RewardReceiptStore: &memoryRewardReceiptStore{},
})
summary := service.ProductSummary(context.Background(), "user-1")
if summary.Credits == nil || summary.Credits.AvailableCredits == nil ||
*summary.Credits.AvailableCredits != "12.5" {
t.Fatalf("credits = %#v", summary.Credits)
}
if summary.PartialError == nil || summary.PartialError.Scope != "membership" ||
summary.PartialError.Code != ErrorCodeUnauthorized {
t.Fatalf("partial error = %#v", summary.PartialError)
}
if summary.PartialError.Message != "Commerce session is unavailable" {
t.Fatalf("partial error message = %q", summary.PartialError.Message)
}
if summary.MembershipAccess != MembershipAccessUnknown {
t.Fatalf("membership access = %q, want unknown", summary.MembershipAccess)
}
}
func TestMembershipFromUserInfoNormalizesAccessState(t *testing.T) {
t.Parallel()
tests := []struct {
name string
data map[string]any
want MembershipAccessState
wantTier string
}{
{
name: "top-level free is authoritative",
data: map[string]any{
"is_vip": false,
"vip_level": "free",
"membership": map[string]any{
"tier_key": "pro",
"access_status": "active",
},
},
want: MembershipAccessFree,
},
{
name: "top-level paid is active",
data: map[string]any{
"is_vip": true,
"vip_level": "pro",
},
want: MembershipAccessActive,
},
{
name: "top-level vip flag without level is unknown",
data: map[string]any{
"is_vip": true,
},
want: MembershipAccessUnknown,
},
{
name: "top-level paid level without vip flag is unknown",
data: map[string]any{
"vip_level": "pro",
},
want: MembershipAccessUnknown,
},
{
name: "contradictory top-level facts fail closed",
data: map[string]any{
"is_vip": false,
"vip_level": "pro",
"membership": map[string]any{
"tier_key": "pro",
"access_status": "active",
},
},
want: MembershipAccessUnknown,
wantTier: "pro",
},
{
name: "legacy free tier is free",
data: map[string]any{
"membership": map[string]any{
"tier_key": "free",
},
},
want: MembershipAccessFree,
},
{
name: "legacy explicit active access is active",
data: map[string]any{
"membership": map[string]any{
"tier_key": "pro",
"access_status": "active",
"cancel_at_period_end": true,
},
},
want: MembershipAccessActive,
},
{
name: "legacy explicit inactive access is inactive",
data: map[string]any{
"membership": map[string]any{
"tier_key": "pro",
"access_status": "inactive",
},
},
want: MembershipAccessInactive,
},
{
name: "unrecognized state fails closed",
data: map[string]any{
"membership": map[string]any{
"tier_key": "pro",
"access_status": "past_due",
},
},
want: MembershipAccessUnknown,
},
{
name: "missing membership is unknown",
data: map[string]any{},
want: MembershipAccessUnknown,
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
t.Parallel()
summary, got := MembershipFromUserInfo(test.data)
if got != test.want {
t.Fatalf("membership access = %q, want %q", got, test.want)
}
if test.wantTier != "" && (summary == nil || summary.TierKey != test.wantTier) {
t.Fatalf("membership summary = %#v, want tier %q", summary, test.wantTier)
}
})
}
}
func TestRegistrationRewardClaimIsSerializedPersistedAndDismissed(t *testing.T) {
var claimCount atomic.Int32
server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) {
switch request.URL.Path {
case "/v1/user-info":
_, _ = writer.Write([]byte(`{"is_vip":false,"vip_level":"free"}`))
case "/v1/credits/overview":
_, _ = writer.Write([]byte(`{"available_credits":"500"}`))
case "/v1/credits/login-claim":
claimCount.Add(1)
time.Sleep(20 * time.Millisecond)
_, _ = writer.Write([]byte(`{
"grant_no":"fallback",
"first_login_claimed":true,
"first_login_grant_no":"first-grant-1",
"first_login_grant_credits":"500",
"daily_claimed":true,
"daily_grant_credits":"200"
}`))
default:
http.NotFound(writer, request)
}
}))
defer server.Close()
store := &memoryRewardReceiptStore{}
service := newTestService(t, Config{BaseURL: server.URL, RewardReceiptStore: store})
const callers = 8
rewards := make(chan *RegistrationCreditsReward, callers)
var waitGroup sync.WaitGroup
for range callers {
waitGroup.Add(1)
go func() {
defer waitGroup.Done()
rewards <- service.ProductSummary(context.Background(), "user-1").RegistrationCreditsReward
}()
}
waitGroup.Wait()
close(rewards)
var reward *RegistrationCreditsReward
for candidate := range rewards {
if candidate == nil {
t.Fatal("concurrent caller did not observe pending reward")
}
if reward == nil {
reward = candidate
continue
}
if candidate.ID != reward.ID {
t.Fatalf("reward id = %q, want %q", candidate.ID, reward.ID)
}
}
if claimCount.Load() != 1 {
t.Fatalf("claim count = %d, want 1", claimCount.Load())
}
if reward == nil || reward.Credits != 500 || reward.GrantNo != "first-grant-1" {
t.Fatalf("reward = %#v", reward)
}
if err := service.DismissRegistrationCreditsReward(context.Background(), reward.ID); err != nil {
t.Fatal(err)
}
summary := service.ProductSummary(context.Background(), "user-1")
if summary.RegistrationCreditsReward != nil {
t.Fatalf("reward after dismiss = %#v", summary.RegistrationCreditsReward)
}
if claimCount.Load() != 1 {
t.Fatalf("claim count after dismiss = %d, want 1", claimCount.Load())
}
}
func TestProductSummaryHonorsCancellation(t *testing.T) {
started := make(chan struct{})
release := make(chan struct{})
server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) {
select {
case started <- struct{}{}:
default:
}
select {
case <-request.Context().Done():
case <-release:
}
}))
defer func() {
close(release)
server.Close()
}()
service := newTestService(t, Config{
BaseURL: server.URL,
RewardReceiptStore: &memoryRewardReceiptStore{},
})
ctx, cancel := context.WithCancel(context.Background())
done := make(chan ProductSummary, 1)
go func() {
done <- service.ProductSummary(ctx, "user-1")
}()
<-started
cancel()
select {
case summary := <-done:
if summary.PartialError == nil || summary.PartialError.Code != ErrorCodeUnavailable {
t.Fatalf("partial error = %#v", summary.PartialError)
}
case <-time.After(time.Second):
t.Fatal("ProductSummary did not return after cancellation")
}
}
func TestNewServiceFailsClosedWithoutAuthorizerOrReceiptStore(t *testing.T) {
_, err := NewService(Config{BaseURL: "https://example.com"})
if !errors.Is(err, ErrRequestAuthorizerRequired) {
t.Fatalf("error = %v, want ErrRequestAuthorizerRequired", err)
}
_, err = NewService(Config{
BaseURL: "https://example.com",
Authorizer: RequestAuthorizerFunc(func(*http.Request) error {
return nil
}),
})
if !errors.Is(err, ErrRewardReceiptStoreRequired) {
t.Fatalf("error = %v, want ErrRewardReceiptStoreRequired", err)
}
}
func TestRegistrationRewardFailsClosedWhenReceiptStateIsUnavailable(t *testing.T) {
var claimCount atomic.Int32
server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) {
if request.URL.Path != "/v1/credits/login-claim" {
_, _ = writer.Write([]byte(`{}`))
return
}
claimCount.Add(1)
_, _ = writer.Write([]byte(`{
"first_login_claimed":true,
"first_login_grant_no":"grant-1",
"first_login_grant_credits":"500"
}`))
}))
defer server.Close()
loadFailed := newTestService(t, Config{
BaseURL: server.URL,
RewardReceiptStore: &failingRewardReceiptStore{
loadErr: errors.New("receipt load failed"),
},
})
if reward := loadFailed.ProductSummary(context.Background(), "user-1").RegistrationCreditsReward; reward != nil {
t.Fatalf("reward with failed load = %#v, want nil", reward)
}
if claimCount.Load() != 0 {
t.Fatalf("claim count after failed load = %d, want 0", claimCount.Load())
}
saveFailed := newTestService(t, Config{
BaseURL: server.URL,
RewardReceiptStore: &failingRewardReceiptStore{
saveErr: errors.New("receipt save failed"),
},
})
if reward := saveFailed.ProductSummary(context.Background(), "user-1").RegistrationCreditsReward; reward != nil {
t.Fatalf("reward with failed save = %#v, want nil", reward)
}
if reward := saveFailed.ProductSummary(context.Background(), "user-1").RegistrationCreditsReward; reward != nil {
t.Fatalf("second reward with failed save = %#v, want nil", reward)
}
if claimCount.Load() != 1 {
t.Fatalf("claim count after failed save = %d, want 1", claimCount.Load())
}
}
func newTestService(t *testing.T, config Config) *Service {
t.Helper()
if config.Authorizer == nil {
config.Authorizer = RequestAuthorizerFunc(func(*http.Request) error { return nil })
}
service, err := NewService(config)
if err != nil {
t.Fatal(err)
}
return service
}
type memoryRewardReceiptStore struct {
mu sync.Mutex
state RewardReceiptState
}
type failingRewardReceiptStore struct {
loadErr error
saveErr error
}
func (s *failingRewardReceiptStore) Load(context.Context) (RewardReceiptState, error) {
return RewardReceiptState{}, s.loadErr
}
func (s *failingRewardReceiptStore) Save(context.Context, RewardReceiptState) error {
return s.saveErr
}
func (s *memoryRewardReceiptStore) Load(context.Context) (RewardReceiptState, error) {
s.mu.Lock()
defer s.mu.Unlock()
return cloneRewardReceiptState(s.state), nil
}
func (s *memoryRewardReceiptStore) Save(_ context.Context, state RewardReceiptState) error {
s.mu.Lock()
defer s.mu.Unlock()
s.state = cloneRewardReceiptState(state)
return nil
}