Add unit tests for player configurations and referral system

- Implement tests for player configuration creation, update, and deletion, ensuring proper handling of free and paid user scenarios.
- Add tests for referral registration, including valid and invalid referrer cases.
- Create tests for referral reward flow, verifying correct reward distribution and eligibility.
- Establish a test database setup with necessary schema for user, plan, payment, and notification models.
- Introduce helper functions for seeding test data and loading entities from the database.
This commit is contained in:
2026-03-26 02:20:05 +07:00
parent bb7f7b0bb3
commit 4de6baee61
25 changed files with 152 additions and 245 deletions

View File

@@ -0,0 +1,78 @@
package service
import (
"testing"
"github.com/google/uuid"
"google.golang.org/grpc"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/metadata"
appv1 "stream.api/internal/api/proto/app/v1"
"stream.api/internal/database/model"
)
func TestCreateAdminPayment(t *testing.T) {
t.Run("happy path admin", func(t *testing.T) {
db := newTestDB(t)
services := newTestAppServices(t, db)
admin := seedTestUser(t, db, model.User{ID: uuid.NewString(), Email: "admin@example.com", Role: ptrString("ADMIN")})
user := seedTestUser(t, db, model.User{ID: uuid.NewString(), Email: "user@example.com", Role: ptrString("USER")})
plan := seedTestPlan(t, db, model.Plan{ID: uuid.NewString(), Name: "Team", Price: 30, Cycle: "monthly", StorageLimit: 200, UploadLimit: 20, QualityLimit: "1440p", IsActive: ptrBool(true)})
seedWalletTransaction(t, db, model.WalletTransaction{ID: uuid.NewString(), UserID: user.ID, Type: walletTransactionTypeTopup, Amount: 5, Currency: ptrString("USD")})
conn, cleanup := newTestGRPCServer(t, services)
defer cleanup()
client := newAdminClient(conn)
resp, err := client.CreateAdminPayment(testActorOutgoingContext(admin.ID, "ADMIN"), &appv1.CreateAdminPaymentRequest{
UserId: user.ID,
PlanId: plan.ID,
TermMonths: 1,
PaymentMethod: paymentMethodTopup,
TopupAmount: ptrFloat64(25),
})
if err != nil {
t.Fatalf("CreateAdminPayment() error = %v", err)
}
if resp.Payment == nil || resp.Subscription == nil {
t.Fatalf("CreateAdminPayment() response incomplete: %#v", resp)
}
if resp.Payment.UserId != user.ID {
t.Fatalf("payment user_id = %q, want %q", resp.Payment.UserId, user.ID)
}
if resp.InvoiceId != buildInvoiceID(resp.Payment.Id) {
t.Fatalf("invoice id = %q, want %q", resp.InvoiceId, buildInvoiceID(resp.Payment.Id))
}
if resp.Payment.GetWalletAmount() != 30 {
t.Fatalf("payment wallet_amount = %v, want 30", resp.Payment.GetWalletAmount())
}
if resp.Payment.GetTopupAmount() != 25 {
t.Fatalf("payment topup_amount = %v, want 25", resp.Payment.GetTopupAmount())
}
})
t.Run("wallet thiếu tiền giữ trailer", func(t *testing.T) {
db := newTestDB(t)
services := newTestAppServices(t, db)
admin := seedTestUser(t, db, model.User{ID: uuid.NewString(), Email: "admin@example.com", Role: ptrString("ADMIN")})
user := seedTestUser(t, db, model.User{ID: uuid.NewString(), Email: "user@example.com", Role: ptrString("USER")})
plan := seedTestPlan(t, db, model.Plan{ID: uuid.NewString(), Name: "Team", Price: 30, Cycle: "monthly", StorageLimit: 200, UploadLimit: 20, QualityLimit: "1440p", IsActive: ptrBool(true)})
conn, cleanup := newTestGRPCServer(t, services)
defer cleanup()
client := newAdminClient(conn)
var trailer metadata.MD
_, err := client.CreateAdminPayment(testActorOutgoingContext(admin.ID, "ADMIN"), &appv1.CreateAdminPaymentRequest{
UserId: user.ID,
PlanId: plan.ID,
TermMonths: 1,
PaymentMethod: paymentMethodWallet,
}, grpc.Trailer(&trailer))
assertGRPCCode(t, err, codes.InvalidArgument)
if body := firstTestMetadataValue(trailer, "x-error-body"); body == "" {
t.Fatal("expected x-error-body trailer")
}
})
}

View File

