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:
@@ -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")
|
||||
}
|
||||
})
|
||||
}
|
||||
195
internal/service/__test__/service_admin_jobs_agents_test.go
Normal file
195
internal/service/__test__/service_admin_jobs_agents_test.go
Normal 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 }
|
||||
238
internal/service/__test__/service_helpers_payment_flow_test.go
Normal file
238
internal/service/__test__/service_helpers_payment_flow_test.go
Normal 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"])
|
||||
}
|
||||
}
|
||||
181
internal/service/__test__/service_payments_test.go
Normal file
181
internal/service/__test__/service_payments_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
266
internal/service/__test__/service_player_configs_test.go
Normal file
266
internal/service/__test__/service_player_configs_test.go
Normal 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
|
||||
}
|
||||
235
internal/service/__test__/service_referrals_test.go
Normal file
235
internal/service/__test__/service_referrals_test.go
Normal 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
|
||||
}
|
||||
385
internal/service/__test__/testdb_setup_test.go
Normal file
385
internal/service/__test__/testdb_setup_test.go
Normal 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{}
|
||||
Reference in New Issue
Block a user