package httpapi import ( "bytes" "context" "encoding/json" "errors" "net/http" "net/http/httptest" "testing" "git.nianxx.cn/wangxuming/NianAIGC/backend/internal/billing" "git.nianxx.cn/wangxuming/NianAIGC/backend/internal/identity" "git.nianxx.cn/wangxuming/NianAIGC/backend/internal/jobs" ) func TestBillingMemberRoutesUseRefreshedSessionScope(t *testing.T) { service := &billingHTTPServiceStub{} h := billingTestHandler(t, identity.Session{AuthMode: identity.AuthModeUser, User: identity.User{ID: "db-user", ClientID: "platform", OrganizationID: "db-org", OrganizationName: "DB Org", Role: "user"}}, service, &billingAccountStoreStub{}) response := serveJSON(t, h, http.MethodGet, "/api/billing", nil) if response.Code != 200 || service.overviewOrganization != "db-org" || service.overviewAccount != "db-user" { t.Fatalf("status=%d scope=%s/%s body=%s", response.Code, service.overviewOrganization, service.overviewAccount, response.Body.String()) } response = serveJSON(t, h, http.MethodPost, "/api/billing/quote", map[string]any{"provider": "bailian", "capability": "image.generate", "ownerId": "attacker", "organizationId": "other"}) if response.Code != 200 || service.quote.AccountID != "db-user" || service.quote.OrganizationID != "db-org" || service.quote.Role != "user" { t.Fatalf("status=%d quote=%+v body=%s", response.Code, service.quote, response.Body.String()) } } func TestBillingQuotePreparesProviderAndModelFromServerOwnedEngineTargets(t *testing.T) { service := &billingHTTPServiceStub{} h := billingTestHandler(t, identity.Session{User: identity.User{ID: "db-user", ClientID: "platform", OrganizationID: "db-org", Role: "user"}}, service, nil) h = NewBillingHandlerWithBuilder(h.(*billingHandler).authorizer, service, nil, jobs.ProviderJobBuilder{ ImageEngine: "bailian", ImageProvider: "bailian", ImageModel: "wan-image", ImageEngines: map[string]jobs.ProviderTarget{"evolink": {Provider: "evolink", Model: "gpt-image-2"}}, VideoEngine: "seedance", VideoProvider: "seedance", VideoModel: "seedance-2", NewID: func() string { return "quote-only" }, }) response := serveJSON(t, h, http.MethodPost, "/api/billing/quote", map[string]any{"engine": "evolink", "prompt": "hello", "quality": "high"}) if response.Code != 200 || service.quote.Provider != "evolink" || service.quote.Capability != "image.generate" || service.quote.ReqKey != "gpt-image-2" || service.quote.Parameters["quality"] != "high" { t.Fatalf("status=%d quote=%+v body=%s", response.Code, service.quote, response.Body.String()) } } func TestBillingRequiresOrganizationAndMapsDomainStatuses(t *testing.T) { service := &billingHTTPServiceStub{} h := billingTestHandler(t, identity.Session{AuthMode: identity.AuthModeUser, User: identity.User{ID: "user", ClientID: "platform", Role: "user"}}, service, nil) if got := serveJSON(t, h, http.MethodGet, "/api/billing", nil); got.Code != 422 { t.Fatalf("status=%d body=%s", got.Code, got.Body.String()) } service.err = &billing.StatusError{Status: 402, Err: billing.ErrInsufficientBalance} h = billingTestHandler(t, identity.Session{AuthMode: identity.AuthModeUser, User: identity.User{ID: "user", ClientID: "platform", OrganizationID: "org", Role: "user"}}, service, nil) if got := serveJSON(t, h, http.MethodPost, "/api/billing/quote", map[string]any{}); got.Code != 402 { t.Fatalf("status=%d body=%s", got.Code, got.Body.String()) } service.err = errors.New("database secret") got := serveJSON(t, h, http.MethodGet, "/api/billing", nil) if got.Code != 500 || bytes.Contains(got.Body.Bytes(), []byte("database secret")) { t.Fatalf("status=%d body=%s", got.Code, got.Body.String()) } } func TestBillingAdminRoutesRequireSuperAdminAndValidateWrites(t *testing.T) { service := &billingHTTPServiceStub{price: &billing.PriceRule{ID: "price-1", MarkupMultiplier: 1.2}} account := &billingAccountStoreStub{} orgAdmin := billingTestHandler(t, identity.Session{AuthMode: identity.AuthModeAdmin, User: identity.User{ID: "admin", ClientID: "platform", OrganizationID: "org", Role: "organization_admin"}}, service, account) if got := serveJSON(t, orgAdmin, http.MethodGet, "/api/admin/billing", nil); got.Code != 403 { t.Fatalf("organization admin status=%d", got.Code) } super := billingTestHandler(t, identity.Session{AuthMode: identity.AuthModeAdmin, User: identity.User{ID: "root", ClientID: "platform", Role: "super_admin"}}, service, account) if got := serveJSON(t, super, http.MethodPatch, "/api/admin/billing/account", map[string]any{"accountName": " Acme ", "bankName": " Bank ", "accountNumber": " 123 ", "contact": " Ops "}); got.Code != 200 || account.saved.AccountName != "Acme" { t.Fatalf("account status=%d saved=%+v body=%s", got.Code, account.saved, got.Body.String()) } if got := serveJSON(t, super, http.MethodPost, "/api/admin/billing/adjustments", map[string]any{"organizationId": "org", "amountYuan": 1.235, "direction": "debit", "note": " correction "}); got.Code != 200 || service.adjustment.AmountFen != 124 || service.adjustment.DeltaFen != -124 || service.adjustment.OperatorID != "root" { t.Fatalf("adjustment status=%d got=%+v body=%s", got.Code, service.adjustment, got.Body.String()) } if got := serveJSON(t, super, http.MethodPatch, "/api/admin/billing/prices/price-1", map[string]any{"standardUnitPriceFen": 1, "markupMultiplier": 2}); got.Code != 400 { t.Fatalf("whitelist status=%d", got.Code) } if got := serveJSON(t, super, http.MethodPatch, "/api/admin/billing/prices/price-1", map[string]any{"markupMultiplier": .5}); got.Code != 400 { t.Fatalf("multiplier status=%d", got.Code) } service.price.Dimensions = []billing.ParameterDimension{{Key: "quality", Tiers: []billing.ParameterTier{{Value: "high", Enabled: true}}}} if got := serveJSON(t, super, http.MethodPatch, "/api/admin/billing/prices/price-1", map[string]any{"markupMultiplier": 2}); got.Code != 400 { t.Fatalf("tier-required status=%d", got.Code) } if got := serveJSON(t, super, http.MethodPatch, "/api/admin/billing/prices/price-1", map[string]any{"markupMultiplier": 2, "dimensionKey": "quality", "tierValue": "missing"}); got.Code != 400 { t.Fatalf("unknown tier status=%d", got.Code) } if got := serveJSON(t, super, http.MethodPatch, "/api/admin/billing/prices/price-1", map[string]any{"markupMultiplier": 2.123456, "dimensionKey": "quality", "tierValue": "high"}); got.Code != 200 || service.pricePatch.MarkupMultiplier != 2.1235 { t.Fatalf("patch status=%d patch=%+v body=%s", got.Code, service.pricePatch, got.Body.String()) } } func TestBillingHandlerRejectsWrongMethods(t *testing.T) { h := billingTestHandler(t, identity.Session{AuthMode: identity.AuthModeAdmin, User: identity.User{ID: "root", ClientID: "platform", Role: "super_admin"}}, &billingHTTPServiceStub{}, nil) got := serveJSON(t, h, http.MethodDelete, "/api/billing", nil) if got.Code != 405 || got.Header().Get("Allow") != http.MethodGet { t.Fatalf("status=%d allow=%q", got.Code, got.Header().Get("Allow")) } } type billingHTTPServiceStub struct { overviewOrganization, overviewAccount string quote billing.QuoteCommand adjustment billing.AdjustmentCommand pricePatch billing.PricePatch price *billing.PriceRule err error } func (s *billingHTTPServiceStub) Overview(_ context.Context, organizationID, accountID string) (billing.Overview, error) { s.overviewOrganization, s.overviewAccount = organizationID, accountID return billing.Overview{Wallet: billing.Wallet{OrganizationID: organizationID, Currency: billing.CurrencyCNY}}, s.err } func (s *billingHTTPServiceStub) Quote(_ context.Context, command billing.QuoteCommand) (*billing.Quote, error) { s.quote = command return &billing.Quote{Currency: billing.CurrencyCNY}, s.err } func (s *billingHTTPServiceStub) AdminOverview(context.Context) (billing.AdminOverview, error) { return billing.AdminOverview{}, s.err } func (s *billingHTTPServiceStub) ListPrices(context.Context) ([]billing.PriceRule, error) { return nil, s.err } func (s *billingHTTPServiceStub) GetPrice(_ context.Context, _ string) (*billing.PriceRule, error) { return s.price, s.err } func (s *billingHTTPServiceStub) UpdatePrice(_ context.Context, _ string, patch billing.PricePatch) (*billing.PriceRule, error) { s.pricePatch = patch return s.price, s.err } func (s *billingHTTPServiceStub) Adjust(_ context.Context, command billing.AdjustmentCommand) (billing.AdjustmentResult, error) { s.adjustment = command return billing.AdjustmentResult{Wallet: billing.Wallet{OrganizationID: command.OrganizationID, Currency: billing.CurrencyCNY}}, s.err } type billingAccountStoreStub struct{ saved billing.AccountConfig } func (s *billingAccountStoreStub) Load(context.Context) (billing.AccountConfig, error) { return s.saved, nil } func (s *billingAccountStoreStub) Save(_ context.Context, value billing.AccountConfig) error { s.saved = value return nil } func billingTestHandler(t *testing.T, session identity.Session, service BillingHTTPService, accounts BillingAccountStore) http.Handler { t.Helper() authorizer, err := NewPlatformAuthorizer(AuthState{Required: true, Configured: true}, &fixedSessionResolver{session: session}) if err != nil { t.Fatal(err) } return NewBillingHandler(authorizer, service, accounts) } type fixedSessionResolver struct{ session identity.Session } func (r *fixedSessionResolver) Resolve(context.Context, string) (identity.Session, error) { return r.session, nil } func serveJSON(t *testing.T, h http.Handler, method, path string, body any) *httptest.ResponseRecorder { t.Helper() var raw []byte if body != nil { raw, _ = json.Marshal(body) } req := httptest.NewRequest(method, path, bytes.NewReader(raw)) req.AddCookie(&http.Cookie{Name: identity.SessionCookieName, Value: "signed"}) response := httptest.NewRecorder() h.ServeHTTP(response, req) return response }