@@ -0,0 +1,195 @@
package service
import (
"testing"
"time"
"github.com/google/uuid"
"google.golang.org/grpc/codes"
"gorm.io/gorm"
appv1 "stream.api/internal/api/proto/app/v1"
"stream.api/internal/database/model"
"stream.api/internal/video"
runtimeservices "stream.api/internal/video/runtime/services"
)
func TestListAdminJobsCursorPagination(t *testing.T) {
db := newTestDB(t)
ensureTestJobsTable(t, db)
services := newTestAppServices(t, db)
services.videoService = video.NewService(db, runtimeservices.NewJobService(nil, nil))
admin := seedTestUser(t, db, model.User{ID: uuid.NewString(), Email: "admin@example.com", Role: ptrString("ADMIN")})
baseTime := time.Date(2026, 3, 22, 10, 0, 0, 0, time.UTC)
seedTestJob(t, db, model.Job{ID: "job-300", CreatedAt: ptrTime(baseTime.Add(time.Minute)), UpdatedAt: ptrTime(baseTime.Add(time.Minute))})
seedTestJob(t, db, model.Job{ID: "job-200", CreatedAt: ptrTime(baseTime), UpdatedAt: ptrTime(baseTime)})
seedTestJob(t, db, model.Job{ID: "job-100", CreatedAt: ptrTime(baseTime), UpdatedAt: ptrTime(baseTime)})
conn, cleanup := newTestGRPCServer(t, services)
defer cleanup()
client := newAdminClient(conn)
resp, err := client.ListAdminJobs(testActorOutgoingContext(admin.ID, "ADMIN"), &appv1.ListAdminJobsRequest{PageSize: 2})
if err != nil {
t.Fatalf("ListAdminJobs(first page) error = %v", err)
}
assertAdminJobIDs(t, resp.GetJobs(), []string{"job-300", "job-200"})
if !resp.GetHasMore() {
t.Fatal("ListAdminJobs(first page) has_more = false, want true")
}
if resp.GetNextCursor() == "" {
t.Fatal("ListAdminJobs(first page) next_cursor is empty")
}
if resp.GetPageSize() != 2 {
t.Fatalf("ListAdminJobs(first page) page_size = %d, want 2", resp.GetPageSize())
}
nextCursor := resp.GetNextCursor()
resp, err = client.ListAdminJobs(testActorOutgoingContext(admin.ID, "ADMIN"), &appv1.ListAdminJobsRequest{
Cursor: ptrString(nextCursor),
PageSize: 2,
})
if err != nil {
t.Fatalf("ListAdminJobs(second page) error = %v", err)
}
assertAdminJobIDs(t, resp.GetJobs(), []string{"job-100"})
if resp.GetHasMore() {
t.Fatal("ListAdminJobs(second page) has_more = true, want false")
}
if resp.GetNextCursor() != "" {
t.Fatalf("ListAdminJobs(second page) next_cursor = %q, want empty", resp.GetNextCursor())
}
}
func TestListAdminJobsInvalidCursor(t *testing.T) {
db := newTestDB(t)
ensureTestJobsTable(t, db)
services := newTestAppServices(t, db)
services.videoService = video.NewService(db, runtimeservices.NewJobService(nil, nil))
admin := seedTestUser(t, db, model.User{ID: uuid.NewString(), Email: "admin@example.com", Role: ptrString("ADMIN")})
conn, cleanup := newTestGRPCServer(t, services)
defer cleanup()
client := newAdminClient(conn)
_, err := client.ListAdminJobs(testActorOutgoingContext(admin.ID, "ADMIN"), &appv1.ListAdminJobsRequest{
Cursor: ptrString("not-a-valid-cursor"),
PageSize: 1,
})
assertGRPCCode(t, err, codes.InvalidArgument)
}
func TestListAdminJobsCursorRejectsAgentMismatch(t *testing.T) {
db := newTestDB(t)
ensureTestJobsTable(t, db)
services := newTestAppServices(t, db)
services.videoService = video.NewService(db, runtimeservices.NewJobService(nil, nil))
admin := seedTestUser(t, db, model.User{ID: uuid.NewString(), Email: "admin@example.com", Role: ptrString("ADMIN")})
baseTime := time.Date(2026, 3, 22, 11, 0, 0, 0, time.UTC)
agentOne := int64(101)
agentTwo := int64(202)
seedTestJob(t, db, model.Job{ID: "job-b", AgentID: &agentOne, CreatedAt: ptrTime(baseTime.Add(time.Minute)), UpdatedAt: ptrTime(baseTime.Add(time.Minute))})
seedTestJob(t, db, model.Job{ID: "job-a", AgentID: &agentOne, CreatedAt: ptrTime(baseTime), UpdatedAt: ptrTime(baseTime)})
seedTestJob(t, db, model.Job{ID: "job-x", AgentID: &agentTwo, CreatedAt: ptrTime(baseTime.Add(2 * time.Minute)), UpdatedAt: ptrTime(baseTime.Add(2 * time.Minute))})
conn, cleanup := newTestGRPCServer(t, services)
defer cleanup()
client := newAdminClient(conn)
resp, err := client.ListAdminJobs(testActorOutgoingContext(admin.ID, "ADMIN"), &appv1.ListAdminJobsRequest{
AgentId: ptrString("101"),
PageSize: 1,
})
if err != nil {
t.Fatalf("ListAdminJobs(filtered first page) error = %v", err)
}
if resp.GetNextCursor() == "" {
t.Fatal("ListAdminJobs(filtered first page) next_cursor is empty")
}
_, err = client.ListAdminJobs(testActorOutgoingContext(admin.ID, "ADMIN"), &appv1.ListAdminJobsRequest{
AgentId: ptrString("202"),
Cursor: ptrString(resp.GetNextCursor()),
PageSize: 1,
})
assertGRPCCode(t, err, codes.InvalidArgument)
}
func ensureTestJobsTable(t *testing.T, db *gorm.DB) {
t.Helper()
stmt := `CREATE TABLE jobs (
id TEXT PRIMARY KEY,
status TEXT,
priority INTEGER,
input_url TEXT,
output_url TEXT,
total_duration INTEGER,
current_time INTEGER,
progress REAL,
agent_id INTEGER,
logs TEXT,
config TEXT,
cancelled BOOLEAN NOT NULL DEFAULT 0,
retry_count INTEGER NOT NULL DEFAULT 0,
max_retries INTEGER NOT NULL DEFAULT 3,
created_at DATETIME NOT NULL,
updated_at DATETIME,
version INTEGER NOT NULL DEFAULT 1
)`
if err := db.Exec(stmt).Error; err != nil {
t.Fatalf("create jobs table: %v", err)
}
}
func seedTestJob(t *testing.T, db *gorm.DB, job model.Job) model.Job {
t.Helper()
if job.Status == nil {
job.Status = ptrString("pending")
}
if job.Priority == nil {
job.Priority = ptrInt64(0)
}
if job.Config == nil {
job.Config = ptrString(`{"name":"` + job.ID + `"}`)
}
if job.Cancelled == nil {
job.Cancelled = ptrBool(false)
}
if job.RetryCount == nil {
job.RetryCount = ptrInt64(0)
}
if job.MaxRetries == nil {
job.MaxRetries = ptrInt64(3)
}
if job.CreatedAt == nil {
job.CreatedAt = ptrTime(time.Now().UTC())
}
if job.UpdatedAt == nil {
job.UpdatedAt = ptrTime(job.CreatedAt.UTC())
}
if job.Version == nil {
job.Version = ptrInt64(1)
}
if err := db.Create(&job).Error; err != nil {
t.Fatalf("create job %s: %v", job.ID, err)
}
return job
}
func assertAdminJobIDs(t *testing.T, jobs []*appv1.AdminJob, want []string) {
t.Helper()
if len(jobs) != len(want) {
t.Fatalf("job count = %d, want %d", len(jobs), len(want))
}
for i, job := range jobs {
if job.GetId() != want[i] {
t.Fatalf("job[%d].id = %q, want %q", i, job.GetId(), want[i])
}
}
}
func ptrTime(v time.Time) *time.Time { return &v }

View File

