accounts/api/accounting_test.go
2026-04-09 14:04:59 +08:00

417 lines
13 KiB
Go

package api
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"os"
"testing"
"time"
"github.com/gin-gonic/gin"
"account/internal/agentserver"
"account/internal/store"
)
func TestAgentUsersUseAccountUUIDAsStatsEmail(t *testing.T) {
gin.SetMode(gin.TestMode)
st := store.NewMemoryStore()
ctx := context.Background()
if err := st.CreateUser(ctx, &store.User{
Name: "Stats User",
Email: "stats@example.com",
PasswordHash: "hashed",
EmailVerified: true,
Role: store.RoleUser,
Level: store.LevelUser,
Active: true,
ProxyUUID: "proxy-user-id",
}); err != nil {
t.Fatalf("create user: %v", err)
}
user, err := st.GetUserByEmail(ctx, "stats@example.com")
if err != nil {
t.Fatalf("get user: %v", err)
}
registry, err := agentserver.NewRegistry(agentserver.Config{
Credentials: []agentserver.Credential{{
ID: "*",
Name: "test-agent",
Token: "agent-token",
}},
})
if err != nil {
t.Fatalf("new registry: %v", err)
}
router := gin.New()
RegisterRoutes(router, WithStore(st), WithAgentRegistry(registry), WithEmailVerification(false))
req := httptest.NewRequest(http.MethodGet, "/api/agent-server/v1/users", nil)
req.Header.Set("Authorization", "Bearer agent-token")
req.Header.Set("X-Agent-ID", "hk-xhttp.svc.plus")
rec := httptest.NewRecorder()
router.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("unexpected status: %d body=%s", rec.Code, rec.Body.String())
}
var payload struct {
Clients []struct {
ID string `json:"id"`
Email string `json:"email"`
} `json:"clients"`
}
if err := json.Unmarshal(rec.Body.Bytes(), &payload); err != nil {
t.Fatalf("decode payload: %v", err)
}
for _, client := range payload.Clients {
if client.Email == user.ID {
return
}
}
t.Fatalf("expected stats email %q in payload, got %#v", user.ID, payload.Clients)
}
func TestAccountUsageAndPolicyEndpoints(t *testing.T) {
gin.SetMode(gin.TestMode)
st := store.NewMemoryStore()
ctx := context.Background()
if err := st.CreateUser(ctx, &store.User{
Name: "Billing User",
Email: "billing@example.com",
PasswordHash: "hashed",
EmailVerified: true,
Role: store.RoleUser,
Level: store.LevelUser,
Active: true,
}); err != nil {
t.Fatalf("create user: %v", err)
}
user, err := st.GetUserByEmail(ctx, "billing@example.com")
if err != nil {
t.Fatalf("get user: %v", err)
}
sessionToken := "usage-session-token"
if err := st.CreateSession(ctx, sessionToken, user.ID, time.Now().UTC().Add(time.Hour)); err != nil {
t.Fatalf("create session: %v", err)
}
bucketStart := time.Date(2026, 4, 1, 10, 30, 0, 0, time.UTC)
if err := st.UpsertTrafficMinuteBucket(ctx, &store.TrafficMinuteBucket{
BucketStart: bucketStart,
NodeID: "node-a",
AccountUUID: user.ID,
Region: "hk",
LineCode: "premium",
UplinkBytes: 128,
DownlinkBytes: 256,
TotalBytes: 384,
Multiplier: 1.5,
RatingStatus: store.RatingStatusRated,
}); err != nil {
t.Fatalf("upsert bucket: %v", err)
}
if err := st.UpsertAccountQuotaState(ctx, &store.AccountQuotaState{
AccountUUID: user.ID,
RemainingIncludedQuota: 2048,
CurrentBalance: 87.5,
Arrears: false,
ThrottleState: "normal",
SuspendState: "active",
EffectiveAt: time.Now().UTC(),
}); err != nil {
t.Fatalf("upsert quota state: %v", err)
}
if err := st.UpsertAccountBillingProfile(ctx, &store.AccountBillingProfile{
AccountUUID: user.ID,
PackageName: "starter",
IncludedQuotaBytes: 4096,
BasePricePerByte: 0.125,
RegionMultiplier: 1.2,
LineMultiplier: 1.5,
PeakMultiplier: 1.0,
OffPeakMultiplier: 1.0,
PricingRuleVersion: "pricing-v1",
}); err != nil {
t.Fatalf("upsert billing profile: %v", err)
}
if err := st.InsertBillingLedgerEntry(ctx, &store.BillingLedgerEntry{
ID: "ledger-1",
AccountUUID: user.ID,
BucketStart: bucketStart,
BucketEnd: bucketStart.Add(time.Minute),
EntryType: "traffic_charge",
RatedBytes: 384,
AmountDelta: -1.25,
BalanceAfter: 87.5,
PricingRuleVersion: "pricing-v1",
}); err != nil {
t.Fatalf("insert billing ledger: %v", err)
}
if err := st.UpsertAccountPolicySnapshot(ctx, &store.AccountPolicySnapshot{
AccountUUID: user.ID,
PolicyVersion: "policy-v1",
AuthState: "active",
RateProfile: "standard",
ConnProfile: "standard",
EligibleNodeGroups: []string{"hk-premium"},
PreferredStrategy: "ewma",
DegradeMode: "fallback",
ExpiresAt: time.Now().UTC().Add(5 * time.Minute),
}); err != nil {
t.Fatalf("upsert policy: %v", err)
}
router := gin.New()
RegisterRoutes(router, WithStore(st), WithEmailVerification(false))
req := httptest.NewRequest(http.MethodGet, "/api/account/usage/summary", nil)
req.Header.Set("Authorization", "Bearer "+sessionToken)
rec := httptest.NewRecorder()
router.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("usage summary status: %d body=%s", rec.Code, rec.Body.String())
}
var usagePayload struct {
AccountUUID string `json:"accountUuid"`
TotalBytes int64 `json:"totalBytes"`
SourceOfTruth string `json:"sourceOfTruth"`
BillingProfile struct {
PackageName string `json:"packageName"`
BasePricePerByte float64 `json:"basePricePerByte"`
RegionMultiplier float64 `json:"regionMultiplier"`
LineMultiplier float64 `json:"lineMultiplier"`
PricingRuleVersion string `json:"pricingRuleVersion"`
} `json:"billingProfile"`
}
if err := json.Unmarshal(rec.Body.Bytes(), &usagePayload); err != nil {
t.Fatalf("decode usage payload: %v", err)
}
if usagePayload.AccountUUID != user.ID {
t.Fatalf("expected account uuid %q, got %q", user.ID, usagePayload.AccountUUID)
}
if usagePayload.TotalBytes != 384 {
t.Fatalf("expected total bytes 384, got %d", usagePayload.TotalBytes)
}
if usagePayload.SourceOfTruth != "postgresql" {
t.Fatalf("expected source of truth postgresql, got %q", usagePayload.SourceOfTruth)
}
if usagePayload.BillingProfile.PackageName != "starter" {
t.Fatalf("expected billing profile package starter, got %q", usagePayload.BillingProfile.PackageName)
}
bucketsReq := httptest.NewRequest(http.MethodGet, "/api/account/usage/buckets", nil)
bucketsReq.Header.Set("Authorization", "Bearer "+sessionToken)
bucketsRec := httptest.NewRecorder()
router.ServeHTTP(bucketsRec, bucketsReq)
if bucketsRec.Code != http.StatusOK {
t.Fatalf("usage buckets status: %d body=%s", bucketsRec.Code, bucketsRec.Body.String())
}
var bucketsPayload struct {
AccountUUID string `json:"accountUuid"`
SourceOfTruth string `json:"sourceOfTruth"`
Buckets []struct {
TotalBytes int64 `json:"totalBytes"`
NodeID string `json:"nodeId"`
BucketStart time.Time `json:"bucketStart"`
} `json:"buckets"`
}
if err := json.Unmarshal(bucketsRec.Body.Bytes(), &bucketsPayload); err != nil {
t.Fatalf("decode usage buckets payload: %v", err)
}
if bucketsPayload.AccountUUID != user.ID {
t.Fatalf("expected usage buckets account uuid %q, got %q", user.ID, bucketsPayload.AccountUUID)
}
if bucketsPayload.SourceOfTruth != "postgresql" {
t.Fatalf("expected usage buckets source of truth postgresql, got %q", bucketsPayload.SourceOfTruth)
}
if len(bucketsPayload.Buckets) != 1 {
t.Fatalf("expected 1 usage bucket, got %d", len(bucketsPayload.Buckets))
}
if bucketsPayload.Buckets[0].TotalBytes != 384 {
t.Fatalf("expected usage bucket total bytes 384, got %d", bucketsPayload.Buckets[0].TotalBytes)
}
if bucketsPayload.Buckets[0].NodeID != "node-a" {
t.Fatalf("expected usage bucket node node-a, got %q", bucketsPayload.Buckets[0].NodeID)
}
billingReq := httptest.NewRequest(http.MethodGet, "/api/account/billing/summary", nil)
billingReq.Header.Set("Authorization", "Bearer "+sessionToken)
billingRec := httptest.NewRecorder()
router.ServeHTTP(billingRec, billingReq)
if billingRec.Code != http.StatusOK {
t.Fatalf("billing summary status: %d body=%s", billingRec.Code, billingRec.Body.String())
}
var billingPayload struct {
AccountUUID string `json:"accountUuid"`
SourceOfTruth string `json:"sourceOfTruth"`
QuotaState struct {
CurrentBalance float64 `json:"currentBalance"`
} `json:"quotaState"`
BillingProfile struct {
PackageName string `json:"packageName"`
IncludedQuotaBytes int64 `json:"includedQuotaBytes"`
BasePricePerByte float64 `json:"basePricePerByte"`
} `json:"billingProfile"`
Ledger []struct {
ID string `json:"id"`
EntryType string `json:"entryType"`
RatedBytes int64 `json:"ratedBytes"`
AmountDelta float64 `json:"amountDelta"`
} `json:"ledger"`
}
if err := json.Unmarshal(billingRec.Body.Bytes(), &billingPayload); err != nil {
t.Fatalf("decode billing payload: %v", err)
}
if billingPayload.AccountUUID != user.ID {
t.Fatalf("expected billing account uuid %q, got %q", user.ID, billingPayload.AccountUUID)
}
if billingPayload.SourceOfTruth != "postgresql" {
t.Fatalf("expected billing source of truth postgresql, got %q", billingPayload.SourceOfTruth)
}
if billingPayload.BillingProfile.IncludedQuotaBytes != 4096 {
t.Fatalf("expected billing profile included quota 4096, got %d", billingPayload.BillingProfile.IncludedQuotaBytes)
}
if billingPayload.QuotaState.CurrentBalance != 87.5 {
t.Fatalf("expected billing current balance 87.5, got %v", billingPayload.QuotaState.CurrentBalance)
}
if len(billingPayload.Ledger) != 1 {
t.Fatalf("expected 1 billing ledger entry, got %d", len(billingPayload.Ledger))
}
if billingPayload.Ledger[0].ID != "ledger-1" {
t.Fatalf("expected billing ledger id ledger-1, got %q", billingPayload.Ledger[0].ID)
}
if billingPayload.Ledger[0].EntryType != "traffic_charge" {
t.Fatalf("expected billing entry type traffic_charge, got %q", billingPayload.Ledger[0].EntryType)
}
if billingPayload.Ledger[0].RatedBytes != 384 {
t.Fatalf("expected billing rated bytes 384, got %d", billingPayload.Ledger[0].RatedBytes)
}
policyReq := httptest.NewRequest(http.MethodGet, "/api/account/policy", nil)
policyReq.Header.Set("Authorization", "Bearer "+sessionToken)
policyRec := httptest.NewRecorder()
router.ServeHTTP(policyRec, policyReq)
if policyRec.Code != http.StatusOK {
t.Fatalf("policy status: %d body=%s", policyRec.Code, policyRec.Body.String())
}
var policyPayload struct {
AccountUUID string `json:"accountUuid"`
PreferredStrategy string `json:"preferredStrategy"`
EligibleNodeGroups []string `json:"eligibleNodeGroups"`
}
if err := json.Unmarshal(policyRec.Body.Bytes(), &policyPayload); err != nil {
t.Fatalf("decode policy payload: %v", err)
}
if policyPayload.AccountUUID != user.ID {
t.Fatalf("expected policy account uuid %q, got %q", user.ID, policyPayload.AccountUUID)
}
if policyPayload.PreferredStrategy != "ewma" {
t.Fatalf("expected preferred strategy ewma, got %q", policyPayload.PreferredStrategy)
}
if len(policyPayload.EligibleNodeGroups) != 1 || policyPayload.EligibleNodeGroups[0] != "hk-premium" {
t.Fatalf("unexpected eligible node groups %#v", policyPayload.EligibleNodeGroups)
}
}
func TestInternalNetworkIdentitiesEndpoint(t *testing.T) {
gin.SetMode(gin.TestMode)
t.Setenv("INTERNAL_SERVICE_TOKEN", "test-internal-token")
st := store.NewMemoryStore()
ctx := context.Background()
if err := st.CreateUser(ctx, &store.User{
Name: "Exporter User",
Email: "exporter@example.com",
PasswordHash: "hashed",
EmailVerified: true,
Role: store.RoleUser,
Level: store.LevelUser,
Active: true,
ProxyUUID: "proxy-exporter-id",
}); err != nil {
t.Fatalf("create user: %v", err)
}
if err := st.CreateUser(ctx, &store.User{
Name: "Inactive User",
Email: "inactive@example.com",
PasswordHash: "hashed",
EmailVerified: true,
Role: store.RoleUser,
Level: store.LevelUser,
Active: false,
ProxyUUID: "proxy-inactive-id",
}); err != nil {
t.Fatalf("create inactive user: %v", err)
}
router := gin.New()
RegisterRoutes(router, WithStore(st), WithEmailVerification(false))
req := httptest.NewRequest(http.MethodGet, "/api/internal/network/identities", nil)
req.Header.Set("X-Service-Token", os.Getenv("INTERNAL_SERVICE_TOKEN"))
rec := httptest.NewRecorder()
router.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("internal identities status: %d body=%s", rec.Code, rec.Body.String())
}
var payload struct {
Identities []struct {
UUID string `json:"uuid"`
Email string `json:"email"`
AccountUUID string `json:"accountUuid"`
} `json:"identities"`
}
if err := json.Unmarshal(rec.Body.Bytes(), &payload); err != nil {
t.Fatalf("decode internal identities payload: %v", err)
}
foundExporter := false
for _, identity := range payload.Identities {
if identity.Email != "exporter@example.com" {
continue
}
foundExporter = true
if identity.UUID == "" {
t.Fatalf("expected exporter UUID to be populated")
}
if identity.AccountUUID == "" {
t.Fatalf("expected account uuid to be populated")
}
}
if !foundExporter {
t.Fatalf("expected exporter identity in payload, got %#v", payload.Identities)
}
}