@@ -0,0 +1,238 @@
package service
import (
"context"
"encoding/json"
"strings"
"testing"
"github.com/google/uuid"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/status"
"stream.api/internal/database/model"
)
func TestValidatePaymentFunding(t *testing.T) {
baseInput := paymentExecutionInput{PaymentMethod: paymentMethodWallet}
tests := []struct {
name string
input paymentExecutionInput
totalAmount float64
walletBalance float64
wantTopup float64
wantCode codes.Code
wantMessage string
}{
{
name: "wallet đủ tiền",
input: baseInput,
totalAmount: 30,
walletBalance: 30,
wantTopup: 0,
},
{
name: "wallet thiếu tiền",
input: baseInput,
totalAmount: 50,
walletBalance: 20,
wantCode: codes.InvalidArgument,
wantMessage: "Insufficient wallet balance",
},
{
name: "topup thiếu amount",
input: paymentExecutionInput{PaymentMethod: paymentMethodTopup},
totalAmount: 50,
walletBalance: 20,
wantCode: codes.InvalidArgument,
wantMessage: "Top-up amount is required when payment method is topup",
},
{
name: "topup amount <= 0",
input: paymentExecutionInput{PaymentMethod: paymentMethodTopup, TopupAmount: ptrFloat64(0)},
totalAmount: 50,
walletBalance: 20,
wantCode: codes.InvalidArgument,
wantMessage: "Top-up amount must be greater than 0",
},
{
name: "topup amount nhỏ hơn shortfall",
input: paymentExecutionInput{PaymentMethod: paymentMethodTopup, TopupAmount: ptrFloat64(20)},
totalAmount: 50,
walletBalance: 20,
wantCode: codes.InvalidArgument,
wantMessage: "Top-up amount must be greater than or equal to the required shortfall",
},
{
name: "topup hợp lệ",
input: paymentExecutionInput{PaymentMethod: paymentMethodTopup, TopupAmount: ptrFloat64(30)},
totalAmount: 50,
walletBalance: 20,
wantTopup: 30,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got, err := validatePaymentFunding(context.Background(), tt.input, tt.totalAmount, tt.walletBalance)
if tt.wantCode == codes.OK {
if err != nil {
t.Fatalf("validatePaymentFunding() error = %v", err)
}
if got != tt.wantTopup {
t.Fatalf("validatePaymentFunding() topup = %v, want %v", got, tt.wantTopup)
}
return
}
if err == nil {
t.Fatalf("validatePaymentFunding() error = nil, want %v", tt.wantCode)
}
if status.Code(err) != tt.wantCode {
t.Fatalf("validatePaymentFunding() code = %v, want %v", status.Code(err), tt.wantCode)
}
if got := err.Error(); !strings.Contains(got, tt.wantMessage) {
t.Fatalf("validatePaymentFunding() message = %q, want contains %q", got, tt.wantMessage)
}
})
}
}
func TestExecutePaymentFlow_CreatesExpectedRecords(t *testing.T) {
db := newTestDB(t)
services := newTestAppServices(t, db)
user := seedTestUser(t, db, model.User{
ID: uuid.NewString(),
Email: "payer@example.com",
Role: ptrString("USER"),
StorageUsed: 0,
})
plan := seedTestPlan(t, db, model.Plan{
ID: uuid.NewString(),
Name: "Pro",
Price: 10,
Cycle: "monthly",
StorageLimit: 100,
UploadLimit: 10,
DurationLimit: 0,
QualityLimit: "1080p",
Features: []string{"priority"},
IsActive: ptrBool(true),
})
seedWalletTransaction(t, db, model.WalletTransaction{
ID: uuid.NewString(),
UserID: user.ID,
Type: walletTransactionTypeTopup,
Amount: 5,
Currency: ptrString("USD"),
Note: ptrString("Initial funds"),
})
result, err := services.executePaymentFlow(context.Background(), paymentExecutionInput{
UserID: user.ID,
Plan: &plan,
TermMonths: 3,
PaymentMethod: paymentMethodTopup,
TopupAmount: ptrFloat64(25),
})
if err != nil {
t.Fatalf("executePaymentFlow() error = %v", err)
}
if result == nil || result.Payment == nil || result.Subscription == nil {
t.Fatalf("executePaymentFlow() returned incomplete result: %#v", result)
}
if result.InvoiceID != buildInvoiceID(result.Payment.ID) {
t.Fatalf("invoice id = %q, want %q", result.InvoiceID, buildInvoiceID(result.Payment.ID))
}
if result.WalletBalance != 0 {
t.Fatalf("wallet balance = %v, want 0", result.WalletBalance)
}
payment := mustLoadPayment(t, db, result.Payment.ID)
if payment.Amount != 30 {
t.Fatalf("payment amount = %v, want 30", payment.Amount)
}
if payment.PlanID == nil || *payment.PlanID != plan.ID {
t.Fatalf("payment plan_id = %v, want %s", payment.PlanID, plan.ID)
}
if normalizePaymentStatus(payment.Status) != "success" {
t.Fatalf("payment status = %q, want success", normalizePaymentStatus(payment.Status))
}
subscription := mustLoadSubscriptionByPayment(t, db, payment.ID)
if subscription.PaymentID != payment.ID {
t.Fatalf("subscription payment_id = %q, want %q", subscription.PaymentID, payment.ID)
}
if subscription.PlanID != plan.ID {
t.Fatalf("subscription plan_id = %q, want %q", subscription.PlanID, plan.ID)
}
if subscription.TermMonths != 3 {
t.Fatalf("subscription term_months = %d, want 3", subscription.TermMonths)
}
if subscription.PaymentMethod != paymentMethodTopup {
t.Fatalf("subscription payment_method = %q, want %q", subscription.PaymentMethod, paymentMethodTopup)
}
if subscription.WalletAmount != 30 {
t.Fatalf("subscription wallet_amount = %v, want 30", subscription.WalletAmount)
}
if subscription.TopupAmount != 25 {
t.Fatalf("subscription topup_amount = %v, want 25", subscription.TopupAmount)
}
if !subscription.ExpiresAt.After(subscription.StartedAt) {
t.Fatalf("subscription expires_at = %v should be after started_at = %v", subscription.ExpiresAt, subscription.StartedAt)
}
walletTransactions := mustListWalletTransactionsByPayment(t, db, payment.ID)
if len(walletTransactions) != 2 {
t.Fatalf("wallet transaction count = %d, want 2", len(walletTransactions))
}
if walletTransactions[0].Amount != 25 || walletTransactions[0].Type != walletTransactionTypeTopup {
t.Fatalf("first wallet transaction = %#v, want topup amount 25", walletTransactions[0])
}
if walletTransactions[1].Amount != -30 || walletTransactions[1].Type != walletTransactionTypeSubscriptionDebit {
t.Fatalf("second wallet transaction = %#v, want debit amount -30", walletTransactions[1])
}
updatedUser := mustLoadUser(t, db, user.ID)
if updatedUser.PlanID == nil || *updatedUser.PlanID != plan.ID {
t.Fatalf("user plan_id = %v, want %s", updatedUser.PlanID, plan.ID)
}
notifications := mustListNotificationsByUser(t, db, user.ID)
if len(notifications) != 1 {
t.Fatalf("notification count = %d, want 1", len(notifications))
}
notification := notifications[0]
if notification.Type != "billing.subscription" {
t.Fatalf("notification type = %q, want %q", notification.Type, "billing.subscription")
}
if !strings.Contains(notification.Message, plan.Name) {
t.Fatalf("notification message = %q, want plan name", notification.Message)
}
if notification.Metadata == nil {
t.Fatal("notification metadata = nil")
}
var metadataPayload map[string]any
if err := json.Unmarshal([]byte(*notification.Metadata), &metadataPayload); err != nil {
t.Fatalf("unmarshal notification metadata: %v", err)
}
if metadataPayload["invoice_id"] != result.InvoiceID {
t.Fatalf("metadata invoice_id = %v, want %q", metadataPayload["invoice_id"], result.InvoiceID)
}
if metadataPayload["payment_id"] != payment.ID {
t.Fatalf("metadata payment_id = %v, want %q", metadataPayload["payment_id"], payment.ID)
}
if metadataPayload["payment_method"] != paymentMethodTopup {
t.Fatalf("metadata payment_method = %v, want %q", metadataPayload["payment_method"], paymentMethodTopup)
}
if metadataPayload["wallet_amount"] != 30.0 {
t.Fatalf("metadata wallet_amount = %v, want 30", metadataPayload["wallet_amount"])
}
if metadataPayload["topup_amount"] != 25.0 {
t.Fatalf("metadata topup_amount = %v, want 25", metadataPayload["topup_amount"])
}
}

View File

@@ -0,0 +1,181 @@
package service
import (
"context"
"strings"
"testing"
"github.com/google/uuid"
"google.golang.org/grpc"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/metadata"
"google.golang.org/grpc/status"
appv1 "stream.api/internal/api/proto/app/v1"
"stream.api/internal/database/model"
"stream.api/internal/middleware"
)
func TestCreatePayment(t *testing.T) {
t.Run("plan không tồn tại", func(t *testing.T) {
db := newTestDB(t)
services := newTestAppServices(t, db)
user := seedTestUser(t, db, model.User{ID: uuid.NewString(), Email: "user@example.com", Role: ptrString("USER")})
conn, cleanup := newTestGRPCServer(t, services)
defer cleanup()
client := newPaymentsClient(conn)
_, err := client.CreatePayment(testActorOutgoingContext(user.ID, "USER"), &appv1.CreatePaymentRequest{
PlanId: uuid.NewString(),
TermMonths: 1,
PaymentMethod: paymentMethodWallet,
})
assertGRPCCode(t, err, codes.NotFound)
})
t.Run("plan inactive", func(t *testing.T) {
db := newTestDB(t)
services := newTestAppServices(t, db)
user := seedTestUser(t, db, model.User{ID: uuid.NewString(), Email: "user@example.com", Role: ptrString("USER")})
plan := seedTestPlan(t, db, model.Plan{ID: uuid.NewString(), Name: "Starter", Price: 10, Cycle: "monthly", StorageLimit: 10, UploadLimit: 1, QualityLimit: "720p", IsActive: ptrBool(false)})
conn, cleanup := newTestGRPCServer(t, services)
defer cleanup()
client := newPaymentsClient(conn)
_, err := client.CreatePayment(testActorOutgoingContext(user.ID, "USER"), &appv1.CreatePaymentRequest{
PlanId: plan.ID,
TermMonths: 1,
PaymentMethod: paymentMethodWallet,
})
assertGRPCCode(t, err, codes.InvalidArgument)
})
t.Run("term không hợp lệ", func(t *testing.T) {
db := newTestDB(t)
services := newTestAppServices(t, db)
user := seedTestUser(t, db, model.User{ID: uuid.NewString(), Email: "user@example.com", Role: ptrString("USER")})
plan := seedTestPlan(t, db, model.Plan{ID: uuid.NewString(), Name: "Starter", Price: 10, Cycle: "monthly", StorageLimit: 10, UploadLimit: 1, QualityLimit: "720p", IsActive: ptrBool(true)})
conn, cleanup := newTestGRPCServer(t, services)
defer cleanup()
client := newPaymentsClient(conn)
_, err := client.CreatePayment(testActorOutgoingContext(user.ID, "USER"), &appv1.CreatePaymentRequest{
PlanId: plan.ID,
TermMonths: 2,
PaymentMethod: paymentMethodWallet,
})
assertGRPCCode(t, err, codes.InvalidArgument)
})
t.Run("payment method không hợp lệ", func(t *testing.T) {
db := newTestDB(t)
services := newTestAppServices(t, db)
user := seedTestUser(t, db, model.User{ID: uuid.NewString(), Email: "user@example.com", Role: ptrString("USER")})
plan := seedTestPlan(t, db, model.Plan{ID: uuid.NewString(), Name: "Starter", Price: 10, Cycle: "monthly", StorageLimit: 10, UploadLimit: 1, QualityLimit: "720p", IsActive: ptrBool(true)})
conn, cleanup := newTestGRPCServer(t, services)
defer cleanup()
client := newPaymentsClient(conn)
_, err := client.CreatePayment(testActorOutgoingContext(user.ID, "USER"), &appv1.CreatePaymentRequest{
PlanId: plan.ID,
TermMonths: 1,
PaymentMethod: "bank_transfer",
})
assertGRPCCode(t, err, codes.InvalidArgument)
})
t.Run("wallet thiếu tiền giữ trailer", func(t *testing.T) {
db := newTestDB(t)
services := newTestAppServices(t, db)
user := seedTestUser(t, db, model.User{ID: uuid.NewString(), Email: "user@example.com", Role: ptrString("USER")})
plan := seedTestPlan(t, db, model.Plan{ID: uuid.NewString(), Name: "Pro", Price: 50, Cycle: "monthly", StorageLimit: 100, UploadLimit: 10, QualityLimit: "1080p", IsActive: ptrBool(true)})
seedWalletTransaction(t, db, model.WalletTransaction{ID: uuid.NewString(), UserID: user.ID, Type: walletTransactionTypeTopup, Amount: 10, Currency: ptrString("USD")})
conn, cleanup := newTestGRPCServer(t, services)
defer cleanup()
client := newPaymentsClient(conn)
var trailer metadata.MD
_, err := client.CreatePayment(testActorOutgoingContext(user.ID, "USER"), &appv1.CreatePaymentRequest{
PlanId: plan.ID,
TermMonths: 1,
PaymentMethod: paymentMethodWallet,
}, grpc.Trailer(&trailer))
assertGRPCCode(t, err, codes.InvalidArgument)
body := firstTestMetadataValue(trailer, "x-error-body")
if body == "" {
t.Fatal("expected x-error-body trailer")
}
if !strings.Contains(body, "Insufficient wallet balance") {
t.Fatalf("x-error-body = %q, want insufficient wallet balance", body)
}
})
t.Run("happy path user", func(t *testing.T) {
db := newTestDB(t)
services := newTestAppServices(t, db)
user := seedTestUser(t, db, model.User{ID: uuid.NewString(), Email: "user@example.com", Role: ptrString("USER")})
plan := seedTestPlan(t, db, model.Plan{ID: uuid.NewString(), Name: "Pro", Price: 20, Cycle: "monthly", StorageLimit: 100, UploadLimit: 10, QualityLimit: "1080p", IsActive: ptrBool(true)})
seedWalletTransaction(t, db, model.WalletTransaction{ID: uuid.NewString(), UserID: user.ID, Type: walletTransactionTypeTopup, Amount: 5, Currency: ptrString("USD")})
conn, cleanup := newTestGRPCServer(t, services)
defer cleanup()
client := newPaymentsClient(conn)
resp, err := client.CreatePayment(testActorOutgoingContext(user.ID, "USER"), &appv1.CreatePaymentRequest{
PlanId: plan.ID,
TermMonths: 1,
PaymentMethod: paymentMethodTopup,
TopupAmount: ptrFloat64(15),
})
if err != nil {
t.Fatalf("CreatePayment() error = %v", err)
}
if resp.Payment == nil || resp.Subscription == nil {
t.Fatalf("CreatePayment() response incomplete: %#v", resp)
}
if resp.InvoiceId != buildInvoiceID(resp.Payment.Id) {
t.Fatalf("invoice id = %q, want %q", resp.InvoiceId, buildInvoiceID(resp.Payment.Id))
}
if resp.Subscription.PaymentMethod != paymentMethodTopup {
t.Fatalf("subscription payment method = %q, want %q", resp.Subscription.PaymentMethod, paymentMethodTopup)
}
if resp.Subscription.WalletAmount != 20 {
t.Fatalf("subscription wallet amount = %v, want 20", resp.Subscription.WalletAmount)
}
if resp.Subscription.TopupAmount != 15 {
t.Fatalf("subscription topup amount = %v, want 15", resp.Subscription.TopupAmount)
}
if resp.WalletBalance != 0 {
t.Fatalf("wallet balance = %v, want 0", resp.WalletBalance)
}
payment := mustLoadPayment(t, db, resp.Payment.Id)
if payment.Amount != 20 {
t.Fatalf("payment amount = %v, want 20", payment.Amount)
}
})
}
func testActorOutgoingContext(userID, role string) context.Context {
return metadata.NewOutgoingContext(context.Background(), metadata.Pairs(
middleware.ActorMarkerMetadataKey, testTrustedMarker,
middleware.ActorIDMetadataKey, userID,
middleware.ActorRoleMetadataKey, role,
middleware.ActorEmailMetadataKey, strings.ToLower(role)+"@example.com",
))
}
func assertGRPCCode(t *testing.T, err error, want codes.Code) {
t.Helper()
if err == nil {
t.Fatalf("grpc error = nil, want %v", want)
}
if got := status.Code(err); got != want {
t.Fatalf("grpc code = %v, want %v, err=%v", got, want, err)
}
}

View File

@@ -0,0 +1,266 @@
package service
import (
"context"
"strings"
"sync"
"testing"
"time"
"github.com/google/uuid"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/metadata"
"google.golang.org/grpc/status"
"gorm.io/gorm"
appv1 "stream.api/internal/api/proto/app/v1"
"stream.api/internal/database/model"
"stream.api/internal/middleware"
)
func TestPlayerConfigsPolicy(t *testing.T) {
t.Run("free user creates first config", func(t *testing.T) {
db := newTestDB(t)
services := newTestAppServices(t, db)
user := seedTestUser(t, db, model.User{ID: uuid.NewString(), Email: "free@example.com", Role: ptrString("USER")})
resp, err := services.CreatePlayerConfig(testActorIncomingContext(user.ID, "USER"), &appv1.CreatePlayerConfigRequest{
Name: "Free Config",
IsDefault: ptrBool(true),
})
if err != nil {
t.Fatalf("CreatePlayerConfig() error = %v", err)
}
if resp.Config == nil {
t.Fatal("CreatePlayerConfig() config is nil")
}
if !resp.Config.IsDefault {
t.Fatal("CreatePlayerConfig() config should be default")
}
items := mustListPlayerConfigsByUser(t, db, user.ID)
if len(items) != 1 {
t.Fatalf("player config count = %d, want 1", len(items))
}
})
t.Run("free user cannot create second config", func(t *testing.T) {
db := newTestDB(t)
services := newTestAppServices(t, db)
user := seedTestUser(t, db, model.User{ID: uuid.NewString(), Email: "free@example.com", Role: ptrString("USER")})
seedTestPlayerConfig(t, db, model.PlayerConfig{ID: uuid.NewString(), UserID: user.ID, Name: "Existing", IsActive: ptrBool(true)})
_, err := services.CreatePlayerConfig(testActorIncomingContext(user.ID, "USER"), &appv1.CreatePlayerConfigRequest{Name: "Second"})
assertGRPCCode(t, err, codes.FailedPrecondition)
if got := status.Convert(err).Message(); got != playerConfigFreePlanLimitMessage {
t.Fatalf("grpc message = %q, want %q", got, playerConfigFreePlanLimitMessage)
}
})
t.Run("free user can update and delete single config", func(t *testing.T) {
db := newTestDB(t)
services := newTestAppServices(t, db)
user := seedTestUser(t, db, model.User{ID: uuid.NewString(), Email: "free@example.com", Role: ptrString("USER")})
config := seedTestPlayerConfig(t, db, model.PlayerConfig{ID: uuid.NewString(), UserID: user.ID, Name: "Original", IsActive: ptrBool(true)})
updateResp, err := services.UpdatePlayerConfig(testActorIncomingContext(user.ID, "USER"), &appv1.UpdatePlayerConfigRequest{
Id: config.ID,
Name: "Updated",
Description: ptrString("note"),
Autoplay: true,
ShowControls: true,
Pip: true,
Airplay: true,
Chromecast: true,
IsActive: ptrBool(true),
IsDefault: ptrBool(true),
})
if err != nil {
t.Fatalf("UpdatePlayerConfig() error = %v", err)
}
if updateResp.Config == nil || updateResp.Config.Name != "Updated" || !updateResp.Config.IsDefault {
t.Fatalf("UpdatePlayerConfig() unexpected response: %#v", updateResp)
}
_, err = services.DeletePlayerConfig(testActorIncomingContext(user.ID, "USER"), &appv1.DeletePlayerConfigRequest{Id: config.ID})
if err != nil {
t.Fatalf("DeletePlayerConfig() error = %v", err)
}
items := mustListPlayerConfigsByUser(t, db, user.ID)
if len(items) != 0 {
t.Fatalf("player config count after delete = %d, want 0", len(items))
}
})
t.Run("free downgrade reconciliation only allows delete", func(t *testing.T) {
db := newTestDB(t)
services := newTestAppServices(t, db)
user := seedTestUser(t, db, model.User{ID: uuid.NewString(), Email: "free@example.com", Role: ptrString("USER")})
first := seedTestPlayerConfig(t, db, model.PlayerConfig{ID: uuid.NewString(), UserID: user.ID, Name: "First", IsActive: ptrBool(true), IsDefault: true})
second := seedTestPlayerConfig(t, db, model.PlayerConfig{ID: uuid.NewString(), UserID: user.ID, Name: "Second", IsActive: ptrBool(true)})
_, err := services.UpdatePlayerConfig(testActorIncomingContext(user.ID, "USER"), &appv1.UpdatePlayerConfigRequest{
Id: first.ID,
Name: "Blocked",
ShowControls: true,
Pip: true,
Airplay: true,
Chromecast: true,
IsActive: ptrBool(true),
})
assertGRPCCode(t, err, codes.FailedPrecondition)
if got := status.Convert(err).Message(); got != playerConfigFreePlanReconciliationMessage {
t.Fatalf("grpc message = %q, want %q", got, playerConfigFreePlanReconciliationMessage)
}
_, err = services.DeletePlayerConfig(testActorIncomingContext(user.ID, "USER"), &appv1.DeletePlayerConfigRequest{Id: second.ID})
if err != nil {
t.Fatalf("DeletePlayerConfig() error = %v", err)
}
items := mustListPlayerConfigsByUser(t, db, user.ID)
if len(items) != 1 {
t.Fatalf("player config count after reconciliation delete = %d, want 1", len(items))
}
})
t.Run("paid user can create multiple configs", func(t *testing.T) {
db := newTestDB(t)
services := newTestAppServices(t, db)
planID := uuid.NewString()
seedTestPlan(t, db, model.Plan{ID: planID, Name: "Pro", Price: 10, Cycle: "monthly", StorageLimit: 10, UploadLimit: 1, DurationLimit: 60, QualityLimit: "1080p", IsActive: ptrBool(true)})
user := seedTestUser(t, db, model.User{ID: uuid.NewString(), Email: "paid@example.com", Role: ptrString("USER"), PlanID: &planID})
for _, name := range []string{"One", "Two"} {
_, err := services.CreatePlayerConfig(testActorIncomingContext(user.ID, "USER"), &appv1.CreatePlayerConfigRequest{Name: name})
if err != nil {
t.Fatalf("CreatePlayerConfig(%q) error = %v", name, err)
}
}
items := mustListPlayerConfigsByUser(t, db, user.ID)
if len(items) != 2 {
t.Fatalf("player config count = %d, want 2", len(items))
}
})
t.Run("set default unsets previous default", func(t *testing.T) {
db := newTestDB(t)
services := newTestAppServices(t, db)
planID := uuid.NewString()
seedTestPlan(t, db, model.Plan{ID: planID, Name: "Pro", Price: 10, Cycle: "monthly", StorageLimit: 10, UploadLimit: 1, DurationLimit: 60, QualityLimit: "1080p", IsActive: ptrBool(true)})
user := seedTestUser(t, db, model.User{ID: uuid.NewString(), Email: "paid@example.com", Role: ptrString("USER"), PlanID: &planID})
first := seedTestPlayerConfig(t, db, model.PlayerConfig{ID: uuid.NewString(), UserID: user.ID, Name: "First", IsActive: ptrBool(true), IsDefault: true})
second := seedTestPlayerConfig(t, db, model.PlayerConfig{ID: uuid.NewString(), UserID: user.ID, Name: "Second", IsActive: ptrBool(true), IsDefault: false})
_, err := services.UpdatePlayerConfig(testActorIncomingContext(user.ID, "USER"), &appv1.UpdatePlayerConfigRequest{
Id: second.ID,
Name: second.Name,
ShowControls: true,
Pip: true,
Airplay: true,
Chromecast: true,
IsActive: ptrBool(true),
IsDefault: ptrBool(true),
})
if err != nil {
t.Fatalf("UpdatePlayerConfig() error = %v", err)
}
items := mustListPlayerConfigsByUser(t, db, user.ID)
defaults := map[string]bool{}
for _, item := range items {
defaults[item.ID] = item.IsDefault
}
if defaults[first.ID] {
t.Fatal("first config should no longer be default")
}
if !defaults[second.ID] {
t.Fatal("second config should be default")
}
})
t.Run("concurrent free create creates at most one record", func(t *testing.T) {
db := newTestDB(t)
services := newTestAppServices(t, db)
user := seedTestUser(t, db, model.User{ID: uuid.NewString(), Email: "free@example.com", Role: ptrString("USER")})
const attempts = 8
var wg sync.WaitGroup
var mu sync.Mutex
successes := 0
messages := make([]string, 0, attempts)
for i := 0; i < attempts; i++ {
wg.Add(1)
go func(index int) {
defer wg.Done()
_, err := services.CreatePlayerConfig(testActorIncomingContext(user.ID, "USER"), &appv1.CreatePlayerConfigRequest{Name: "Config-" + uuid.NewString()})
mu.Lock()
defer mu.Unlock()
if err == nil {
successes++
return
}
messages = append(messages, status.Convert(err).Message())
}(i)
}
wg.Wait()
if successes != 1 {
t.Fatalf("success count = %d, want 1 (messages=%v)", successes, messages)
}
items := mustListPlayerConfigsByUser(t, db, user.ID)
if len(items) != 1 {
t.Fatalf("player config count = %d, want 1", len(items))
}
for _, message := range messages {
if message != playerConfigFreePlanLimitMessage && !strings.Contains(strings.ToLower(message), "locked") {
t.Fatalf("unexpected concurrent create error message: %q", message)
}
}
})
}
func testActorIncomingContext(userID, role string) context.Context {
incoming := metadata.NewIncomingContext(context.Background(), metadata.Pairs(
middleware.ActorMarkerMetadataKey, testTrustedMarker,
middleware.ActorIDMetadataKey, userID,
middleware.ActorRoleMetadataKey, role,
middleware.ActorEmailMetadataKey, strings.ToLower(role)+"@example.com",
))
return context.WithValue(incoming, struct{}{}, time.Now())
}
func seedTestPlayerConfig(t *testing.T, db *gorm.DB, config model.PlayerConfig) model.PlayerConfig {
t.Helper()
if config.ShowControls == nil {
config.ShowControls = ptrBool(true)
}
if config.Pip == nil {
config.Pip = ptrBool(true)
}
if config.Airplay == nil {
config.Airplay = ptrBool(true)
}
if config.Chromecast == nil {
config.Chromecast = ptrBool(true)
}
if config.IsActive == nil {
config.IsActive = ptrBool(true)
}
if config.CreatedAt == nil {
now := time.Now().UTC()
config.CreatedAt = &now
}
if err := db.Create(&config).Error; err != nil {
t.Fatalf("create player config: %v", err)
}
return config
}
func mustListPlayerConfigsByUser(t *testing.T, db *gorm.DB, userID string) []model.PlayerConfig {
t.Helper()
var items []model.PlayerConfig
if err := db.Order("created_at ASC, id ASC").Find(&items, "user_id = ?", userID).Error; err != nil {
t.Fatalf("list player configs for user %s: %v", userID, err)
}
return items
}

View File

@@ -0,0 +1,235 @@
package service
import (
"context"
"strings"
"testing"
"github.com/google/uuid"
"google.golang.org/grpc/codes"
"gorm.io/gorm"
appv1 "stream.api/internal/api/proto/app/v1"
"stream.api/internal/database/model"
)
func TestRegisterReferralCapture(t *testing.T) {
t.Run("register với ref hợp lệ lưu referred_by_user_id", func(t *testing.T) {
db := newTestDB(t)
services := newTestAppServices(t, db)
referrer := seedTestUser(t, db, model.User{ID: uuid.NewString(), Email: "ref@example.com", Username: ptrString("alice"), Role: ptrString("USER")})
resp, err := services.Register(context.Background(), &appv1.RegisterRequest{
Username: "bob",
Email: "bob@example.com",
Password: "secret123",
RefUsername: ptrString("alice"),
})
if err != nil {
t.Fatalf("Register() error = %v", err)
}
if resp.User == nil {
t.Fatal("Register() user is nil")
}
created := mustLoadUser(t, db, resp.User.Id)
if created.ReferredByUserID == nil || *created.ReferredByUserID != referrer.ID {
t.Fatalf("referred_by_user_id = %v, want %s", created.ReferredByUserID, referrer.ID)
}
})
t.Run("register với ref invalid hoặc self-ref vẫn tạo user", func(t *testing.T) {
db := newTestDB(t)
services := newTestAppServices(t, db)
resp, err := services.Register(context.Background(), &appv1.RegisterRequest{
Username: "selfie",
Email: "selfie@example.com",
Password: "secret123",
RefUsername: ptrString("selfie"),
})
if err != nil {
t.Fatalf("Register() error = %v", err)
}
created := mustLoadUser(t, db, resp.User.Id)
if created.ReferredByUserID != nil {
t.Fatalf("referred_by_user_id = %v, want nil", created.ReferredByUserID)
}
})
}
func TestResolveSignupReferrerID(t *testing.T) {
t.Run("resolve referrer theo username hợp lệ", func(t *testing.T) {
db := newTestDB(t)
services := newTestAppServices(t, db)
referrer := seedTestUser(t, db, model.User{ID: uuid.NewString(), Email: "ref@example.com", Username: ptrString("alice"), Role: ptrString("USER")})
referrerID, err := services.resolveSignupReferrerID(context.Background(), "alice", "bob")
if err != nil {
t.Fatalf("resolveSignupReferrerID() error = %v", err)
}
if referrerID == nil || *referrerID != referrer.ID {
t.Fatalf("referrerID = %v, want %s", referrerID, referrer.ID)
}
})
t.Run("invalid hoặc self-ref bị ignore", func(t *testing.T) {
db := newTestDB(t)
services := newTestAppServices(t, db)
referrerID, err := services.resolveSignupReferrerID(context.Background(), "bob", "bob")
if err != nil {
t.Fatalf("resolveSignupReferrerID() error = %v", err)
}
if referrerID != nil {
t.Fatalf("referrerID = %v, want nil", referrerID)
}
})
t.Run("username trùng thì ignore trong signup path", func(t *testing.T) {
db := newTestDB(t)
services := newTestAppServices(t, db)
seedTestUser(t, db, model.User{ID: uuid.NewString(), Email: "a@example.com", Username: ptrString("alice"), Role: ptrString("USER")})
seedTestUser(t, db, model.User{ID: uuid.NewString(), Email: "b@example.com", Username: ptrString("alice"), Role: ptrString("USER")})
referrerID, err := services.resolveSignupReferrerID(context.Background(), "alice", "bob")
if err != nil {
t.Fatalf("resolveSignupReferrerID() error = %v", err)
}
if referrerID != nil {
t.Fatalf("referrerID = %v, want nil", referrerID)
}
})
}
func TestReferralRewardFlow(t *testing.T) {
setup := func(t *testing.T) (*appServices, *gorm.DB, model.User, model.User, model.Plan) {
t.Helper()
db := newTestDB(t)
services := newTestAppServices(t, db)
referrer := seedTestUser(t, db, model.User{ID: uuid.NewString(), Email: "ref@example.com", Username: ptrString("alice"), Role: ptrString("USER"), ReferralEligible: ptrBool(true)})
referee := seedTestUser(t, db, model.User{ID: uuid.NewString(), Email: "payer@example.com", Username: ptrString("bob"), Role: ptrString("USER"), ReferredByUserID: &referrer.ID, ReferralEligible: ptrBool(true)})
plan := seedTestPlan(t, db, model.Plan{ID: uuid.NewString(), Name: "Pro", Price: 20, Cycle: "monthly", StorageLimit: 100, UploadLimit: 10, QualityLimit: "1080p", IsActive: ptrBool(true)})
return services, db, referrer, referee, plan
}
t.Run("first subscription thưởng 5 phần trăm", func(t *testing.T) {
services, db, referrer, referee, plan := setup(t)
seedWalletTransaction(t, db, model.WalletTransaction{ID: uuid.NewString(), UserID: referee.ID, Type: walletTransactionTypeTopup, Amount: 20, Currency: ptrString("USD")})
result, err := services.executePaymentFlow(context.Background(), paymentExecutionInput{UserID: referee.ID, Plan: &plan, TermMonths: 1, PaymentMethod: paymentMethodWallet})
if err != nil {
t.Fatalf("executePaymentFlow() error = %v", err)
}
updatedReferee := mustLoadUser(t, db, referee.ID)
if updatedReferee.ReferralRewardPaymentID == nil || *updatedReferee.ReferralRewardPaymentID != result.Payment.ID {
t.Fatalf("reward payment id = %v, want %s", updatedReferee.ReferralRewardPaymentID, result.Payment.ID)
}
if updatedReferee.ReferralRewardAmount == nil || *updatedReferee.ReferralRewardAmount != 1 {
t.Fatalf("reward amount = %v, want 1", updatedReferee.ReferralRewardAmount)
}
balance, err := model.GetWalletBalance(context.Background(), db, referrer.ID)
if err != nil {
t.Fatalf("GetWalletBalance() error = %v", err)
}
if balance != 1 {
t.Fatalf("referrer wallet balance = %v, want 1", balance)
}
notifications := mustListNotificationsByUser(t, db, referrer.ID)
if len(notifications) != 1 || notifications[0].Type != "billing.referral_reward" {
t.Fatalf("notifications = %#v, want one referral reward notification", notifications)
}
})
t.Run("subscription thứ hai không thưởng lại", func(t *testing.T) {
services, db, referrer, referee, plan := setup(t)
seedWalletTransaction(t, db, model.WalletTransaction{ID: uuid.NewString(), UserID: referee.ID, Type: walletTransactionTypeTopup, Amount: 40, Currency: ptrString("USD")})
if _, err := services.executePaymentFlow(context.Background(), paymentExecutionInput{UserID: referee.ID, Plan: &plan, TermMonths: 1, PaymentMethod: paymentMethodWallet}); err != nil {
t.Fatalf("first executePaymentFlow() error = %v", err)
}
if _, err := services.executePaymentFlow(context.Background(), paymentExecutionInput{UserID: referee.ID, Plan: &plan, TermMonths: 1, PaymentMethod: paymentMethodWallet}); err != nil {
t.Fatalf("second executePaymentFlow() error = %v", err)
}
balance, err := model.GetWalletBalance(context.Background(), db, referrer.ID)
if err != nil {
t.Fatalf("GetWalletBalance() error = %v", err)
}
if balance != 1 {
t.Fatalf("referrer wallet balance = %v, want 1", balance)
}
})
t.Run("topup ví đơn thuần không kích hoạt reward", func(t *testing.T) {
services, db, referrer, referee, _ := setup(t)
_, err := services.TopupWallet(testActorIncomingContext(referee.ID, "USER"), &appv1.TopupWalletRequest{Amount: 10})
if err != nil {
t.Fatalf("TopupWallet() error = %v", err)
}
balance, err := model.GetWalletBalance(context.Background(), db, referrer.ID)
if err != nil {
t.Fatalf("GetWalletBalance() error = %v", err)
}
if balance != 0 {
t.Fatalf("referrer wallet balance = %v, want 0", balance)
}
})
t.Run("referrer không eligible thì không grant", func(t *testing.T) {
services, db, referrer, referee, plan := setup(t)
if err := db.Model(&model.User{}).Where("id = ?", referrer.ID).Update("referral_eligible", false).Error; err != nil {
t.Fatalf("update referral_eligible: %v", err)
}
seedWalletTransaction(t, db, model.WalletTransaction{ID: uuid.NewString(), UserID: referee.ID, Type: walletTransactionTypeTopup, Amount: 20, Currency: ptrString("USD")})
if _, err := services.executePaymentFlow(context.Background(), paymentExecutionInput{UserID: referee.ID, Plan: &plan, TermMonths: 1, PaymentMethod: paymentMethodWallet}); err != nil {
t.Fatalf("executePaymentFlow() error = %v", err)
}
balance, err := model.GetWalletBalance(context.Background(), db, referrer.ID)
if err != nil {
t.Fatalf("GetWalletBalance() error = %v", err)
}
if balance != 0 {
t.Fatalf("referrer wallet balance = %v, want 0", balance)
}
})
t.Run("override reward bps áp dụng đúng", func(t *testing.T) {
services, db, referrer, referee, plan := setup(t)
if err := db.Model(&model.User{}).Where("id = ?", referrer.ID).Update("referral_reward_bps", 750).Error; err != nil {
t.Fatalf("update referral_reward_bps: %v", err)
}
seedWalletTransaction(t, db, model.WalletTransaction{ID: uuid.NewString(), UserID: referee.ID, Type: walletTransactionTypeTopup, Amount: 20, Currency: ptrString("USD")})
if _, err := services.executePaymentFlow(context.Background(), paymentExecutionInput{UserID: referee.ID, Plan: &plan, TermMonths: 1, PaymentMethod: paymentMethodWallet}); err != nil {
t.Fatalf("executePaymentFlow() error = %v", err)
}
balance, err := model.GetWalletBalance(context.Background(), db, referrer.ID)
if err != nil {
t.Fatalf("GetWalletBalance() error = %v", err)
}
if balance != 1.5 {
t.Fatalf("referrer wallet balance = %v, want 1.5", balance)
}
})
}
func TestUpdateAdminUserReferralSettings(t *testing.T) {
db := newTestDB(t)
services := newTestAppServices(t, db)
admin := seedTestUser(t, db, model.User{ID: uuid.NewString(), Email: "admin@example.com", Role: ptrString("ADMIN")})
referrer := seedTestUser(t, db, model.User{ID: uuid.NewString(), Email: "ref@example.com", Username: ptrString("alice"), Role: ptrString("USER")})
referee := seedTestUser(t, db, model.User{ID: uuid.NewString(), Email: "payer@example.com", Username: ptrString("bob"), Role: ptrString("USER"), ReferredByUserID: &referrer.ID, ReferralEligible: ptrBool(true)})
plan := seedTestPlan(t, db, model.Plan{ID: uuid.NewString(), Name: "Pro", Price: 20, Cycle: "monthly", StorageLimit: 100, UploadLimit: 10, QualityLimit: "1080p", IsActive: ptrBool(true)})
seedWalletTransaction(t, db, model.WalletTransaction{ID: uuid.NewString(), UserID: referee.ID, Type: walletTransactionTypeTopup, Amount: 20, Currency: ptrString("USD")})
if _, err := services.executePaymentFlow(context.Background(), paymentExecutionInput{UserID: referee.ID, Plan: &plan, TermMonths: 1, PaymentMethod: paymentMethodWallet}); err != nil {
t.Fatalf("executePaymentFlow() error = %v", err)
}
_, err := services.UpdateAdminUserReferralSettings(testActorIncomingContext(admin.ID, "ADMIN"), &appv1.UpdateAdminUserReferralSettingsRequest{
Id: referee.ID,
RefUsername: ptrString("alice"),
})
assertGRPCCode(t, err, codes.InvalidArgument)
}
func containsAny(value string, parts ...string) bool {
for _, part := range parts {
if part != "" && strings.Contains(value, part) {
return true
}
}
return false
}

View File

@@ -0,0 +1,385 @@
// update lại test sau nhé.
package service
import (
"context"
"fmt"
"net"
"testing"
"time"
"github.com/google/uuid"
"google.golang.org/grpc"
"google.golang.org/grpc/credentials/insecure"
"google.golang.org/grpc/metadata"
"google.golang.org/grpc/test/bufconn"
"gorm.io/driver/sqlite"
"gorm.io/gorm"
_ "modernc.org/sqlite"
appv1 "stream.api/internal/api/proto/app/v1"
"stream.api/internal/database/model"
"stream.api/internal/database/query"
"stream.api/internal/middleware"
"stream.api/pkg/logger"
)
const testTrustedMarker = "trusted-test-marker"
var testBufDialerListenerSize = 1024 * 1024
type testLogger struct{}
type fakeCache struct {
values map[string]string
}
type fakeTokenProvider struct{}
func (testLogger) Info(string, ...any) {}
func (testLogger) Error(string, ...any) {}
func (testLogger) Debug(string, ...any) {}
func (testLogger) Warn(string, ...any) {}
func (f *fakeCache) Set(_ context.Context, key string, value interface{}, _ time.Duration) error {
if f.values == nil {
f.values = map[string]string{}
}
f.values[key] = fmt.Sprint(value)
return nil
}
func (f *fakeCache) Get(_ context.Context, key string) (string, error) {
if f.values == nil {
return "", fmt.Errorf("cache miss")
}
value, ok := f.values[key]
if !ok {
return "", fmt.Errorf("cache miss")
}
return value, nil
}
func (f *fakeCache) Del(_ context.Context, key string) error {
delete(f.values, key)
return nil
}
func (f *fakeCache) Close() error {
return nil
}
// var _ goredis.Client = (*fakeCache)(nil)
func newTestDB(t *testing.T) *gorm.DB {
t.Helper()
dsn := fmt.Sprintf("file:%s?mode=memory&cache=shared", uuid.NewString())
db, err := gorm.Open(sqlite.Dialector{DriverName: "sqlite", DSN: dsn}, &gorm.Config{})
if err != nil {
t.Fatalf("open sqlite db: %v", err)
}
for _, stmt := range []string{
`CREATE TABLE user (
id TEXT PRIMARY KEY,
email TEXT NOT NULL,
password TEXT,
username TEXT,
avatar TEXT,
role TEXT NOT NULL,
google_id TEXT,
storage_used INTEGER NOT NULL DEFAULT 0,
plan_id TEXT,
referred_by_user_id TEXT,
referral_eligible BOOLEAN NOT NULL DEFAULT 1,
referral_reward_bps INTEGER,
referral_reward_granted_at DATETIME,
referral_reward_payment_id TEXT,
referral_reward_amount REAL,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME,
version INTEGER NOT NULL DEFAULT 1,
telegram_id TEXT
)`,
`CREATE TABLE plan (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
description TEXT,
price REAL NOT NULL,
cycle TEXT NOT NULL,
storage_limit INTEGER NOT NULL,
upload_limit INTEGER NOT NULL,
duration_limit INTEGER NOT NULL,
quality_limit TEXT NOT NULL,
features JSON,
is_active BOOLEAN NOT NULL DEFAULT 1,
version INTEGER NOT NULL DEFAULT 1
)`,
`CREATE TABLE payment (
id TEXT PRIMARY KEY,
user_id TEXT NOT NULL,
plan_id TEXT,
amount REAL NOT NULL,
currency TEXT,
status TEXT,
provider TEXT,
transaction_id TEXT,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME,
version INTEGER NOT NULL DEFAULT 1
)`,
`CREATE TABLE plan_subscriptions (
id TEXT PRIMARY KEY,
user_id TEXT NOT NULL,
payment_id TEXT NOT NULL,
plan_id TEXT NOT NULL,
term_months INTEGER NOT NULL,
payment_method TEXT NOT NULL,
wallet_amount REAL NOT NULL,
topup_amount REAL NOT NULL,
started_at DATETIME NOT NULL,
expires_at DATETIME NOT NULL,
reminder_7d_sent_at DATETIME,
reminder_3d_sent_at DATETIME,
reminder_1d_sent_at DATETIME,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME,
version INTEGER NOT NULL DEFAULT 1
)`,
`CREATE TABLE wallet_transactions (
id TEXT PRIMARY KEY,
user_id TEXT NOT NULL,
type TEXT NOT NULL,
amount REAL NOT NULL,
currency TEXT,
note TEXT,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME,
payment_id TEXT,
plan_id TEXT,
term_months INTEGER,
version INTEGER NOT NULL DEFAULT 1
)`,
`CREATE TABLE notifications (
id TEXT PRIMARY KEY,
user_id TEXT NOT NULL,
type TEXT NOT NULL,
title TEXT NOT NULL,
message TEXT NOT NULL,
metadata TEXT,
action_url TEXT,
action_label TEXT,
is_read BOOLEAN NOT NULL DEFAULT 0,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME,
version INTEGER NOT NULL DEFAULT 1
)`,
`CREATE TABLE user_preferences (
user_id TEXT PRIMARY KEY,
language TEXT NOT NULL DEFAULT 'en',
locale TEXT NOT NULL DEFAULT 'en',
email_notifications BOOLEAN NOT NULL DEFAULT 1,
push_notifications BOOLEAN NOT NULL DEFAULT 1,
marketing_notifications BOOLEAN NOT NULL DEFAULT 0,
telegram_notifications BOOLEAN NOT NULL DEFAULT 0,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME,
version INTEGER NOT NULL DEFAULT 1
)`,
`CREATE TABLE player_configs (
id TEXT PRIMARY KEY,
user_id TEXT NOT NULL,
name TEXT NOT NULL,
description TEXT,
autoplay BOOLEAN NOT NULL DEFAULT 0,
loop BOOLEAN NOT NULL DEFAULT 0,
muted BOOLEAN NOT NULL DEFAULT 0,
show_controls BOOLEAN NOT NULL DEFAULT 1,
pip BOOLEAN NOT NULL DEFAULT 1,
airplay BOOLEAN NOT NULL DEFAULT 1,
chromecast BOOLEAN NOT NULL DEFAULT 1,
is_active BOOLEAN NOT NULL DEFAULT 1,
is_default BOOLEAN NOT NULL DEFAULT 0,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME,
version INTEGER NOT NULL DEFAULT 1,
encrytion_m3u8 BOOLEAN NOT NULL DEFAULT 1,
logo_url TEXT
)`,
} {
if err := db.Exec(stmt).Error; err != nil {
t.Fatalf("create test schema: %v", err)
}
}
query.SetDefault(db)
return db
}
func newTestAppServices(t *testing.T, db *gorm.DB) *appServices {
t.Helper()
if db == nil {
db = newTestDB(t)
}
return &appServices{
db: db,
logger: testLogger{},
authenticator: middleware.NewAuthenticator(db, testLogger{}, testTrustedMarker),
// cache: &fakeCache{values: map[string]string{}},
googleUserInfoURL: defaultGoogleUserInfoURL,
}
}
func newTestGRPCServer(t *testing.T, services *appServices) (*grpc.ClientConn, func()) {
t.Helper()
lis := bufconn.Listen(testBufDialerListenerSize)
server := grpc.NewServer()
Register(server, &Services{
AuthServer: services,
AccountServer: services,
UsageServer: services,
NotificationsServer: services,
DomainsServer: services,
AdTemplatesServer: services,
PlayerConfigsServer: services,
PlansServer: services,
PaymentsServer: services,
VideosServer: services,
AdminServer: services,
})
go func() {
_ = server.Serve(lis)
}()
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
conn, err := grpc.DialContext(ctx, "bufnet",
grpc.WithContextDialer(func(context.Context, string) (net.Conn, error) {
return lis.Dial()
}),
grpc.WithTransportCredentials(insecure.NewCredentials()),
)
cancel()
if err != nil {
server.Stop()
_ = lis.Close()
t.Fatalf("dial bufconn: %v", err)
}
cleanup := func() {
_ = conn.Close()
server.Stop()
_ = lis.Close()
}
return conn, cleanup
}
func ptrFloat64(v float64) *float64 { return &v }
func ptrString(v string) *string { return &v }
func ptrBool(v bool) *bool { return &v }
func ptrInt64(v int64) *int64 { return &v }
func firstTestMetadataValue(md metadata.MD, key string) string {
values := md.Get(key)
if len(values) == 0 {
return ""
}
return values[0]
}
func seedTestUser(t *testing.T, db *gorm.DB, user model.User) model.User {
t.Helper()
if user.Role == nil {
user.Role = ptrString("USER")
}
if err := db.Create(&user).Error; err != nil {
t.Fatalf("create user: %v", err)
}
return user
}
func seedTestPlan(t *testing.T, db *gorm.DB, plan model.Plan) model.Plan {
t.Helper()
if plan.IsActive == nil {
plan.IsActive = ptrBool(true)
}
if err := db.Create(&plan).Error; err != nil {
t.Fatalf("create plan: %v", err)
}
return plan
}
func seedWalletTransaction(t *testing.T, db *gorm.DB, tx model.WalletTransaction) model.WalletTransaction {
t.Helper()
if err := db.Create(&tx).Error; err != nil {
t.Fatalf("create wallet transaction: %v", err)
}
return tx
}
func seedSubscription(t *testing.T, db *gorm.DB, subscription model.PlanSubscription) model.PlanSubscription {
t.Helper()
if err := db.Create(&subscription).Error; err != nil {
t.Fatalf("create subscription: %v", err)
}
return subscription
}
func mustLoadUser(t *testing.T, db *gorm.DB, userID string) model.User {
t.Helper()
var user model.User
if err := db.First(&user, "id = ?", userID).Error; err != nil {
t.Fatalf("load user %s: %v", userID, err)
}
return user
}
func mustLoadPayment(t *testing.T, db *gorm.DB, paymentID string) model.Payment {
t.Helper()
var payment model.Payment
if err := db.First(&payment, "id = ?", paymentID).Error; err != nil {
t.Fatalf("load payment %s: %v", paymentID, err)
}
return payment
}
func mustLoadSubscriptionByPayment(t *testing.T, db *gorm.DB, paymentID string) model.PlanSubscription {
t.Helper()
var subscription model.PlanSubscription
if err := db.First(&subscription, "payment_id = ?", paymentID).Error; err != nil {
t.Fatalf("load subscription for payment %s: %v", paymentID, err)
}
return subscription
}
func mustListWalletTransactionsByPayment(t *testing.T, db *gorm.DB, paymentID string) []model.WalletTransaction {
t.Helper()
var items []model.WalletTransaction
if err := db.Order("amount DESC").Find(&items, "payment_id = ?", paymentID).Error; err != nil {
t.Fatalf("list wallet transactions for payment %s: %v", paymentID, err)
}
return items
}
func mustListNotificationsByUser(t *testing.T, db *gorm.DB, userID string) []model.Notification {
t.Helper()
var items []model.Notification
if err := db.Order("created_at ASC, id ASC").Find(&items, "user_id = ?", userID).Error; err != nil {
t.Fatalf("list notifications for user %s: %v", userID, err)
}
return items
}
func newPaymentsClient(conn *grpc.ClientConn) appv1.PaymentsClient {
return appv1.NewPaymentsClient(conn)
}
func newAdminClient(conn *grpc.ClientConn) appv1.AdminClient {
return appv1.NewAdminClient(conn)
}
var _ logger.Logger = testLogger{}