feat: Add player_configs feature and migrate user preferences
- Implemented player_configs table to store multiple player configurations per user. - Migrated existing player settings from user_preferences to player_configs. - Removed player-related columns from user_preferences. - Added referral state fields to user for tracking referral rewards. - Created migration scripts for database changes and data migration. - Added test cases for app services and usage helpers. - Introduced video job service interfaces and implementations.
This commit is contained in:
63
internal/rpc/app/preferences_helpers.go
Normal file
63
internal/rpc/app/preferences_helpers.go
Normal file
@@ -0,0 +1,63 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
|
||||
"gorm.io/gorm"
|
||||
"stream.api/internal/database/model"
|
||||
"stream.api/pkg/logger"
|
||||
)
|
||||
|
||||
type updatePreferencesInput struct {
|
||||
EmailNotifications *bool
|
||||
PushNotifications *bool
|
||||
MarketingNotifications *bool
|
||||
TelegramNotifications *bool
|
||||
Language *string
|
||||
Locale *string
|
||||
}
|
||||
|
||||
func loadUserPreferences(ctx context.Context, db *gorm.DB, userID string) (*model.UserPreference, error) {
|
||||
return model.FindOrCreateUserPreference(ctx, db, userID)
|
||||
}
|
||||
|
||||
func updateUserPreferences(ctx context.Context, db *gorm.DB, l logger.Logger, userID string, req updatePreferencesInput) (*model.UserPreference, error) {
|
||||
pref, err := model.FindOrCreateUserPreference(ctx, db, userID)
|
||||
if err != nil {
|
||||
l.Error("Failed to load preferences", "error", err)
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if req.EmailNotifications != nil {
|
||||
pref.EmailNotifications = model.BoolPtr(*req.EmailNotifications)
|
||||
}
|
||||
if req.PushNotifications != nil {
|
||||
pref.PushNotifications = model.BoolPtr(*req.PushNotifications)
|
||||
}
|
||||
if req.MarketingNotifications != nil {
|
||||
pref.MarketingNotifications = *req.MarketingNotifications
|
||||
}
|
||||
if req.TelegramNotifications != nil {
|
||||
pref.TelegramNotifications = *req.TelegramNotifications
|
||||
}
|
||||
if req.Language != nil {
|
||||
pref.Language = model.StringPtr(strings.TrimSpace(*req.Language))
|
||||
}
|
||||
if req.Locale != nil {
|
||||
pref.Locale = model.StringPtr(strings.TrimSpace(*req.Locale))
|
||||
}
|
||||
if strings.TrimSpace(model.StringValue(pref.Language)) == "" {
|
||||
pref.Language = model.StringPtr("en")
|
||||
}
|
||||
if strings.TrimSpace(model.StringValue(pref.Locale)) == "" {
|
||||
pref.Locale = model.StringPtr(model.StringValue(pref.Language))
|
||||
}
|
||||
|
||||
if err := db.WithContext(ctx).Save(pref).Error; err != nil {
|
||||
l.Error("Failed to save preferences", "error", err)
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return pref, nil
|
||||
}
|
||||
87
internal/rpc/app/profile_helpers.go
Normal file
87
internal/rpc/app/profile_helpers.go
Normal file
@@ -0,0 +1,87 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"strings"
|
||||
|
||||
"gorm.io/gorm"
|
||||
"stream.api/internal/database/model"
|
||||
"stream.api/internal/database/query"
|
||||
"stream.api/pkg/logger"
|
||||
)
|
||||
|
||||
var (
|
||||
errEmailRequired = errors.New("Email is required")
|
||||
errEmailAlreadyRegistered = errors.New("Email already registered")
|
||||
)
|
||||
|
||||
type updateProfileInput struct {
|
||||
Username *string
|
||||
Email *string
|
||||
Language *string
|
||||
Locale *string
|
||||
}
|
||||
|
||||
func updateUserProfile(ctx context.Context, db *gorm.DB, l logger.Logger, userID string, req updateProfileInput) (*model.User, error) {
|
||||
updates := map[string]any{}
|
||||
if req.Username != nil {
|
||||
username := strings.TrimSpace(*req.Username)
|
||||
updates["username"] = username
|
||||
}
|
||||
if req.Email != nil {
|
||||
email := strings.TrimSpace(*req.Email)
|
||||
if email == "" {
|
||||
return nil, errEmailRequired
|
||||
}
|
||||
updates["email"] = email
|
||||
}
|
||||
|
||||
if len(updates) > 0 {
|
||||
if err := db.WithContext(ctx).Model(&model.User{}).Where("id = ?", userID).Updates(updates).Error; err != nil {
|
||||
if errors.Is(err, gorm.ErrDuplicatedKey) {
|
||||
return nil, errEmailAlreadyRegistered
|
||||
}
|
||||
l.Error("Failed to update user", "error", err)
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
pref, err := model.FindOrCreateUserPreference(ctx, db, userID)
|
||||
if err != nil {
|
||||
l.Error("Failed to load user preference", "error", err)
|
||||
return nil, err
|
||||
}
|
||||
|
||||
prefChanged := false
|
||||
if req.Language != nil {
|
||||
pref.Language = model.StringPtr(strings.TrimSpace(*req.Language))
|
||||
prefChanged = true
|
||||
}
|
||||
if req.Locale != nil {
|
||||
pref.Locale = model.StringPtr(strings.TrimSpace(*req.Locale))
|
||||
prefChanged = true
|
||||
}
|
||||
if strings.TrimSpace(model.StringValue(pref.Language)) == "" {
|
||||
pref.Language = model.StringPtr("en")
|
||||
prefChanged = true
|
||||
}
|
||||
if strings.TrimSpace(model.StringValue(pref.Locale)) == "" {
|
||||
pref.Locale = model.StringPtr(model.StringValue(pref.Language))
|
||||
prefChanged = true
|
||||
}
|
||||
if prefChanged {
|
||||
if err := db.WithContext(ctx).Save(pref).Error; err != nil {
|
||||
l.Error("Failed to save user preference", "error", err)
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
u := query.User
|
||||
user, err := u.WithContext(ctx).Where(u.ID.Eq(userID)).First()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return user, nil
|
||||
}
|
||||
@@ -13,6 +13,7 @@ func Register(server grpc.ServiceRegistrar, services *Services) {
|
||||
appv1.RegisterNotificationsServiceServer(server, services.NotificationsServiceServer)
|
||||
appv1.RegisterDomainsServiceServer(server, services.DomainsServiceServer)
|
||||
appv1.RegisterAdTemplatesServiceServer(server, services.AdTemplatesServiceServer)
|
||||
appv1.RegisterPlayerConfigsServiceServer(server, services.PlayerConfigsServiceServer)
|
||||
appv1.RegisterPlansServiceServer(server, services.PlansServiceServer)
|
||||
appv1.RegisterPaymentsServiceServer(server, services.PaymentsServiceServer)
|
||||
appv1.RegisterVideosServiceServer(server, services.VideosServiceServer)
|
||||
|
||||
@@ -6,11 +6,10 @@ import (
|
||||
|
||||
"google.golang.org/grpc/codes"
|
||||
"google.golang.org/grpc/status"
|
||||
"google.golang.org/protobuf/types/known/wrapperspb"
|
||||
"gorm.io/gorm"
|
||||
authapi "stream.api/internal/api/auth"
|
||||
preferencesapi "stream.api/internal/api/preferences"
|
||||
usageapi "stream.api/internal/api/usage"
|
||||
"stream.api/internal/database/model"
|
||||
"stream.api/internal/database/query"
|
||||
appv1 "stream.api/internal/gen/proto/app/v1"
|
||||
)
|
||||
|
||||
@@ -19,11 +18,27 @@ func (s *appServices) GetMe(ctx context.Context, _ *appv1.GetMeRequest) (*appv1.
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
payload, err := authapi.BuildUserPayload(ctx, s.db, result.User)
|
||||
payload, err := buildUserPayload(ctx, s.db, result.User)
|
||||
if err != nil {
|
||||
return nil, status.Error(codes.Internal, "Failed to build user payload")
|
||||
}
|
||||
return &appv1.GetMeResponse{User: toProtoUserPayload(payload)}, nil
|
||||
return &appv1.GetMeResponse{User: toProtoUser(payload)}, nil
|
||||
}
|
||||
func (s *appServices) GetUserById(ctx context.Context, req *wrapperspb.StringValue) (*appv1.User, error) {
|
||||
_, err := s.authenticator.RequireTrustedMetadata(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
u := query.User
|
||||
user, err := u.WithContext(ctx).Where(u.ID.Eq(req.Value)).First()
|
||||
if err != nil {
|
||||
return nil, status.Error(codes.Unauthenticated, "Unauthorized")
|
||||
}
|
||||
payload, err := buildUserPayload(ctx, s.db, user)
|
||||
if err != nil {
|
||||
return nil, status.Error(codes.Internal, "Failed to build user payload")
|
||||
}
|
||||
return toProtoUser(payload), nil
|
||||
}
|
||||
func (s *appServices) UpdateMe(ctx context.Context, req *appv1.UpdateMeRequest) (*appv1.UpdateMeResponse, error) {
|
||||
result, err := s.authenticate(ctx)
|
||||
@@ -31,7 +46,7 @@ func (s *appServices) UpdateMe(ctx context.Context, req *appv1.UpdateMeRequest)
|
||||
return nil, err
|
||||
}
|
||||
|
||||
updatedUser, err := authapi.UpdateUserProfile(ctx, s.db, s.logger, result.UserID, authapi.UpdateProfileInput{
|
||||
updatedUser, err := updateUserProfile(ctx, s.db, s.logger, result.UserID, updateProfileInput{
|
||||
Username: req.Username,
|
||||
Email: req.Email,
|
||||
Language: req.Language,
|
||||
@@ -39,14 +54,14 @@ func (s *appServices) UpdateMe(ctx context.Context, req *appv1.UpdateMeRequest)
|
||||
})
|
||||
if err != nil {
|
||||
switch {
|
||||
case errors.Is(err, authapi.ErrEmailRequired), errors.Is(err, authapi.ErrEmailAlreadyRegistered):
|
||||
case errors.Is(err, errEmailRequired), errors.Is(err, errEmailAlreadyRegistered):
|
||||
return nil, status.Error(codes.InvalidArgument, err.Error())
|
||||
default:
|
||||
return nil, status.Error(codes.Internal, "Failed to update profile")
|
||||
}
|
||||
}
|
||||
|
||||
payload, err := authapi.BuildUserPayload(ctx, s.db, updatedUser)
|
||||
payload, err := buildUserPayload(ctx, s.db, updatedUser)
|
||||
if err != nil {
|
||||
return nil, status.Error(codes.Internal, "Failed to build user payload")
|
||||
}
|
||||
@@ -69,9 +84,6 @@ func (s *appServices) DeleteMe(ctx context.Context, _ *appv1.DeleteMeRequest) (*
|
||||
if err := tx.Where("user_id = ?", userID).Delete(&model.AdTemplate{}).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if err := tx.Where("user_id = ?", userID).Delete(&model.VideoAdConfig{}).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if err := tx.Where("user_id = ?", userID).Delete(&model.WalletTransaction{}).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -115,9 +127,6 @@ func (s *appServices) ClearMyData(ctx context.Context, _ *appv1.ClearMyDataReque
|
||||
if err := tx.Where("user_id = ?", userID).Delete(&model.AdTemplate{}).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if err := tx.Where("user_id = ?", userID).Delete(&model.VideoAdConfig{}).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if err := tx.Where("user_id = ?", userID).Delete(&model.Video{}).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -137,7 +146,7 @@ func (s *appServices) GetPreferences(ctx context.Context, _ *appv1.GetPreference
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
pref, err := preferencesapi.LoadUserPreferences(ctx, s.db, result.UserID)
|
||||
pref, err := loadUserPreferences(ctx, s.db, result.UserID)
|
||||
if err != nil {
|
||||
return nil, status.Error(codes.Internal, "Failed to load preferences")
|
||||
}
|
||||
@@ -148,18 +157,11 @@ func (s *appServices) UpdatePreferences(ctx context.Context, req *appv1.UpdatePr
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
pref, err := preferencesapi.UpdateUserPreferences(ctx, s.db, s.logger, result.UserID, preferencesapi.UpdateInput{
|
||||
pref, err := updateUserPreferences(ctx, s.db, s.logger, result.UserID, updatePreferencesInput{
|
||||
EmailNotifications: req.EmailNotifications,
|
||||
PushNotifications: req.PushNotifications,
|
||||
MarketingNotifications: req.MarketingNotifications,
|
||||
TelegramNotifications: req.TelegramNotifications,
|
||||
Autoplay: req.Autoplay,
|
||||
Loop: req.Loop,
|
||||
Muted: req.Muted,
|
||||
ShowControls: req.ShowControls,
|
||||
Pip: req.Pip,
|
||||
Airplay: req.Airplay,
|
||||
Chromecast: req.Chromecast,
|
||||
Language: req.Language,
|
||||
Locale: req.Locale,
|
||||
})
|
||||
@@ -173,7 +175,7 @@ func (s *appServices) GetUsage(ctx context.Context, _ *appv1.GetUsageRequest) (*
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
payload, err := usageapi.LoadUsage(ctx, s.db, s.logger, result.User)
|
||||
payload, err := loadUsage(ctx, s.db, s.logger, result.User)
|
||||
if err != nil {
|
||||
return nil, status.Error(codes.Internal, "Failed to load usage")
|
||||
}
|
||||
|
||||
@@ -3,10 +3,7 @@ package app
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"google.golang.org/grpc/codes"
|
||||
@@ -99,173 +96,21 @@ func (s *appServices) CreateAdminPayment(ctx context.Context, req *appv1.CreateA
|
||||
return nil, status.Error(codes.InvalidArgument, "Payment method must be wallet or topup")
|
||||
}
|
||||
|
||||
var user model.User
|
||||
if err := s.db.WithContext(ctx).Where("id = ?", userID).First(&user).Error; err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return nil, status.Error(codes.InvalidArgument, "User not found")
|
||||
}
|
||||
return nil, status.Error(codes.Internal, "Failed to create payment")
|
||||
user, err := s.loadPaymentUserForAdmin(ctx, userID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
planRecord, err := s.loadPaymentPlanForAdmin(ctx, planID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var planRecord model.Plan
|
||||
if err := s.db.WithContext(ctx).Where("id = ?", planID).First(&planRecord).Error; err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return nil, status.Error(codes.InvalidArgument, "Plan not found")
|
||||
}
|
||||
return nil, status.Error(codes.Internal, "Failed to create payment")
|
||||
}
|
||||
if planRecord.IsActive == nil || !*planRecord.IsActive {
|
||||
return nil, status.Error(codes.InvalidArgument, "Plan is not active")
|
||||
}
|
||||
|
||||
totalAmount := planRecord.Price * float64(req.GetTermMonths())
|
||||
statusValue := "SUCCESS"
|
||||
provider := "INTERNAL"
|
||||
currency := normalizeCurrency(nil)
|
||||
transactionID := buildTransactionID("sub")
|
||||
now := time.Now().UTC()
|
||||
|
||||
paymentRecord := &model.Payment{
|
||||
ID: uuid.New().String(),
|
||||
resultValue, err := s.executePaymentFlow(ctx, paymentExecutionInput{
|
||||
UserID: user.ID,
|
||||
PlanID: &planRecord.ID,
|
||||
Amount: totalAmount,
|
||||
Currency: ¤cy,
|
||||
Status: &statusValue,
|
||||
Provider: &provider,
|
||||
TransactionID: &transactionID,
|
||||
}
|
||||
invoiceID := buildInvoiceID(paymentRecord.ID)
|
||||
var subscription *model.PlanSubscription
|
||||
var walletBalance float64
|
||||
|
||||
err := s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||
if _, err := lockUserForUpdate(ctx, tx, user.ID); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
currentSubscription, err := model.GetLatestPlanSubscription(ctx, tx, user.ID)
|
||||
if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return err
|
||||
}
|
||||
|
||||
baseExpiry := now
|
||||
if currentSubscription != nil && currentSubscription.ExpiresAt.After(baseExpiry) {
|
||||
baseExpiry = currentSubscription.ExpiresAt.UTC()
|
||||
}
|
||||
newExpiry := baseExpiry.AddDate(0, int(req.GetTermMonths()), 0)
|
||||
|
||||
currentWalletBalance, err := model.GetWalletBalance(ctx, tx, user.ID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
shortfall := maxFloat(totalAmount-currentWalletBalance, 0)
|
||||
if paymentMethod == paymentMethodWallet && shortfall > 0 {
|
||||
return statusErrorWithBody(ctx, codes.InvalidArgument, http.StatusBadRequest, "Insufficient wallet balance", map[string]interface{}{
|
||||
"payment_method": paymentMethod,
|
||||
"wallet_balance": currentWalletBalance,
|
||||
"total_amount": totalAmount,
|
||||
"shortfall": shortfall,
|
||||
})
|
||||
}
|
||||
|
||||
topupAmount := 0.0
|
||||
if paymentMethod == paymentMethodTopup {
|
||||
if req.TopupAmount == nil {
|
||||
return statusErrorWithBody(ctx, codes.InvalidArgument, http.StatusBadRequest, "Top-up amount is required when payment method is topup", map[string]interface{}{
|
||||
"payment_method": paymentMethod,
|
||||
"wallet_balance": currentWalletBalance,
|
||||
"total_amount": totalAmount,
|
||||
"shortfall": shortfall,
|
||||
})
|
||||
}
|
||||
topupAmount = maxFloat(req.GetTopupAmount(), 0)
|
||||
if topupAmount <= 0 {
|
||||
return statusErrorWithBody(ctx, codes.InvalidArgument, http.StatusBadRequest, "Top-up amount must be greater than 0", map[string]interface{}{
|
||||
"payment_method": paymentMethod,
|
||||
"wallet_balance": currentWalletBalance,
|
||||
"total_amount": totalAmount,
|
||||
"shortfall": shortfall,
|
||||
})
|
||||
}
|
||||
if topupAmount < shortfall {
|
||||
return statusErrorWithBody(ctx, codes.InvalidArgument, http.StatusBadRequest, "Top-up amount must be greater than or equal to the required shortfall", map[string]interface{}{
|
||||
"payment_method": paymentMethod,
|
||||
"wallet_balance": currentWalletBalance,
|
||||
"total_amount": totalAmount,
|
||||
"shortfall": shortfall,
|
||||
"topup_amount": topupAmount,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
if err := tx.Create(paymentRecord).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
walletUsedAmount := totalAmount
|
||||
if paymentMethod == paymentMethodTopup {
|
||||
topupTransaction := &model.WalletTransaction{
|
||||
ID: uuid.New().String(),
|
||||
UserID: user.ID,
|
||||
Type: walletTransactionTypeTopup,
|
||||
Amount: maxFloat(req.GetTopupAmount(), 0),
|
||||
Currency: model.StringPtr(currency),
|
||||
Note: model.StringPtr(fmt.Sprintf("Wallet top-up for %s (%d months)", planRecord.Name, req.GetTermMonths())),
|
||||
PaymentID: &paymentRecord.ID,
|
||||
PlanID: &planRecord.ID,
|
||||
TermMonths: int32Ptr(req.GetTermMonths()),
|
||||
}
|
||||
if err := tx.Create(topupTransaction).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
debitTransaction := &model.WalletTransaction{
|
||||
ID: uuid.New().String(),
|
||||
UserID: user.ID,
|
||||
Type: walletTransactionTypeSubscriptionDebit,
|
||||
Amount: -totalAmount,
|
||||
Currency: model.StringPtr(currency),
|
||||
Note: model.StringPtr(fmt.Sprintf("Subscription payment for %s (%d months)", planRecord.Name, req.GetTermMonths())),
|
||||
PaymentID: &paymentRecord.ID,
|
||||
PlanID: &planRecord.ID,
|
||||
TermMonths: int32Ptr(req.GetTermMonths()),
|
||||
}
|
||||
if err := tx.Create(debitTransaction).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
subscription = &model.PlanSubscription{
|
||||
ID: uuid.New().String(),
|
||||
UserID: user.ID,
|
||||
PaymentID: paymentRecord.ID,
|
||||
PlanID: planRecord.ID,
|
||||
TermMonths: req.GetTermMonths(),
|
||||
PaymentMethod: paymentMethod,
|
||||
WalletAmount: walletUsedAmount,
|
||||
TopupAmount: maxFloat(req.GetTopupAmount(), 0),
|
||||
StartedAt: now,
|
||||
ExpiresAt: newExpiry,
|
||||
}
|
||||
if err := tx.Create(subscription).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := tx.Model(&model.User{}).Where("id = ?", user.ID).Update("plan_id", planRecord.ID).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
notification := buildSubscriptionNotification(user.ID, paymentRecord.ID, invoiceID, &planRecord, subscription)
|
||||
if err := tx.Create(notification).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
walletBalance, err = model.GetWalletBalance(ctx, tx, user.ID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
Plan: planRecord,
|
||||
TermMonths: req.GetTermMonths(),
|
||||
PaymentMethod: paymentMethod,
|
||||
TopupAmount: req.TopupAmount,
|
||||
})
|
||||
if err != nil {
|
||||
if _, ok := status.FromError(err); ok {
|
||||
@@ -274,15 +119,15 @@ func (s *appServices) CreateAdminPayment(ctx context.Context, req *appv1.CreateA
|
||||
return nil, status.Error(codes.Internal, "Failed to create payment")
|
||||
}
|
||||
|
||||
payload, err := s.buildAdminPayment(ctx, paymentRecord)
|
||||
payload, err := s.buildAdminPayment(ctx, resultValue.Payment)
|
||||
if err != nil {
|
||||
return nil, status.Error(codes.Internal, "Failed to create payment")
|
||||
}
|
||||
return &appv1.CreateAdminPaymentResponse{
|
||||
Payment: payload,
|
||||
Subscription: toProtoPlanSubscription(subscription),
|
||||
WalletBalance: walletBalance,
|
||||
InvoiceId: invoiceID,
|
||||
Subscription: toProtoPlanSubscription(resultValue.Subscription),
|
||||
WalletBalance: resultValue.WalletBalance,
|
||||
InvoiceId: resultValue.InvoiceID,
|
||||
}, nil
|
||||
}
|
||||
func (s *appServices) UpdateAdminPayment(ctx context.Context, req *appv1.UpdateAdminPaymentRequest) (*appv1.UpdateAdminPaymentResponse, error) {
|
||||
@@ -554,7 +399,7 @@ func (s *appServices) CreateAdminAdTemplate(ctx context.Context, req *appv1.Crea
|
||||
Name: strings.TrimSpace(req.GetName()),
|
||||
Description: nullableTrimmedStringPtr(req.Description),
|
||||
VastTagURL: strings.TrimSpace(req.GetVastTagUrl()),
|
||||
AdFormat: model.StringPtr(normalizeAdminAdFormatValue(req.GetAdFormat())),
|
||||
AdFormat: model.StringPtr(normalizeAdFormat(req.GetAdFormat())),
|
||||
Duration: duration,
|
||||
IsActive: model.BoolPtr(req.GetIsActive()),
|
||||
IsDefault: req.GetIsDefault(),
|
||||
@@ -614,7 +459,7 @@ func (s *appServices) UpdateAdminAdTemplate(ctx context.Context, req *appv1.Upda
|
||||
item.Name = strings.TrimSpace(req.GetName())
|
||||
item.Description = nullableTrimmedStringPtr(req.Description)
|
||||
item.VastTagURL = strings.TrimSpace(req.GetVastTagUrl())
|
||||
item.AdFormat = model.StringPtr(normalizeAdminAdFormatValue(req.GetAdFormat()))
|
||||
item.AdFormat = model.StringPtr(normalizeAdFormat(req.GetAdFormat()))
|
||||
item.Duration = duration
|
||||
item.IsActive = model.BoolPtr(req.GetIsActive())
|
||||
item.IsDefault = req.GetIsDefault()
|
||||
@@ -650,7 +495,7 @@ func (s *appServices) DeleteAdminAdTemplate(ctx context.Context, req *appv1.Dele
|
||||
}
|
||||
|
||||
err := s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||
if err := tx.Where("ad_template_id = ?", id).Delete(&model.VideoAdConfig{}).Error; err != nil {
|
||||
if err := tx.Model(&model.Video{}).Where("ad_id = ?", id).Update("ad_id", nil).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
res := tx.Where("id = ?", id).Delete(&model.AdTemplate{})
|
||||
@@ -670,3 +515,221 @@ func (s *appServices) DeleteAdminAdTemplate(ctx context.Context, req *appv1.Dele
|
||||
}
|
||||
return &appv1.MessageResponse{Message: "Ad template deleted"}, nil
|
||||
}
|
||||
|
||||
func (s *appServices) ListAdminPlayerConfigs(ctx context.Context, req *appv1.ListAdminPlayerConfigsRequest) (*appv1.ListAdminPlayerConfigsResponse, error) {
|
||||
if _, err := s.requireAdmin(ctx); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
page, limit, offset := adminPageLimitOffset(req.GetPage(), req.GetLimit())
|
||||
limitInt := int(limit)
|
||||
search := strings.TrimSpace(protoStringValue(req.Search))
|
||||
userID := strings.TrimSpace(protoStringValue(req.UserId))
|
||||
|
||||
db := s.db.WithContext(ctx).Model(&model.PlayerConfig{})
|
||||
if search != "" {
|
||||
like := "%" + search + "%"
|
||||
db = db.Where("name ILIKE ?", like)
|
||||
}
|
||||
if userID != "" {
|
||||
db = db.Where("user_id = ?", userID)
|
||||
}
|
||||
|
||||
var total int64
|
||||
if err := db.Count(&total).Error; err != nil {
|
||||
return nil, status.Error(codes.Internal, "Failed to list player configs")
|
||||
}
|
||||
|
||||
var configs []model.PlayerConfig
|
||||
if err := db.Order("created_at DESC").Offset(offset).Limit(limitInt).Find(&configs).Error; err != nil {
|
||||
return nil, status.Error(codes.Internal, "Failed to list player configs")
|
||||
}
|
||||
|
||||
items := make([]*appv1.AdminPlayerConfig, 0, len(configs))
|
||||
for i := range configs {
|
||||
payload, err := s.buildAdminPlayerConfig(ctx, &configs[i])
|
||||
if err != nil {
|
||||
return nil, status.Error(codes.Internal, "Failed to list player configs")
|
||||
}
|
||||
items = append(items, payload)
|
||||
}
|
||||
|
||||
return &appv1.ListAdminPlayerConfigsResponse{
|
||||
Configs: items,
|
||||
Total: total,
|
||||
Page: page,
|
||||
Limit: limit,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (s *appServices) GetAdminPlayerConfig(ctx context.Context, req *appv1.GetAdminPlayerConfigRequest) (*appv1.GetAdminPlayerConfigResponse, error) {
|
||||
if _, err := s.requireAdmin(ctx); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
id := strings.TrimSpace(req.GetId())
|
||||
if id == "" {
|
||||
return nil, status.Error(codes.NotFound, "Player config not found")
|
||||
}
|
||||
|
||||
var item model.PlayerConfig
|
||||
if err := s.db.WithContext(ctx).Where("id = ?", id).First(&item).Error; err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return nil, status.Error(codes.NotFound, "Player config not found")
|
||||
}
|
||||
return nil, status.Error(codes.Internal, "Failed to load player config")
|
||||
}
|
||||
|
||||
payload, err := s.buildAdminPlayerConfig(ctx, &item)
|
||||
if err != nil {
|
||||
return nil, status.Error(codes.Internal, "Failed to load player config")
|
||||
}
|
||||
return &appv1.GetAdminPlayerConfigResponse{Config: payload}, nil
|
||||
}
|
||||
|
||||
func (s *appServices) CreateAdminPlayerConfig(ctx context.Context, req *appv1.CreateAdminPlayerConfigRequest) (*appv1.CreateAdminPlayerConfigResponse, error) {
|
||||
if _, err := s.requireAdmin(ctx); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if msg := validateAdminPlayerConfigInput(req.GetUserId(), req.GetName()); msg != "" {
|
||||
return nil, status.Error(codes.InvalidArgument, msg)
|
||||
}
|
||||
|
||||
var user model.User
|
||||
if err := s.db.WithContext(ctx).Where("id = ?", strings.TrimSpace(req.GetUserId())).First(&user).Error; err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return nil, status.Error(codes.InvalidArgument, "User not found")
|
||||
}
|
||||
return nil, status.Error(codes.Internal, "Failed to save player config")
|
||||
}
|
||||
|
||||
item := &model.PlayerConfig{
|
||||
ID: uuid.New().String(),
|
||||
UserID: user.ID,
|
||||
Name: strings.TrimSpace(req.GetName()),
|
||||
Description: nullableTrimmedStringPtr(req.Description),
|
||||
Autoplay: req.GetAutoplay(),
|
||||
Loop: req.GetLoop(),
|
||||
Muted: req.GetMuted(),
|
||||
ShowControls: model.BoolPtr(req.GetShowControls()),
|
||||
Pip: model.BoolPtr(req.GetPip()),
|
||||
Airplay: model.BoolPtr(req.GetAirplay()),
|
||||
Chromecast: model.BoolPtr(req.GetChromecast()),
|
||||
IsActive: model.BoolPtr(req.GetIsActive()),
|
||||
IsDefault: req.GetIsDefault(),
|
||||
EncrytionM3u8: model.BoolPtr(req.EncrytionM3U8 == nil || *req.EncrytionM3U8),
|
||||
LogoURL: nullableTrimmedStringPtr(req.LogoUrl),
|
||||
}
|
||||
if !boolValue(item.IsActive) {
|
||||
item.IsDefault = false
|
||||
}
|
||||
|
||||
if err := s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||
if item.IsDefault {
|
||||
if err := s.unsetAdminDefaultPlayerConfigs(ctx, tx, item.UserID, ""); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return tx.Create(item).Error
|
||||
}); err != nil {
|
||||
return nil, status.Error(codes.Internal, "Failed to save player config")
|
||||
}
|
||||
|
||||
payload, err := s.buildAdminPlayerConfig(ctx, item)
|
||||
if err != nil {
|
||||
return nil, status.Error(codes.Internal, "Failed to save player config")
|
||||
}
|
||||
return &appv1.CreateAdminPlayerConfigResponse{Config: payload}, nil
|
||||
}
|
||||
|
||||
func (s *appServices) UpdateAdminPlayerConfig(ctx context.Context, req *appv1.UpdateAdminPlayerConfigRequest) (*appv1.UpdateAdminPlayerConfigResponse, error) {
|
||||
if _, err := s.requireAdmin(ctx); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
id := strings.TrimSpace(req.GetId())
|
||||
if id == "" {
|
||||
return nil, status.Error(codes.NotFound, "Player config not found")
|
||||
}
|
||||
|
||||
if msg := validateAdminPlayerConfigInput(req.GetUserId(), req.GetName()); msg != "" {
|
||||
return nil, status.Error(codes.InvalidArgument, msg)
|
||||
}
|
||||
|
||||
var user model.User
|
||||
if err := s.db.WithContext(ctx).Where("id = ?", strings.TrimSpace(req.GetUserId())).First(&user).Error; err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return nil, status.Error(codes.InvalidArgument, "User not found")
|
||||
}
|
||||
return nil, status.Error(codes.Internal, "Failed to save player config")
|
||||
}
|
||||
|
||||
var item model.PlayerConfig
|
||||
if err := s.db.WithContext(ctx).Where("id = ?", id).First(&item).Error; err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return nil, status.Error(codes.NotFound, "Player config not found")
|
||||
}
|
||||
return nil, status.Error(codes.Internal, "Failed to save player config")
|
||||
}
|
||||
|
||||
item.UserID = user.ID
|
||||
item.Name = strings.TrimSpace(req.GetName())
|
||||
item.Description = nullableTrimmedStringPtr(req.Description)
|
||||
item.Autoplay = req.GetAutoplay()
|
||||
item.Loop = req.GetLoop()
|
||||
item.Muted = req.GetMuted()
|
||||
item.ShowControls = model.BoolPtr(req.GetShowControls())
|
||||
item.Pip = model.BoolPtr(req.GetPip())
|
||||
item.Airplay = model.BoolPtr(req.GetAirplay())
|
||||
item.Chromecast = model.BoolPtr(req.GetChromecast())
|
||||
item.IsActive = model.BoolPtr(req.GetIsActive())
|
||||
item.IsDefault = req.GetIsDefault()
|
||||
if req.EncrytionM3U8 != nil {
|
||||
item.EncrytionM3u8 = model.BoolPtr(*req.EncrytionM3U8)
|
||||
}
|
||||
if req.LogoUrl != nil {
|
||||
item.LogoURL = nullableTrimmedStringPtr(req.LogoUrl)
|
||||
}
|
||||
if !boolValue(item.IsActive) {
|
||||
item.IsDefault = false
|
||||
}
|
||||
|
||||
if err := s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||
if item.IsDefault {
|
||||
if err := s.unsetAdminDefaultPlayerConfigs(ctx, tx, item.UserID, item.ID); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return tx.Save(&item).Error
|
||||
}); err != nil {
|
||||
return nil, status.Error(codes.Internal, "Failed to save player config")
|
||||
}
|
||||
|
||||
payload, err := s.buildAdminPlayerConfig(ctx, &item)
|
||||
if err != nil {
|
||||
return nil, status.Error(codes.Internal, "Failed to save player config")
|
||||
}
|
||||
return &appv1.UpdateAdminPlayerConfigResponse{Config: payload}, nil
|
||||
}
|
||||
|
||||
func (s *appServices) DeleteAdminPlayerConfig(ctx context.Context, req *appv1.DeleteAdminPlayerConfigRequest) (*appv1.MessageResponse, error) {
|
||||
if _, err := s.requireAdmin(ctx); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
id := strings.TrimSpace(req.GetId())
|
||||
if id == "" {
|
||||
return nil, status.Error(codes.NotFound, "Player config not found")
|
||||
}
|
||||
|
||||
res := s.db.WithContext(ctx).Where("id = ?", id).Delete(&model.PlayerConfig{})
|
||||
if res.Error != nil {
|
||||
return nil, status.Error(codes.Internal, "Failed to delete player config")
|
||||
}
|
||||
if res.RowsAffected == 0 {
|
||||
return nil, status.Error(codes.NotFound, "Player config not found")
|
||||
}
|
||||
|
||||
return &appv1.MessageResponse{Message: "Player config deleted"}, nil
|
||||
}
|
||||
|
||||
78
internal/rpc/app/service_admin_finance_catalog_test.go
Normal file
78
internal/rpc/app/service_admin_finance_catalog_test.go
Normal file
@@ -0,0 +1,78 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"google.golang.org/grpc"
|
||||
"google.golang.org/grpc/codes"
|
||||
"google.golang.org/grpc/metadata"
|
||||
"stream.api/internal/database/model"
|
||||
appv1 "stream.api/internal/gen/proto/app/v1"
|
||||
)
|
||||
|
||||
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")
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -10,36 +10,38 @@ import (
|
||||
"google.golang.org/grpc/status"
|
||||
"gorm.io/gorm"
|
||||
appv1 "stream.api/internal/gen/proto/app/v1"
|
||||
"stream.api/internal/video/runtime/services"
|
||||
"stream.api/internal/video"
|
||||
)
|
||||
|
||||
func (s *appServices) ListAdminJobs(ctx context.Context, req *appv1.ListAdminJobsRequest) (*appv1.ListAdminJobsResponse, error) {
|
||||
if _, err := s.requireAdmin(ctx); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if s.jobService == nil {
|
||||
if s.videoService == nil {
|
||||
return nil, status.Error(codes.Unavailable, "Job service is unavailable")
|
||||
}
|
||||
|
||||
agentID := strings.TrimSpace(req.GetAgentId())
|
||||
offset := int(req.GetOffset())
|
||||
limit := int(req.GetLimit())
|
||||
if offset < 0 {
|
||||
offset = 0
|
||||
}
|
||||
if limit <= 0 || limit > 100 {
|
||||
limit = 20
|
||||
}
|
||||
pageSize := int(req.GetPageSize())
|
||||
useCursorPagination := req.Cursor != nil || pageSize > 0
|
||||
|
||||
var (
|
||||
result *services.PaginatedJobs
|
||||
result *video.PaginatedJobs
|
||||
err error
|
||||
)
|
||||
if agentID := strings.TrimSpace(req.GetAgentId()); agentID != "" {
|
||||
result, err = s.jobService.ListJobsByAgent(ctx, agentID, offset, limit)
|
||||
if useCursorPagination {
|
||||
result, err = s.videoService.ListJobsByCursor(ctx, agentID, req.GetCursor(), pageSize)
|
||||
} else if agentID != "" {
|
||||
result, err = s.videoService.ListJobsByAgent(ctx, agentID, offset, limit)
|
||||
} else {
|
||||
result, err = s.jobService.ListJobs(ctx, offset, limit)
|
||||
result, err = s.videoService.ListJobs(ctx, offset, limit)
|
||||
}
|
||||
if err != nil {
|
||||
if errors.Is(err, video.ErrInvalidJobCursor) {
|
||||
return nil, status.Error(codes.InvalidArgument, "Invalid job cursor")
|
||||
}
|
||||
return nil, status.Error(codes.Internal, "Failed to list jobs")
|
||||
}
|
||||
|
||||
@@ -48,19 +50,24 @@ func (s *appServices) ListAdminJobs(ctx context.Context, req *appv1.ListAdminJob
|
||||
jobs = append(jobs, buildAdminJob(job))
|
||||
}
|
||||
|
||||
return &appv1.ListAdminJobsResponse{
|
||||
Jobs: jobs,
|
||||
Total: result.Total,
|
||||
Offset: int32(result.Offset),
|
||||
Limit: int32(result.Limit),
|
||||
HasMore: result.HasMore,
|
||||
}, nil
|
||||
response := &appv1.ListAdminJobsResponse{
|
||||
Jobs: jobs,
|
||||
Total: result.Total,
|
||||
Offset: int32(result.Offset),
|
||||
Limit: int32(result.Limit),
|
||||
HasMore: result.HasMore,
|
||||
PageSize: int32(result.PageSize),
|
||||
}
|
||||
if strings.TrimSpace(result.NextCursor) != "" {
|
||||
response.NextCursor = &result.NextCursor
|
||||
}
|
||||
return response, nil
|
||||
}
|
||||
func (s *appServices) GetAdminJob(ctx context.Context, req *appv1.GetAdminJobRequest) (*appv1.GetAdminJobResponse, error) {
|
||||
if _, err := s.requireAdmin(ctx); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if s.jobService == nil {
|
||||
if s.videoService == nil {
|
||||
return nil, status.Error(codes.Unavailable, "Job service is unavailable")
|
||||
}
|
||||
|
||||
@@ -68,7 +75,7 @@ func (s *appServices) GetAdminJob(ctx context.Context, req *appv1.GetAdminJobReq
|
||||
if id == "" {
|
||||
return nil, status.Error(codes.NotFound, "Job not found")
|
||||
}
|
||||
job, err := s.jobService.GetJob(ctx, id)
|
||||
job, err := s.videoService.GetJob(ctx, id)
|
||||
if err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return nil, status.Error(codes.NotFound, "Job not found")
|
||||
@@ -88,7 +95,7 @@ func (s *appServices) CreateAdminJob(ctx context.Context, req *appv1.CreateAdmin
|
||||
if _, err := s.requireAdmin(ctx); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if s.jobService == nil {
|
||||
if s.videoService == nil {
|
||||
return nil, status.Error(codes.Unavailable, "Job service is unavailable")
|
||||
}
|
||||
|
||||
@@ -113,7 +120,11 @@ func (s *appServices) CreateAdminJob(ctx context.Context, req *appv1.CreateAdmin
|
||||
return nil, status.Error(codes.Internal, "Failed to create job payload")
|
||||
}
|
||||
|
||||
job, err := s.jobService.CreateJob(ctx, strings.TrimSpace(req.GetUserId()), name, payload, int(req.GetPriority()), req.GetTimeLimit())
|
||||
videoID := ""
|
||||
if req.VideoId != nil {
|
||||
videoID = strings.TrimSpace(req.GetVideoId())
|
||||
}
|
||||
job, err := s.videoService.CreateJob(ctx, strings.TrimSpace(req.GetUserId()), videoID, name, payload, int(req.GetPriority()), req.GetTimeLimit())
|
||||
if err != nil {
|
||||
return nil, status.Error(codes.Internal, "Failed to create job")
|
||||
}
|
||||
@@ -123,7 +134,7 @@ func (s *appServices) CancelAdminJob(ctx context.Context, req *appv1.CancelAdmin
|
||||
if _, err := s.requireAdmin(ctx); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if s.jobService == nil {
|
||||
if s.videoService == nil {
|
||||
return nil, status.Error(codes.Unavailable, "Job service is unavailable")
|
||||
}
|
||||
|
||||
@@ -131,7 +142,7 @@ func (s *appServices) CancelAdminJob(ctx context.Context, req *appv1.CancelAdmin
|
||||
if id == "" {
|
||||
return nil, status.Error(codes.NotFound, "Job not found")
|
||||
}
|
||||
if err := s.jobService.CancelJob(ctx, id); err != nil {
|
||||
if err := s.videoService.CancelJob(ctx, id); err != nil {
|
||||
if strings.Contains(strings.ToLower(err.Error()), "not found") {
|
||||
return nil, status.Error(codes.NotFound, "Job not found")
|
||||
}
|
||||
@@ -143,7 +154,7 @@ func (s *appServices) RetryAdminJob(ctx context.Context, req *appv1.RetryAdminJo
|
||||
if _, err := s.requireAdmin(ctx); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if s.jobService == nil {
|
||||
if s.videoService == nil {
|
||||
return nil, status.Error(codes.Unavailable, "Job service is unavailable")
|
||||
}
|
||||
|
||||
@@ -151,7 +162,7 @@ func (s *appServices) RetryAdminJob(ctx context.Context, req *appv1.RetryAdminJo
|
||||
if id == "" {
|
||||
return nil, status.Error(codes.NotFound, "Job not found")
|
||||
}
|
||||
job, err := s.jobService.RetryJob(ctx, id)
|
||||
job, err := s.videoService.RetryJob(ctx, id)
|
||||
if err != nil {
|
||||
if strings.Contains(strings.ToLower(err.Error()), "not found") {
|
||||
return nil, status.Error(codes.NotFound, "Job not found")
|
||||
|
||||
195
internal/rpc/app/service_admin_jobs_agents_test.go
Normal file
195
internal/rpc/app/service_admin_jobs_agents_test.go
Normal file
@@ -0,0 +1,195 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"google.golang.org/grpc/codes"
|
||||
"gorm.io/gorm"
|
||||
"stream.api/internal/database/model"
|
||||
appv1 "stream.api/internal/gen/proto/app/v1"
|
||||
"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 }
|
||||
@@ -13,6 +13,7 @@ import (
|
||||
"gorm.io/gorm"
|
||||
"stream.api/internal/database/model"
|
||||
appv1 "stream.api/internal/gen/proto/app/v1"
|
||||
"stream.api/internal/video"
|
||||
)
|
||||
|
||||
func (s *appServices) GetAdminDashboard(ctx context.Context, _ *appv1.GetAdminDashboardRequest) (*appv1.GetAdminDashboardResponse, error) {
|
||||
@@ -95,20 +96,19 @@ func (s *appServices) GetAdminUser(ctx context.Context, req *appv1.GetAdminUserR
|
||||
return nil, status.Error(codes.Internal, "Failed to get user")
|
||||
}
|
||||
|
||||
payload, err := s.buildAdminUser(ctx, &user)
|
||||
if err != nil {
|
||||
return nil, status.Error(codes.Internal, "Failed to get user")
|
||||
}
|
||||
|
||||
var subscription model.PlanSubscription
|
||||
var subscriptionPayload *appv1.PlanSubscription
|
||||
if err := s.db.WithContext(ctx).Where("user_id = ?", id).Order("created_at DESC").First(&subscription).Error; err == nil {
|
||||
subscriptionPayload = toProtoPlanSubscription(&subscription)
|
||||
var subscription *model.PlanSubscription
|
||||
var subscriptionRecord model.PlanSubscription
|
||||
if err := s.db.WithContext(ctx).Where("user_id = ?", id).Order("created_at DESC").First(&subscriptionRecord).Error; err == nil {
|
||||
subscription = &subscriptionRecord
|
||||
} else if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return nil, status.Error(codes.Internal, "Failed to get user")
|
||||
}
|
||||
|
||||
return &appv1.GetAdminUserResponse{User: &appv1.AdminUserDetail{User: payload, Subscription: subscriptionPayload}}, nil
|
||||
detail, err := s.buildAdminUserDetail(ctx, &user, subscription)
|
||||
if err != nil {
|
||||
return nil, status.Error(codes.Internal, "Failed to get user")
|
||||
}
|
||||
return &appv1.GetAdminUserResponse{User: detail}, nil
|
||||
}
|
||||
func (s *appServices) CreateAdminUser(ctx context.Context, req *appv1.CreateAdminUserRequest) (*appv1.CreateAdminUserResponse, error) {
|
||||
if _, err := s.requireAdmin(ctx); err != nil {
|
||||
@@ -243,6 +243,89 @@ func (s *appServices) UpdateAdminUser(ctx context.Context, req *appv1.UpdateAdmi
|
||||
}
|
||||
return &appv1.UpdateAdminUserResponse{User: payload}, nil
|
||||
}
|
||||
func (s *appServices) UpdateAdminUserReferralSettings(ctx context.Context, req *appv1.UpdateAdminUserReferralSettingsRequest) (*appv1.UpdateAdminUserReferralSettingsResponse, error) {
|
||||
if _, err := s.requireAdmin(ctx); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
id := strings.TrimSpace(req.GetId())
|
||||
if id == "" {
|
||||
return nil, status.Error(codes.NotFound, "User not found")
|
||||
}
|
||||
if req.ClearReferrer != nil && req.GetClearReferrer() && req.RefUsername != nil && strings.TrimSpace(req.GetRefUsername()) != "" {
|
||||
return nil, status.Error(codes.InvalidArgument, "Cannot set and clear referrer at the same time")
|
||||
}
|
||||
if req.ClearReferralRewardBps != nil && req.GetClearReferralRewardBps() && req.ReferralRewardBps != nil {
|
||||
return nil, status.Error(codes.InvalidArgument, "Cannot set and clear referral reward override at the same time")
|
||||
}
|
||||
if req.ReferralRewardBps != nil {
|
||||
bps := req.GetReferralRewardBps()
|
||||
if bps < 0 || bps > 10000 {
|
||||
return nil, status.Error(codes.InvalidArgument, "Referral reward bps must be between 0 and 10000")
|
||||
}
|
||||
}
|
||||
|
||||
var user model.User
|
||||
if err := s.db.WithContext(ctx).Where("id = ?", id).First(&user).Error; err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return nil, status.Error(codes.NotFound, "User not found")
|
||||
}
|
||||
return nil, status.Error(codes.Internal, "Failed to update referral settings")
|
||||
}
|
||||
|
||||
updates := map[string]any{}
|
||||
if req.RefUsername != nil || (req.ClearReferrer != nil && req.GetClearReferrer()) {
|
||||
if referralRewardProcessed(&user) {
|
||||
return nil, status.Error(codes.InvalidArgument, "Cannot change referrer after reward has been granted")
|
||||
}
|
||||
if req.ClearReferrer != nil && req.GetClearReferrer() {
|
||||
updates["referred_by_user_id"] = nil
|
||||
} else if req.RefUsername != nil {
|
||||
referrer, err := s.loadReferralUserByUsernameStrict(ctx, req.GetRefUsername())
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if referrer.ID == user.ID {
|
||||
return nil, status.Error(codes.InvalidArgument, "User cannot refer themselves")
|
||||
}
|
||||
updates["referred_by_user_id"] = referrer.ID
|
||||
}
|
||||
}
|
||||
if req.ReferralEligible != nil {
|
||||
updates["referral_eligible"] = req.GetReferralEligible()
|
||||
}
|
||||
if req.ClearReferralRewardBps != nil && req.GetClearReferralRewardBps() {
|
||||
updates["referral_reward_bps"] = nil
|
||||
} else if req.ReferralRewardBps != nil {
|
||||
updates["referral_reward_bps"] = req.GetReferralRewardBps()
|
||||
}
|
||||
|
||||
if len(updates) > 0 {
|
||||
result := s.db.WithContext(ctx).Model(&model.User{}).Where("id = ?", id).Updates(updates)
|
||||
if result.Error != nil {
|
||||
return nil, status.Error(codes.Internal, "Failed to update referral settings")
|
||||
}
|
||||
if result.RowsAffected == 0 {
|
||||
return nil, status.Error(codes.NotFound, "User not found")
|
||||
}
|
||||
}
|
||||
|
||||
if err := s.db.WithContext(ctx).Where("id = ?", id).First(&user).Error; err != nil {
|
||||
return nil, status.Error(codes.Internal, "Failed to update referral settings")
|
||||
}
|
||||
var subscription *model.PlanSubscription
|
||||
var subscriptionRecord model.PlanSubscription
|
||||
if err := s.db.WithContext(ctx).Where("user_id = ?", id).Order("created_at DESC").First(&subscriptionRecord).Error; err == nil {
|
||||
subscription = &subscriptionRecord
|
||||
} else if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return nil, status.Error(codes.Internal, "Failed to update referral settings")
|
||||
}
|
||||
payload, err := s.buildAdminUserDetail(ctx, &user, subscription)
|
||||
if err != nil {
|
||||
return nil, status.Error(codes.Internal, "Failed to update referral settings")
|
||||
}
|
||||
return &appv1.UpdateAdminUserReferralSettingsResponse{User: payload}, nil
|
||||
}
|
||||
func (s *appServices) UpdateAdminUserRole(ctx context.Context, req *appv1.UpdateAdminUserRoleRequest) (*appv1.UpdateAdminUserRoleResponse, error) {
|
||||
adminResult, err := s.requireAdmin(ctx)
|
||||
if err != nil {
|
||||
@@ -299,7 +382,6 @@ func (s *appServices) DeleteAdminUser(ctx context.Context, req *appv1.DeleteAdmi
|
||||
model interface{}
|
||||
where string
|
||||
}{
|
||||
{&model.VideoAdConfig{}, "user_id = ?"},
|
||||
{&model.AdTemplate{}, "user_id = ?"},
|
||||
{&model.Notification{}, "user_id = ?"},
|
||||
{&model.Domain{}, "user_id = ?"},
|
||||
@@ -395,6 +477,9 @@ func (s *appServices) CreateAdminVideo(ctx context.Context, req *appv1.CreateAdm
|
||||
if _, err := s.requireAdmin(ctx); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if s.videoService == nil {
|
||||
return nil, status.Error(codes.Unavailable, "Job service is unavailable")
|
||||
}
|
||||
|
||||
userID := strings.TrimSpace(req.GetUserId())
|
||||
title := strings.TrimSpace(req.GetTitle())
|
||||
@@ -406,49 +491,30 @@ func (s *appServices) CreateAdminVideo(ctx context.Context, req *appv1.CreateAdm
|
||||
return nil, status.Error(codes.InvalidArgument, "Size must be greater than or equal to 0")
|
||||
}
|
||||
|
||||
var user model.User
|
||||
if err := s.db.WithContext(ctx).Where("id = ?", userID).First(&user).Error; err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return nil, status.Error(codes.InvalidArgument, "User not found")
|
||||
}
|
||||
return nil, status.Error(codes.Internal, "Failed to create video")
|
||||
}
|
||||
|
||||
statusValue := normalizeVideoStatusValue(req.GetStatus())
|
||||
processingStatus := strings.ToUpper(statusValue)
|
||||
storageType := detectStorageType(videoURL)
|
||||
video := &model.Video{
|
||||
ID: uuid.New().String(),
|
||||
UserID: user.ID,
|
||||
Name: title,
|
||||
Title: title,
|
||||
Description: nullableTrimmedString(req.Description),
|
||||
URL: videoURL,
|
||||
Size: req.GetSize(),
|
||||
Duration: req.GetDuration(),
|
||||
Format: strings.TrimSpace(req.GetFormat()),
|
||||
Status: model.StringPtr(statusValue),
|
||||
ProcessingStatus: model.StringPtr(processingStatus),
|
||||
StorageType: model.StringPtr(storageType),
|
||||
}
|
||||
|
||||
err := s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||
if err := tx.Create(video).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if err := tx.Model(&model.User{}).Where("id = ?", user.ID).UpdateColumn("storage_used", gorm.Expr("storage_used + ?", video.Size)).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
return s.saveAdminVideoAdConfig(ctx, tx, video.ID, user.ID, nullableTrimmedString(req.AdTemplateId))
|
||||
created, err := s.videoService.CreateVideo(ctx, video.CreateVideoInput{
|
||||
UserID: userID,
|
||||
Title: title,
|
||||
Description: req.Description,
|
||||
URL: videoURL,
|
||||
Size: req.GetSize(),
|
||||
Duration: req.GetDuration(),
|
||||
Format: strings.TrimSpace(req.GetFormat()),
|
||||
AdTemplateID: nullableTrimmedString(req.AdTemplateId),
|
||||
})
|
||||
if err != nil {
|
||||
if strings.Contains(err.Error(), "Ad template not found") {
|
||||
switch {
|
||||
case errors.Is(err, video.ErrUserNotFound):
|
||||
return nil, status.Error(codes.InvalidArgument, "User not found")
|
||||
case errors.Is(err, video.ErrAdTemplateNotFound):
|
||||
return nil, status.Error(codes.InvalidArgument, "Ad template not found")
|
||||
case errors.Is(err, video.ErrJobServiceUnavailable):
|
||||
return nil, status.Error(codes.Unavailable, "Job service is unavailable")
|
||||
default:
|
||||
return nil, status.Error(codes.Internal, "Failed to create video")
|
||||
}
|
||||
return nil, status.Error(codes.Internal, "Failed to create video")
|
||||
}
|
||||
|
||||
payload, err := s.buildAdminVideo(ctx, video)
|
||||
payload, err := s.buildAdminVideo(ctx, created.Video)
|
||||
if err != nil {
|
||||
return nil, status.Error(codes.Internal, "Failed to create video")
|
||||
}
|
||||
@@ -524,12 +590,7 @@ func (s *appServices) UpdateAdminVideo(ctx context.Context, req *appv1.UpdateAdm
|
||||
return err
|
||||
}
|
||||
}
|
||||
if oldUserID != user.ID {
|
||||
if err := tx.Model(&model.VideoAdConfig{}).Where("video_id = ?", video.ID).Update("user_id", user.ID).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return s.saveAdminVideoAdConfig(ctx, tx, video.ID, user.ID, nullableTrimmedString(req.AdTemplateId))
|
||||
return s.saveAdminVideoAdConfig(ctx, tx, &video, user.ID, nullableTrimmedString(req.AdTemplateId))
|
||||
})
|
||||
if err != nil {
|
||||
if strings.Contains(err.Error(), "Ad template not found") {
|
||||
@@ -563,9 +624,6 @@ func (s *appServices) DeleteAdminVideo(ctx context.Context, req *appv1.DeleteAdm
|
||||
}
|
||||
|
||||
err := s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||
if err := tx.Where("video_id = ?", video.ID).Delete(&model.VideoAdConfig{}).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if err := tx.Where("id = ?", video.ID).Delete(&model.Video{}).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -14,7 +14,6 @@ import (
|
||||
"google.golang.org/grpc/codes"
|
||||
"google.golang.org/grpc/status"
|
||||
"gorm.io/gorm"
|
||||
authapi "stream.api/internal/api/auth"
|
||||
"stream.api/internal/database/model"
|
||||
"stream.api/internal/database/query"
|
||||
appv1 "stream.api/internal/gen/proto/app/v1"
|
||||
@@ -43,7 +42,7 @@ func (s *appServices) Login(ctx context.Context, req *appv1.LoginRequest) (*appv
|
||||
return nil, err
|
||||
}
|
||||
|
||||
payload, err := authapi.BuildUserPayload(ctx, s.db, user)
|
||||
payload, err := buildUserPayload(ctx, s.db, user)
|
||||
if err != nil {
|
||||
return nil, status.Error(codes.Internal, "Failed to build user payload")
|
||||
}
|
||||
@@ -53,6 +52,7 @@ func (s *appServices) Register(ctx context.Context, req *appv1.RegisterRequest)
|
||||
email := strings.TrimSpace(req.GetEmail())
|
||||
username := strings.TrimSpace(req.GetUsername())
|
||||
password := req.GetPassword()
|
||||
refUsername := strings.TrimSpace(req.GetRefUsername())
|
||||
if email == "" || username == "" || password == "" {
|
||||
return nil, status.Error(codes.InvalidArgument, "Username, email and password are required")
|
||||
}
|
||||
@@ -72,21 +72,29 @@ func (s *appServices) Register(ctx context.Context, req *appv1.RegisterRequest)
|
||||
return nil, status.Error(codes.Internal, "Failed to register")
|
||||
}
|
||||
|
||||
referrerID, err := s.resolveSignupReferrerID(ctx, refUsername, username)
|
||||
if err != nil {
|
||||
s.logger.Error("Failed to resolve signup referrer", "error", err)
|
||||
return nil, status.Error(codes.Internal, "Failed to register")
|
||||
}
|
||||
|
||||
role := "USER"
|
||||
passwordHash := string(hashedPassword)
|
||||
newUser := &model.User{
|
||||
ID: uuid.New().String(),
|
||||
Email: email,
|
||||
Password: &passwordHash,
|
||||
Username: &username,
|
||||
Role: &role,
|
||||
ID: uuid.New().String(),
|
||||
Email: email,
|
||||
Password: &passwordHash,
|
||||
Username: &username,
|
||||
Role: &role,
|
||||
ReferredByUserID: referrerID,
|
||||
ReferralEligible: model.BoolPtr(true),
|
||||
}
|
||||
if err := u.WithContext(ctx).Create(newUser); err != nil {
|
||||
s.logger.Error("Failed to create user", "error", err)
|
||||
return nil, status.Error(codes.Internal, "Failed to register")
|
||||
}
|
||||
|
||||
payload, err := authapi.BuildUserPayload(ctx, s.db, newUser)
|
||||
payload, err := buildUserPayload(ctx, s.db, newUser)
|
||||
if err != nil {
|
||||
return nil, status.Error(codes.Internal, "Failed to build user payload")
|
||||
}
|
||||
@@ -216,7 +224,7 @@ func (s *appServices) CompleteGoogleLogin(ctx context.Context, req *appv1.Comple
|
||||
}
|
||||
|
||||
client := s.googleOauth.Client(ctx, tokenResp)
|
||||
resp, err := client.Get("https://www.googleapis.com/oauth2/v2/userinfo")
|
||||
resp, err := client.Get(s.googleUserInfoURL)
|
||||
if err != nil {
|
||||
s.logger.Error("Failed to fetch Google user info", "error", err)
|
||||
return nil, status.Error(codes.Unauthenticated, "userinfo_failed")
|
||||
@@ -240,6 +248,7 @@ func (s *appServices) CompleteGoogleLogin(ctx context.Context, req *appv1.Comple
|
||||
}
|
||||
|
||||
email := strings.TrimSpace(strings.ToLower(googleUser.Email))
|
||||
refUsername := strings.TrimSpace(req.GetRefUsername())
|
||||
if email == "" {
|
||||
return nil, status.Error(codes.InvalidArgument, "missing_email")
|
||||
}
|
||||
@@ -251,14 +260,21 @@ func (s *appServices) CompleteGoogleLogin(ctx context.Context, req *appv1.Comple
|
||||
s.logger.Error("Failed to load Google user", "error", err)
|
||||
return nil, status.Error(codes.Internal, "load_user_failed")
|
||||
}
|
||||
referrerID, resolveErr := s.resolveSignupReferrerID(ctx, refUsername, googleUser.Name)
|
||||
if resolveErr != nil {
|
||||
s.logger.Error("Failed to resolve Google signup referrer", "error", resolveErr)
|
||||
return nil, status.Error(codes.Internal, "create_user_failed")
|
||||
}
|
||||
role := "USER"
|
||||
user = &model.User{
|
||||
ID: uuid.New().String(),
|
||||
Email: email,
|
||||
Username: stringPointerOrNil(googleUser.Name),
|
||||
GoogleID: stringPointerOrNil(googleUser.ID),
|
||||
Avatar: stringPointerOrNil(googleUser.Picture),
|
||||
Role: &role,
|
||||
ID: uuid.New().String(),
|
||||
Email: email,
|
||||
Username: stringPointerOrNil(googleUser.Name),
|
||||
GoogleID: stringPointerOrNil(googleUser.ID),
|
||||
Avatar: stringPointerOrNil(googleUser.Picture),
|
||||
Role: &role,
|
||||
ReferredByUserID: referrerID,
|
||||
ReferralEligible: model.BoolPtr(true),
|
||||
}
|
||||
if err := u.WithContext(ctx).Create(user); err != nil {
|
||||
s.logger.Error("Failed to create Google user", "error", err)
|
||||
@@ -292,7 +308,7 @@ func (s *appServices) CompleteGoogleLogin(ctx context.Context, req *appv1.Comple
|
||||
return nil, status.Error(codes.Internal, "session_failed")
|
||||
}
|
||||
|
||||
payload, err := authapi.BuildUserPayload(ctx, s.db, user)
|
||||
payload, err := buildUserPayload(ctx, s.db, user)
|
||||
if err != nil {
|
||||
return nil, status.Error(codes.Internal, "Failed to build user payload")
|
||||
}
|
||||
|
||||
@@ -7,10 +7,10 @@ import (
|
||||
"golang.org/x/oauth2/google"
|
||||
"gorm.io/gorm"
|
||||
"stream.api/internal/config"
|
||||
"stream.api/internal/database/model"
|
||||
appv1 "stream.api/internal/gen/proto/app/v1"
|
||||
"stream.api/internal/middleware"
|
||||
videogrpc "stream.api/internal/video/runtime/grpc"
|
||||
"stream.api/internal/video/runtime/services"
|
||||
"stream.api/internal/video"
|
||||
"stream.api/pkg/cache"
|
||||
"stream.api/pkg/logger"
|
||||
"stream.api/pkg/storage"
|
||||
@@ -18,14 +18,22 @@ import (
|
||||
)
|
||||
|
||||
const adTemplateUpgradeRequiredMessage = "Upgrade required to manage Ads & VAST"
|
||||
const defaultGoogleUserInfoURL = "https://www.googleapis.com/oauth2/v2/userinfo"
|
||||
|
||||
const (
|
||||
playerConfigFreePlanLimitMessage = "Free plan supports only 1 player config"
|
||||
playerConfigFreePlanReconciliationMessage = "Delete extra player configs to continue managing player configs on the free plan"
|
||||
)
|
||||
|
||||
const (
|
||||
walletTransactionTypeTopup = "topup"
|
||||
walletTransactionTypeSubscriptionDebit = "subscription_debit"
|
||||
walletTransactionTypeReferralReward = "referral_reward"
|
||||
paymentMethodWallet = "wallet"
|
||||
paymentMethodTopup = "topup"
|
||||
paymentKindSubscription = "subscription"
|
||||
paymentKindWalletTopup = "wallet_topup"
|
||||
defaultReferralRewardBps = int32(500)
|
||||
)
|
||||
|
||||
var allowedTermMonths = map[int32]struct{}{
|
||||
@@ -43,6 +51,7 @@ type Services struct {
|
||||
NotificationsServiceServer
|
||||
DomainsServiceServer
|
||||
AdTemplatesServiceServer
|
||||
PlayerConfigsServiceServer
|
||||
PlansServiceServer
|
||||
PaymentsServiceServer
|
||||
VideosServiceServer
|
||||
@@ -57,6 +66,7 @@ type appServices struct {
|
||||
appv1.UnimplementedNotificationsServiceServer
|
||||
appv1.UnimplementedDomainsServiceServer
|
||||
appv1.UnimplementedAdTemplatesServiceServer
|
||||
appv1.UnimplementedPlayerConfigsServiceServer
|
||||
appv1.UnimplementedPlansServiceServer
|
||||
appv1.UnimplementedPaymentsServiceServer
|
||||
appv1.UnimplementedVideosServiceServer
|
||||
@@ -68,23 +78,12 @@ type appServices struct {
|
||||
tokenProvider token.Provider
|
||||
cache cache.Cache
|
||||
storageProvider storage.Provider
|
||||
jobService *services.JobService
|
||||
agentRuntime *videogrpc.Server
|
||||
googleOauth *oauth2.Config
|
||||
googleStateTTL time.Duration
|
||||
}
|
||||
|
||||
type paymentRow struct {
|
||||
ID string `gorm:"column:id"`
|
||||
Amount float64 `gorm:"column:amount"`
|
||||
Currency *string `gorm:"column:currency"`
|
||||
Status *string `gorm:"column:status"`
|
||||
PlanID *string `gorm:"column:plan_id"`
|
||||
PlanName *string `gorm:"column:plan_name"`
|
||||
TermMonths *int32 `gorm:"column:term_months"`
|
||||
PaymentMethod *string `gorm:"column:payment_method"`
|
||||
ExpiresAt *time.Time `gorm:"column:expires_at"`
|
||||
CreatedAt *time.Time `gorm:"column:created_at"`
|
||||
videoService *video.Service
|
||||
agentRuntime video.AgentRuntime
|
||||
googleOauth *oauth2.Config
|
||||
googleStateTTL time.Duration
|
||||
googleUserInfoURL string
|
||||
frontendBaseURL string
|
||||
}
|
||||
|
||||
type paymentInvoiceDetails struct {
|
||||
@@ -96,13 +95,33 @@ type paymentInvoiceDetails struct {
|
||||
TopupAmount float64
|
||||
}
|
||||
|
||||
type apiErrorBody struct {
|
||||
Code int `json:"code"`
|
||||
Message string `json:"message"`
|
||||
Data interface{} `json:"data,omitempty"`
|
||||
type paymentExecutionInput struct {
|
||||
UserID string
|
||||
Plan *model.Plan
|
||||
TermMonths int32
|
||||
PaymentMethod string
|
||||
TopupAmount *float64
|
||||
}
|
||||
|
||||
func NewServices(c cache.Cache, t token.Provider, db *gorm.DB, l logger.Logger, cfg *config.Config, jobService *services.JobService, agentRuntime *videogrpc.Server) *Services {
|
||||
type paymentExecutionResult struct {
|
||||
Payment *model.Payment
|
||||
Subscription *model.PlanSubscription
|
||||
WalletBalance float64
|
||||
InvoiceID string
|
||||
}
|
||||
|
||||
type referralRewardResult struct {
|
||||
Granted bool
|
||||
Amount float64
|
||||
}
|
||||
|
||||
type apiErrorBody struct {
|
||||
Code int `json:"code"`
|
||||
Message string `json:"message"`
|
||||
Data any `json:"data,omitempty"`
|
||||
}
|
||||
|
||||
func NewServices(c cache.Cache, t token.Provider, db *gorm.DB, l logger.Logger, cfg *config.Config, videoService *video.Service, agentRuntime video.AgentRuntime) *Services {
|
||||
var storageProvider storage.Provider
|
||||
if cfg != nil {
|
||||
provider, err := storage.NewS3Provider(cfg)
|
||||
@@ -131,17 +150,24 @@ func NewServices(c cache.Cache, t token.Provider, db *gorm.DB, l logger.Logger,
|
||||
}
|
||||
}
|
||||
|
||||
frontendBaseURL := ""
|
||||
if cfg != nil {
|
||||
frontendBaseURL = cfg.Frontend.BaseURL
|
||||
}
|
||||
|
||||
service := &appServices{
|
||||
db: db,
|
||||
logger: l,
|
||||
authenticator: middleware.NewAuthenticator(db, l, cfg.Internal.Marker),
|
||||
tokenProvider: t,
|
||||
cache: c,
|
||||
storageProvider: storageProvider,
|
||||
jobService: jobService,
|
||||
agentRuntime: agentRuntime,
|
||||
googleOauth: googleOauth,
|
||||
googleStateTTL: googleStateTTL,
|
||||
db: db,
|
||||
logger: l,
|
||||
authenticator: middleware.NewAuthenticator(db, l, cfg.Internal.Marker),
|
||||
tokenProvider: t,
|
||||
cache: c,
|
||||
storageProvider: storageProvider,
|
||||
videoService: videoService,
|
||||
agentRuntime: agentRuntime,
|
||||
googleOauth: googleOauth,
|
||||
googleStateTTL: googleStateTTL,
|
||||
googleUserInfoURL: defaultGoogleUserInfoURL,
|
||||
frontendBaseURL: frontendBaseURL,
|
||||
}
|
||||
return &Services{
|
||||
AuthServiceServer: service,
|
||||
@@ -151,6 +177,7 @@ func NewServices(c cache.Cache, t token.Provider, db *gorm.DB, l logger.Logger,
|
||||
NotificationsServiceServer: service,
|
||||
DomainsServiceServer: service,
|
||||
AdTemplatesServiceServer: service,
|
||||
PlayerConfigsServiceServer: service,
|
||||
PlansServiceServer: service,
|
||||
PaymentsServiceServer: service,
|
||||
VideosServiceServer: service,
|
||||
@@ -186,6 +213,10 @@ type AdTemplatesServiceServer interface {
|
||||
appv1.AdTemplatesServiceServer
|
||||
}
|
||||
|
||||
type PlayerConfigsServiceServer interface {
|
||||
appv1.PlayerConfigsServiceServer
|
||||
}
|
||||
|
||||
type PlansServiceServer interface {
|
||||
appv1.PlansServiceServer
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
238
internal/rpc/app/service_helpers_payment_flow_test.go
Normal file
238
internal/rpc/app/service_helpers_payment_flow_test.go
Normal file
@@ -0,0 +1,238 @@
|
||||
package app
|
||||
|
||||
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"])
|
||||
}
|
||||
}
|
||||
@@ -4,7 +4,6 @@ import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
@@ -13,7 +12,6 @@ import (
|
||||
"google.golang.org/grpc/codes"
|
||||
"google.golang.org/grpc/status"
|
||||
"gorm.io/gorm"
|
||||
paymentapi "stream.api/internal/api/payment"
|
||||
"stream.api/internal/database/model"
|
||||
"stream.api/internal/database/query"
|
||||
appv1 "stream.api/internal/gen/proto/app/v1"
|
||||
@@ -38,177 +36,17 @@ func (s *appServices) CreatePayment(ctx context.Context, req *appv1.CreatePaymen
|
||||
return nil, status.Error(codes.InvalidArgument, "Payment method must be wallet or topup")
|
||||
}
|
||||
|
||||
var planRecord model.Plan
|
||||
if err := s.db.WithContext(ctx).Where("id = ?", planID).First(&planRecord).Error; err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return nil, status.Error(codes.NotFound, "Plan not found")
|
||||
}
|
||||
s.logger.Error("Failed to load plan", "error", err)
|
||||
return nil, status.Error(codes.Internal, "Failed to create payment")
|
||||
}
|
||||
if planRecord.IsActive == nil || !*planRecord.IsActive {
|
||||
return nil, status.Error(codes.InvalidArgument, "Plan is not active")
|
||||
planRecord, err := s.loadPaymentPlanForUser(ctx, planID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
totalAmount := planRecord.Price * float64(req.GetTermMonths())
|
||||
if totalAmount < 0 {
|
||||
return nil, status.Error(codes.InvalidArgument, "Amount must be greater than or equal to 0")
|
||||
}
|
||||
|
||||
statusValue := "SUCCESS"
|
||||
provider := "INTERNAL"
|
||||
currency := normalizeCurrency(nil)
|
||||
transactionID := buildTransactionID("sub")
|
||||
now := time.Now().UTC()
|
||||
|
||||
paymentRecord := &model.Payment{
|
||||
ID: uuid.New().String(),
|
||||
resultValue, err := s.executePaymentFlow(ctx, paymentExecutionInput{
|
||||
UserID: result.UserID,
|
||||
PlanID: &planRecord.ID,
|
||||
Amount: totalAmount,
|
||||
Currency: ¤cy,
|
||||
Status: &statusValue,
|
||||
Provider: &provider,
|
||||
TransactionID: &transactionID,
|
||||
}
|
||||
|
||||
invoiceID := buildInvoiceID(paymentRecord.ID)
|
||||
var subscription *model.PlanSubscription
|
||||
var walletBalance float64
|
||||
|
||||
err = s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||
if _, err := lockUserForUpdate(ctx, tx, result.UserID); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
currentSubscription, err := model.GetLatestPlanSubscription(ctx, tx, result.UserID)
|
||||
if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return err
|
||||
}
|
||||
|
||||
baseExpiry := now
|
||||
if currentSubscription != nil && currentSubscription.ExpiresAt.After(baseExpiry) {
|
||||
baseExpiry = currentSubscription.ExpiresAt.UTC()
|
||||
}
|
||||
newExpiry := baseExpiry.AddDate(0, int(req.GetTermMonths()), 0)
|
||||
|
||||
currentWalletBalance, err := model.GetWalletBalance(ctx, tx, result.UserID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
shortfall := maxFloat(totalAmount-currentWalletBalance, 0)
|
||||
|
||||
if paymentMethod == paymentMethodWallet && shortfall > 0 {
|
||||
return statusErrorWithBody(ctx, codes.InvalidArgument, http.StatusBadRequest, "Insufficient wallet balance", map[string]interface{}{
|
||||
"payment_method": paymentMethod,
|
||||
"wallet_balance": currentWalletBalance,
|
||||
"total_amount": totalAmount,
|
||||
"shortfall": shortfall,
|
||||
})
|
||||
}
|
||||
|
||||
topupAmount := 0.0
|
||||
if paymentMethod == paymentMethodTopup {
|
||||
if req.TopupAmount == nil {
|
||||
return statusErrorWithBody(ctx, codes.InvalidArgument, http.StatusBadRequest, "Top-up amount is required when payment method is topup", map[string]interface{}{
|
||||
"payment_method": paymentMethod,
|
||||
"wallet_balance": currentWalletBalance,
|
||||
"total_amount": totalAmount,
|
||||
"shortfall": shortfall,
|
||||
})
|
||||
}
|
||||
|
||||
topupAmount = maxFloat(req.GetTopupAmount(), 0)
|
||||
if topupAmount <= 0 {
|
||||
return statusErrorWithBody(ctx, codes.InvalidArgument, http.StatusBadRequest, "Top-up amount must be greater than 0", map[string]interface{}{
|
||||
"payment_method": paymentMethod,
|
||||
"wallet_balance": currentWalletBalance,
|
||||
"total_amount": totalAmount,
|
||||
"shortfall": shortfall,
|
||||
})
|
||||
}
|
||||
if topupAmount < shortfall {
|
||||
return statusErrorWithBody(ctx, codes.InvalidArgument, http.StatusBadRequest, "Top-up amount must be greater than or equal to the required shortfall", map[string]interface{}{
|
||||
"payment_method": paymentMethod,
|
||||
"wallet_balance": currentWalletBalance,
|
||||
"total_amount": totalAmount,
|
||||
"shortfall": shortfall,
|
||||
"topup_amount": topupAmount,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
if err := tx.Create(paymentRecord).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
walletUsedAmount := totalAmount
|
||||
|
||||
if paymentMethod == paymentMethodTopup {
|
||||
topupTransaction := &model.WalletTransaction{
|
||||
ID: uuid.New().String(),
|
||||
UserID: result.UserID,
|
||||
Type: walletTransactionTypeTopup,
|
||||
Amount: topupAmount,
|
||||
Currency: model.StringPtr(currency),
|
||||
Note: model.StringPtr(fmt.Sprintf("Wallet top-up for %s (%d months)", planRecord.Name, req.GetTermMonths())),
|
||||
PaymentID: &paymentRecord.ID,
|
||||
PlanID: &planRecord.ID,
|
||||
TermMonths: int32Ptr(req.GetTermMonths()),
|
||||
}
|
||||
if err := tx.Create(topupTransaction).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
debitTransaction := &model.WalletTransaction{
|
||||
ID: uuid.New().String(),
|
||||
UserID: result.UserID,
|
||||
Type: walletTransactionTypeSubscriptionDebit,
|
||||
Amount: -totalAmount,
|
||||
Currency: model.StringPtr(currency),
|
||||
Note: model.StringPtr(fmt.Sprintf("Subscription payment for %s (%d months)", planRecord.Name, req.GetTermMonths())),
|
||||
PaymentID: &paymentRecord.ID,
|
||||
PlanID: &planRecord.ID,
|
||||
TermMonths: int32Ptr(req.GetTermMonths()),
|
||||
}
|
||||
if err := tx.Create(debitTransaction).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
subscription = &model.PlanSubscription{
|
||||
ID: uuid.New().String(),
|
||||
UserID: result.UserID,
|
||||
PaymentID: paymentRecord.ID,
|
||||
PlanID: planRecord.ID,
|
||||
TermMonths: req.GetTermMonths(),
|
||||
PaymentMethod: paymentMethod,
|
||||
WalletAmount: walletUsedAmount,
|
||||
TopupAmount: topupAmount,
|
||||
StartedAt: now,
|
||||
ExpiresAt: newExpiry,
|
||||
}
|
||||
if err := tx.Create(subscription).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := tx.Model(&model.User{}).
|
||||
Where("id = ?", result.UserID).
|
||||
Update("plan_id", planRecord.ID).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
notification := buildSubscriptionNotification(result.UserID, paymentRecord.ID, invoiceID, &planRecord, subscription)
|
||||
if err := tx.Create(notification).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
walletBalance, err = model.GetWalletBalance(ctx, tx, result.UserID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
Plan: planRecord,
|
||||
TermMonths: req.GetTermMonths(),
|
||||
PaymentMethod: paymentMethod,
|
||||
TopupAmount: req.TopupAmount,
|
||||
})
|
||||
if err != nil {
|
||||
if _, ok := status.FromError(err); ok {
|
||||
@@ -219,20 +57,35 @@ func (s *appServices) CreatePayment(ctx context.Context, req *appv1.CreatePaymen
|
||||
}
|
||||
|
||||
return &appv1.CreatePaymentResponse{
|
||||
Payment: toProtoPayment(paymentRecord),
|
||||
Subscription: toProtoPlanSubscription(subscription),
|
||||
WalletBalance: walletBalance,
|
||||
InvoiceId: invoiceID,
|
||||
Payment: toProtoPayment(resultValue.Payment),
|
||||
Subscription: toProtoPlanSubscription(resultValue.Subscription),
|
||||
WalletBalance: resultValue.WalletBalance,
|
||||
InvoiceId: resultValue.InvoiceID,
|
||||
Message: "Payment completed successfully",
|
||||
}, nil
|
||||
}
|
||||
func (s *appServices) ListPaymentHistory(ctx context.Context, _ *appv1.ListPaymentHistoryRequest) (*appv1.ListPaymentHistoryResponse, error) {
|
||||
func (s *appServices) ListPaymentHistory(ctx context.Context, req *appv1.ListPaymentHistoryRequest) (*appv1.ListPaymentHistoryResponse, error) {
|
||||
result, err := s.authenticate(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var rows []paymentRow
|
||||
page, limit, offset := adminPageLimitOffset(req.GetPage(), req.GetLimit())
|
||||
|
||||
type paymentHistoryRow struct {
|
||||
ID string `gorm:"column:id"`
|
||||
Amount float64 `gorm:"column:amount"`
|
||||
Currency *string `gorm:"column:currency"`
|
||||
Status *string `gorm:"column:status"`
|
||||
PlanID *string `gorm:"column:plan_id"`
|
||||
PlanName *string `gorm:"column:plan_name"`
|
||||
TermMonths *int32 `gorm:"column:term_months"`
|
||||
PaymentMethod *string `gorm:"column:payment_method"`
|
||||
ExpiresAt *time.Time `gorm:"column:expires_at"`
|
||||
CreatedAt *time.Time `gorm:"column:created_at"`
|
||||
}
|
||||
|
||||
var rows []paymentHistoryRow
|
||||
if err := s.db.WithContext(ctx).
|
||||
Table("payment AS p").
|
||||
Select("p.id, p.amount, p.currency, p.status, p.plan_id, pl.name AS plan_name, ps.term_months, ps.payment_method, ps.expires_at, p.created_at").
|
||||
@@ -245,21 +98,21 @@ func (s *appServices) ListPaymentHistory(ctx context.Context, _ *appv1.ListPayme
|
||||
return nil, status.Error(codes.Internal, "Failed to fetch payment history")
|
||||
}
|
||||
|
||||
items := make([]paymentapi.PaymentHistoryItem, 0, len(rows))
|
||||
items := make([]*appv1.PaymentHistoryItem, 0, len(rows))
|
||||
for _, row := range rows {
|
||||
items = append(items, paymentapi.PaymentHistoryItem{
|
||||
ID: row.ID,
|
||||
items = append(items, &appv1.PaymentHistoryItem{
|
||||
Id: row.ID,
|
||||
Amount: row.Amount,
|
||||
Currency: normalizeCurrency(row.Currency),
|
||||
Status: normalizePaymentStatus(row.Status),
|
||||
PlanID: row.PlanID,
|
||||
PlanId: row.PlanID,
|
||||
PlanName: row.PlanName,
|
||||
InvoiceID: buildInvoiceID(row.ID),
|
||||
InvoiceId: buildInvoiceID(row.ID),
|
||||
Kind: paymentKindSubscription,
|
||||
TermMonths: row.TermMonths,
|
||||
PaymentMethod: normalizeOptionalPaymentMethod(row.PaymentMethod),
|
||||
ExpiresAt: row.ExpiresAt,
|
||||
CreatedAt: row.CreatedAt,
|
||||
ExpiresAt: timeToProto(row.ExpiresAt),
|
||||
CreatedAt: timeToProto(row.CreatedAt),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -273,15 +126,14 @@ func (s *appServices) ListPaymentHistory(ctx context.Context, _ *appv1.ListPayme
|
||||
}
|
||||
|
||||
for _, topup := range topups {
|
||||
createdAt := topup.CreatedAt
|
||||
items = append(items, paymentapi.PaymentHistoryItem{
|
||||
ID: topup.ID,
|
||||
items = append(items, &appv1.PaymentHistoryItem{
|
||||
Id: topup.ID,
|
||||
Amount: topup.Amount,
|
||||
Currency: normalizeCurrency(topup.Currency),
|
||||
Status: "success",
|
||||
InvoiceID: buildInvoiceID(topup.ID),
|
||||
InvoiceId: buildInvoiceID(topup.ID),
|
||||
Kind: paymentKindWalletTopup,
|
||||
CreatedAt: createdAt,
|
||||
CreatedAt: timeToProto(topup.CreatedAt),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -289,22 +141,30 @@ func (s *appServices) ListPaymentHistory(ctx context.Context, _ *appv1.ListPayme
|
||||
left := time.Time{}
|
||||
right := time.Time{}
|
||||
if items[i].CreatedAt != nil {
|
||||
left = *items[i].CreatedAt
|
||||
left = items[i].CreatedAt.AsTime()
|
||||
}
|
||||
if items[j].CreatedAt != nil {
|
||||
right = *items[j].CreatedAt
|
||||
right = items[j].CreatedAt.AsTime()
|
||||
}
|
||||
if right.Equal(left) {
|
||||
return items[i].GetId() > items[j].GetId()
|
||||
}
|
||||
return right.After(left)
|
||||
})
|
||||
|
||||
payload := make([]*appv1.PaymentHistoryItem, 0, len(items))
|
||||
for _, item := range items {
|
||||
copyItem := item
|
||||
payload = append(payload, toProtoPaymentHistoryItem(©Item))
|
||||
total := int64(len(items))
|
||||
hasPrev := page > 1 && total > 0
|
||||
if offset >= len(items) {
|
||||
return &appv1.ListPaymentHistoryResponse{Payments: []*appv1.PaymentHistoryItem{}, Total: total, Page: page, Limit: limit, HasPrev: hasPrev, HasNext: false}, nil
|
||||
}
|
||||
|
||||
return &appv1.ListPaymentHistoryResponse{Payments: payload}, nil
|
||||
end := offset + int(limit)
|
||||
if end > len(items) {
|
||||
end = len(items)
|
||||
}
|
||||
hasNext := end < len(items)
|
||||
return &appv1.ListPaymentHistoryResponse{Payments: items[offset:end], Total: total, Page: page, Limit: limit, HasPrev: hasPrev, HasNext: hasNext}, nil
|
||||
}
|
||||
|
||||
func (s *appServices) TopupWallet(ctx context.Context, req *appv1.TopupWalletRequest) (*appv1.TopupWalletResponse, error) {
|
||||
result, err := s.authenticate(ctx)
|
||||
if err != nil {
|
||||
@@ -331,7 +191,7 @@ func (s *appServices) TopupWallet(ctx context.Context, req *appv1.TopupWalletReq
|
||||
Type: "billing.topup",
|
||||
Title: "Wallet credited",
|
||||
Message: fmt.Sprintf("Your wallet has been credited with %.2f USD.", amount),
|
||||
Metadata: model.StringPtr(mustMarshalJSON(map[string]interface{}{
|
||||
Metadata: model.StringPtr(mustMarshalJSON(map[string]any{
|
||||
"wallet_transaction_id": transaction.ID,
|
||||
"invoice_id": buildInvoiceID(transaction.ID),
|
||||
})),
|
||||
@@ -391,7 +251,7 @@ func (s *appServices) DownloadInvoice(ctx context.Context, req *appv1.DownloadIn
|
||||
Content: invoiceText,
|
||||
}, nil
|
||||
}
|
||||
if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
if !errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
s.logger.Error("Failed to load payment invoice", "error", err)
|
||||
return nil, status.Error(codes.Internal, "Failed to download invoice")
|
||||
}
|
||||
|
||||
181
internal/rpc/app/service_payments_test.go
Normal file
181
internal/rpc/app/service_payments_test.go
Normal file
@@ -0,0 +1,181 @@
|
||||
package app
|
||||
|
||||
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"
|
||||
"stream.api/internal/database/model"
|
||||
appv1 "stream.api/internal/gen/proto/app/v1"
|
||||
"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/rpc/app/service_player_configs_test.go
Normal file
266
internal/rpc/app/service_player_configs_test.go
Normal file
@@ -0,0 +1,266 @@
|
||||
package app
|
||||
|
||||
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"
|
||||
"stream.api/internal/database/model"
|
||||
appv1 "stream.api/internal/gen/proto/app/v1"
|
||||
"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/rpc/app/service_referrals_test.go
Normal file
235
internal/rpc/app/service_referrals_test.go
Normal file
@@ -0,0 +1,235 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
"strings"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"google.golang.org/grpc/codes"
|
||||
"gorm.io/gorm"
|
||||
"stream.api/internal/database/model"
|
||||
appv1 "stream.api/internal/gen/proto/app/v1"
|
||||
)
|
||||
|
||||
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
|
||||
}
|
||||
@@ -346,8 +346,9 @@ func (s *appServices) DeleteAdTemplate(ctx context.Context, req *appv1.DeleteAdT
|
||||
}
|
||||
|
||||
if err := s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||
if err := tx.Where("ad_template_id = ? AND user_id = ?", id, result.UserID).
|
||||
Delete(&model.VideoAdConfig{}).Error; err != nil {
|
||||
if err := tx.Model(&model.Video{}).
|
||||
Where("user_id = ? AND ad_id = ?", result.UserID, id).
|
||||
Update("ad_id", nil).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -388,3 +389,236 @@ func (s *appServices) ListPlans(ctx context.Context, _ *appv1.ListPlansRequest)
|
||||
|
||||
return &appv1.ListPlansResponse{Plans: items}, nil
|
||||
}
|
||||
|
||||
func (s *appServices) ListPlayerConfigs(ctx context.Context, _ *appv1.ListPlayerConfigsRequest) (*appv1.ListPlayerConfigsResponse, error) {
|
||||
result, err := s.authenticate(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var items []model.PlayerConfig
|
||||
if err := s.db.WithContext(ctx).
|
||||
Where("user_id = ?", result.UserID).
|
||||
Order("is_default DESC").
|
||||
Order("created_at DESC").
|
||||
Find(&items).Error; err != nil {
|
||||
s.logger.Error("Failed to list player configs", "error", err)
|
||||
return nil, status.Error(codes.Internal, "Failed to load player configs")
|
||||
}
|
||||
|
||||
payload := make([]*appv1.PlayerConfig, 0, len(items))
|
||||
for _, item := range items {
|
||||
copyItem := item
|
||||
payload = append(payload, toProtoPlayerConfig(©Item))
|
||||
}
|
||||
|
||||
return &appv1.ListPlayerConfigsResponse{Configs: payload}, nil
|
||||
}
|
||||
|
||||
func (s *appServices) CreatePlayerConfig(ctx context.Context, req *appv1.CreatePlayerConfigRequest) (*appv1.CreatePlayerConfigResponse, error) {
|
||||
result, err := s.authenticate(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
name := strings.TrimSpace(req.GetName())
|
||||
if name == "" {
|
||||
return nil, status.Error(codes.InvalidArgument, "Name is required")
|
||||
}
|
||||
|
||||
item := &model.PlayerConfig{
|
||||
ID: uuid.New().String(),
|
||||
UserID: result.UserID,
|
||||
Name: name,
|
||||
Description: nullableTrimmedString(req.Description),
|
||||
Autoplay: req.GetAutoplay(),
|
||||
Loop: req.GetLoop(),
|
||||
Muted: req.GetMuted(),
|
||||
ShowControls: model.BoolPtr(req.GetShowControls()),
|
||||
Pip: model.BoolPtr(req.GetPip()),
|
||||
Airplay: model.BoolPtr(req.GetAirplay()),
|
||||
Chromecast: model.BoolPtr(req.GetChromecast()),
|
||||
IsActive: model.BoolPtr(req.IsActive == nil || *req.IsActive),
|
||||
IsDefault: req.IsDefault != nil && *req.IsDefault,
|
||||
EncrytionM3u8: model.BoolPtr(req.EncrytionM3U8 == nil || *req.EncrytionM3U8),
|
||||
LogoURL: nullableTrimmedString(req.LogoUrl),
|
||||
}
|
||||
if !playerConfigIsActive(item.IsActive) {
|
||||
item.IsDefault = false
|
||||
}
|
||||
|
||||
if err := s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||
lockedUser, err := lockUserForUpdate(ctx, tx, result.UserID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
var configCount int64
|
||||
if err := tx.WithContext(ctx).
|
||||
Model(&model.PlayerConfig{}).
|
||||
Where("user_id = ?", result.UserID).
|
||||
Count(&configCount).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if err := playerConfigActionAllowed(lockedUser, configCount, "create"); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if item.IsDefault {
|
||||
if err := unsetDefaultPlayerConfigs(tx, result.UserID, ""); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return tx.Create(item).Error
|
||||
}); err != nil {
|
||||
if status.Code(err) != codes.Unknown {
|
||||
return nil, err
|
||||
}
|
||||
s.logger.Error("Failed to create player config", "error", err)
|
||||
return nil, status.Error(codes.Internal, "Failed to save player config")
|
||||
}
|
||||
|
||||
return &appv1.CreatePlayerConfigResponse{Config: toProtoPlayerConfig(item)}, nil
|
||||
}
|
||||
|
||||
func (s *appServices) UpdatePlayerConfig(ctx context.Context, req *appv1.UpdatePlayerConfigRequest) (*appv1.UpdatePlayerConfigResponse, error) {
|
||||
result, err := s.authenticate(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
id := strings.TrimSpace(req.GetId())
|
||||
if id == "" {
|
||||
return nil, status.Error(codes.NotFound, "Player config not found")
|
||||
}
|
||||
|
||||
name := strings.TrimSpace(req.GetName())
|
||||
if name == "" {
|
||||
return nil, status.Error(codes.InvalidArgument, "Name is required")
|
||||
}
|
||||
|
||||
var item model.PlayerConfig
|
||||
if err := s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||
lockedUser, err := lockUserForUpdate(ctx, tx, result.UserID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
var configCount int64
|
||||
if err := tx.WithContext(ctx).
|
||||
Model(&model.PlayerConfig{}).
|
||||
Where("user_id = ?", result.UserID).
|
||||
Count(&configCount).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := tx.WithContext(ctx).Where("id = ? AND user_id = ?", id, result.UserID).First(&item).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
action := "update"
|
||||
wasActive := playerConfigIsActive(item.IsActive)
|
||||
if req.IsActive != nil && *req.IsActive != wasActive {
|
||||
action = "toggle-active"
|
||||
}
|
||||
if req.IsDefault != nil && *req.IsDefault {
|
||||
action = "set-default"
|
||||
}
|
||||
if err := playerConfigActionAllowed(lockedUser, configCount, action); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
item.Name = name
|
||||
item.Description = nullableTrimmedString(req.Description)
|
||||
item.Autoplay = req.GetAutoplay()
|
||||
item.Loop = req.GetLoop()
|
||||
item.Muted = req.GetMuted()
|
||||
item.ShowControls = model.BoolPtr(req.GetShowControls())
|
||||
item.Pip = model.BoolPtr(req.GetPip())
|
||||
item.Airplay = model.BoolPtr(req.GetAirplay())
|
||||
item.Chromecast = model.BoolPtr(req.GetChromecast())
|
||||
if req.EncrytionM3U8 != nil {
|
||||
item.EncrytionM3u8 = model.BoolPtr(*req.EncrytionM3U8)
|
||||
}
|
||||
if req.LogoUrl != nil {
|
||||
item.LogoURL = nullableTrimmedString(req.LogoUrl)
|
||||
}
|
||||
if req.IsActive != nil {
|
||||
item.IsActive = model.BoolPtr(*req.IsActive)
|
||||
}
|
||||
if req.IsDefault != nil {
|
||||
item.IsDefault = *req.IsDefault
|
||||
}
|
||||
if !playerConfigIsActive(item.IsActive) {
|
||||
item.IsDefault = false
|
||||
}
|
||||
|
||||
if item.IsDefault {
|
||||
if err := unsetDefaultPlayerConfigs(tx, result.UserID, item.ID); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return tx.Save(&item).Error
|
||||
}); err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return nil, status.Error(codes.NotFound, "Player config not found")
|
||||
}
|
||||
if status.Code(err) != codes.Unknown {
|
||||
return nil, err
|
||||
}
|
||||
s.logger.Error("Failed to update player config", "error", err)
|
||||
return nil, status.Error(codes.Internal, "Failed to save player config")
|
||||
}
|
||||
|
||||
return &appv1.UpdatePlayerConfigResponse{Config: toProtoPlayerConfig(&item)}, nil
|
||||
}
|
||||
|
||||
func (s *appServices) DeletePlayerConfig(ctx context.Context, req *appv1.DeletePlayerConfigRequest) (*appv1.MessageResponse, error) {
|
||||
result, err := s.authenticate(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
id := strings.TrimSpace(req.GetId())
|
||||
if id == "" {
|
||||
return nil, status.Error(codes.NotFound, "Player config not found")
|
||||
}
|
||||
|
||||
if err := s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||
lockedUser, err := lockUserForUpdate(ctx, tx, result.UserID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
var configCount int64
|
||||
if err := tx.WithContext(ctx).
|
||||
Model(&model.PlayerConfig{}).
|
||||
Where("user_id = ?", result.UserID).
|
||||
Count(&configCount).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if err := playerConfigActionAllowed(lockedUser, configCount, "delete"); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
res := tx.Where("id = ? AND user_id = ?", id, result.UserID).Delete(&model.PlayerConfig{})
|
||||
if res.Error != nil {
|
||||
return res.Error
|
||||
}
|
||||
if res.RowsAffected == 0 {
|
||||
return gorm.ErrRecordNotFound
|
||||
}
|
||||
return nil
|
||||
}); err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return nil, status.Error(codes.NotFound, "Player config not found")
|
||||
}
|
||||
if status.Code(err) != codes.Unknown {
|
||||
return nil, err
|
||||
}
|
||||
s.logger.Error("Failed to delete player config", "error", err)
|
||||
return nil, status.Error(codes.Internal, "Failed to delete player config")
|
||||
}
|
||||
|
||||
return messageResponse("Player config deleted"), nil
|
||||
}
|
||||
|
||||
@@ -13,6 +13,7 @@ import (
|
||||
"gorm.io/gorm"
|
||||
"stream.api/internal/database/model"
|
||||
appv1 "stream.api/internal/gen/proto/app/v1"
|
||||
"stream.api/internal/video"
|
||||
)
|
||||
|
||||
func (s *appServices) GetUploadUrl(ctx context.Context, req *appv1.GetUploadUrlRequest) (*appv1.GetUploadUrlResponse, error) {
|
||||
@@ -44,6 +45,9 @@ func (s *appServices) CreateVideo(ctx context.Context, req *appv1.CreateVideoReq
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if s.videoService == nil {
|
||||
return nil, status.Error(codes.Unavailable, "Job service is unavailable")
|
||||
}
|
||||
|
||||
title := strings.TrimSpace(req.GetTitle())
|
||||
if title == "" {
|
||||
@@ -53,41 +57,28 @@ func (s *appServices) CreateVideo(ctx context.Context, req *appv1.CreateVideoReq
|
||||
if videoURL == "" {
|
||||
return nil, status.Error(codes.InvalidArgument, "URL is required")
|
||||
}
|
||||
|
||||
statusValue := "ready"
|
||||
processingStatus := "READY"
|
||||
storageType := detectStorageType(videoURL)
|
||||
description := strings.TrimSpace(req.GetDescription())
|
||||
format := strings.TrimSpace(req.GetFormat())
|
||||
|
||||
video := &model.Video{
|
||||
ID: uuid.New().String(),
|
||||
UserID: result.UserID,
|
||||
Name: title,
|
||||
Title: title,
|
||||
Description: nullableTrimmedString(&description),
|
||||
URL: videoURL,
|
||||
Size: req.GetSize(),
|
||||
Duration: req.GetDuration(),
|
||||
Format: format,
|
||||
Status: &statusValue,
|
||||
ProcessingStatus: &processingStatus,
|
||||
StorageType: &storageType,
|
||||
}
|
||||
|
||||
if err := s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||
if err := tx.Create(video).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
return tx.Model(&model.User{}).
|
||||
Where("id = ?", result.UserID).
|
||||
UpdateColumn("storage_used", gorm.Expr("storage_used + ?", video.Size)).Error
|
||||
}); err != nil {
|
||||
created, err := s.videoService.CreateVideo(ctx, video.CreateVideoInput{
|
||||
UserID: result.UserID,
|
||||
Title: title,
|
||||
Description: &description,
|
||||
URL: videoURL,
|
||||
Size: req.GetSize(),
|
||||
Duration: req.GetDuration(),
|
||||
Format: strings.TrimSpace(req.GetFormat()),
|
||||
})
|
||||
if err != nil {
|
||||
s.logger.Error("Failed to create video", "error", err)
|
||||
return nil, status.Error(codes.Internal, "Failed to create video")
|
||||
switch {
|
||||
case errors.Is(err, video.ErrJobServiceUnavailable):
|
||||
return nil, status.Error(codes.Unavailable, "Job service is unavailable")
|
||||
default:
|
||||
return nil, status.Error(codes.Internal, "Failed to create video")
|
||||
}
|
||||
}
|
||||
|
||||
return &appv1.CreateVideoResponse{Video: toProtoVideo(video)}, nil
|
||||
return &appv1.CreateVideoResponse{Video: toProtoVideo(created.Video, created.Job.ID)}, nil
|
||||
}
|
||||
func (s *appServices) ListVideos(ctx context.Context, req *appv1.ListVideosRequest) (*appv1.ListVideosResponse, error) {
|
||||
result, err := s.authenticate(ctx)
|
||||
@@ -131,7 +122,12 @@ func (s *appServices) ListVideos(ctx context.Context, req *appv1.ListVideosReque
|
||||
|
||||
items := make([]*appv1.Video, 0, len(videos))
|
||||
for i := range videos {
|
||||
items = append(items, toProtoVideo(&videos[i]))
|
||||
payload, err := s.buildVideo(ctx, &videos[i])
|
||||
if err != nil {
|
||||
s.logger.Error("Failed to build video payload", "error", err, "video_id", videos[i].ID)
|
||||
return nil, status.Error(codes.Internal, "Failed to fetch videos")
|
||||
}
|
||||
items = append(items, payload)
|
||||
}
|
||||
|
||||
return &appv1.ListVideosResponse{Videos: items, Total: total, Page: page, Limit: limit}, nil
|
||||
@@ -160,7 +156,12 @@ func (s *appServices) GetVideo(ctx context.Context, req *appv1.GetVideoRequest)
|
||||
return nil, status.Error(codes.Internal, "Failed to fetch video")
|
||||
}
|
||||
|
||||
return &appv1.GetVideoResponse{Video: toProtoVideo(&video)}, nil
|
||||
payload, err := s.buildVideo(ctx, &video)
|
||||
if err != nil {
|
||||
s.logger.Error("Failed to build video payload", "error", err, "video_id", video.ID)
|
||||
return nil, status.Error(codes.Internal, "Failed to fetch video")
|
||||
}
|
||||
return &appv1.GetVideoResponse{Video: payload}, nil
|
||||
}
|
||||
func (s *appServices) UpdateVideo(ctx context.Context, req *appv1.UpdateVideoRequest) (*appv1.UpdateVideoResponse, error) {
|
||||
result, err := s.authenticate(ctx)
|
||||
@@ -219,7 +220,12 @@ func (s *appServices) UpdateVideo(ctx context.Context, req *appv1.UpdateVideoReq
|
||||
return nil, status.Error(codes.Internal, "Failed to update video")
|
||||
}
|
||||
|
||||
return &appv1.UpdateVideoResponse{Video: toProtoVideo(&video)}, nil
|
||||
payload, err := s.buildVideo(ctx, &video)
|
||||
if err != nil {
|
||||
s.logger.Error("Failed to build video payload", "error", err, "video_id", video.ID)
|
||||
return nil, status.Error(codes.Internal, "Failed to update video")
|
||||
}
|
||||
return &appv1.UpdateVideoResponse{Video: payload}, nil
|
||||
}
|
||||
func (s *appServices) DeleteVideo(ctx context.Context, req *appv1.DeleteVideoRequest) (*appv1.MessageResponse, error) {
|
||||
result, err := s.authenticate(ctx)
|
||||
@@ -256,9 +262,6 @@ func (s *appServices) DeleteVideo(ctx context.Context, req *appv1.DeleteVideoReq
|
||||
}
|
||||
|
||||
if err := s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||
if err := tx.Where("video_id = ? AND user_id = ?", video.ID, result.UserID).Delete(&model.VideoAdConfig{}).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if err := tx.Where("id = ? AND user_id = ?", video.ID, result.UserID).Delete(&model.Video{}).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
408
internal/rpc/app/testdb_setup_test.go
Normal file
408
internal/rpc/app/testdb_setup_test.go
Normal file
@@ -0,0 +1,408 @@
|
||||
package app
|
||||
|
||||
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"
|
||||
"stream.api/internal/database/model"
|
||||
"stream.api/internal/database/query"
|
||||
appv1 "stream.api/internal/gen/proto/app/v1"
|
||||
"stream.api/internal/middleware"
|
||||
"stream.api/pkg/cache"
|
||||
"stream.api/pkg/logger"
|
||||
"stream.api/pkg/token"
|
||||
)
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
func (fakeTokenProvider) GenerateTokenPair(userID, _, _ string) (*token.TokenPair, error) {
|
||||
return &token.TokenPair{
|
||||
AccessToken: "access-" + userID,
|
||||
RefreshToken: "refresh-" + userID,
|
||||
AccessUUID: "access-uuid-" + userID,
|
||||
RefreshUUID: "refresh-uuid-" + userID,
|
||||
AtExpires: time.Now().Add(time.Hour).Unix(),
|
||||
RtExpires: time.Now().Add(24 * time.Hour).Unix(),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (fakeTokenProvider) ParseToken(tokenString string) (*token.Claims, error) {
|
||||
return &token.Claims{UserID: tokenString}, nil
|
||||
}
|
||||
|
||||
func (fakeTokenProvider) ParseMapToken(tokenString string) (map[string]interface{}, error) {
|
||||
return map[string]interface{}{"token": tokenString}, nil
|
||||
}
|
||||
|
||||
var _ cache.Cache = (*fakeCache)(nil)
|
||||
var _ token.Provider = fakeTokenProvider{}
|
||||
|
||||
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{}},
|
||||
tokenProvider: fakeTokenProvider{},
|
||||
googleUserInfoURL: defaultGoogleUserInfoURL,
|
||||
}
|
||||
}
|
||||
|
||||
func newTestGRPCServer(t *testing.T, services *appServices) (*grpc.ClientConn, func()) {
|
||||
t.Helper()
|
||||
|
||||
lis := bufconn.Listen(testBufDialerListenerSize)
|
||||
server := grpc.NewServer()
|
||||
Register(server, &Services{
|
||||
AuthServiceServer: services,
|
||||
AccountServiceServer: services,
|
||||
PreferencesServiceServer: services,
|
||||
UsageServiceServer: services,
|
||||
NotificationsServiceServer: services,
|
||||
DomainsServiceServer: services,
|
||||
AdTemplatesServiceServer: services,
|
||||
PlayerConfigsServiceServer: services,
|
||||
PlansServiceServer: services,
|
||||
PaymentsServiceServer: services,
|
||||
VideosServiceServer: services,
|
||||
AdminServiceServer: 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.PaymentsServiceClient {
|
||||
return appv1.NewPaymentsServiceClient(conn)
|
||||
}
|
||||
|
||||
func newAdminClient(conn *grpc.ClientConn) appv1.AdminServiceClient {
|
||||
return appv1.NewAdminServiceClient(conn)
|
||||
}
|
||||
|
||||
var _ logger.Logger = testLogger{}
|
||||
29
internal/rpc/app/usage_helpers.go
Normal file
29
internal/rpc/app/usage_helpers.go
Normal file
@@ -0,0 +1,29 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"gorm.io/gorm"
|
||||
"stream.api/internal/database/model"
|
||||
"stream.api/pkg/logger"
|
||||
)
|
||||
|
||||
type usagePayload struct {
|
||||
UserID string `json:"user_id"`
|
||||
TotalVideos int64 `json:"total_videos"`
|
||||
TotalStorage int64 `json:"total_storage"`
|
||||
}
|
||||
|
||||
func loadUsage(ctx context.Context, db *gorm.DB, l logger.Logger, user *model.User) (*usagePayload, error) {
|
||||
var totalVideos int64
|
||||
if err := db.WithContext(ctx).Model(&model.Video{}).Where("user_id = ?", user.ID).Count(&totalVideos).Error; err != nil {
|
||||
l.Error("Failed to count user videos", "error", err, "user_id", user.ID)
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &usagePayload{
|
||||
UserID: user.ID,
|
||||
TotalVideos: totalVideos,
|
||||
TotalStorage: user.StorageUsed,
|
||||
}, nil
|
||||
}
|
||||
120
internal/rpc/app/user_payload.go
Normal file
120
internal/rpc/app/user_payload.go
Normal file
@@ -0,0 +1,120 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"gorm.io/gorm"
|
||||
"stream.api/internal/database/model"
|
||||
)
|
||||
|
||||
type userPayload struct {
|
||||
ID string `json:"id"`
|
||||
Email string `json:"email"`
|
||||
Username *string `json:"username,omitempty"`
|
||||
Avatar *string `json:"avatar,omitempty"`
|
||||
Role *string `json:"role,omitempty"`
|
||||
GoogleID *string `json:"google_id,omitempty"`
|
||||
StorageUsed int64 `json:"storage_used"`
|
||||
PlanID *string `json:"plan_id,omitempty"`
|
||||
PlanStartedAt *time.Time `json:"plan_started_at,omitempty"`
|
||||
PlanExpiresAt *time.Time `json:"plan_expires_at,omitempty"`
|
||||
PlanTermMonths *int32 `json:"plan_term_months,omitempty"`
|
||||
PlanPaymentMethod *string `json:"plan_payment_method,omitempty"`
|
||||
PlanExpiringSoon bool `json:"plan_expiring_soon"`
|
||||
WalletBalance float64 `json:"wallet_balance"`
|
||||
Language string `json:"language"`
|
||||
Locale string `json:"locale"`
|
||||
CreatedAt *time.Time `json:"created_at,omitempty"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
|
||||
func buildUserPayload(ctx context.Context, db *gorm.DB, user *model.User) (*userPayload, error) {
|
||||
pref, err := model.FindOrCreateUserPreference(ctx, db, user.ID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
walletBalance, err := model.GetWalletBalance(ctx, db, user.ID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
language := strings.TrimSpace(model.StringValue(pref.Language))
|
||||
if language == "" {
|
||||
language = "en"
|
||||
}
|
||||
locale := strings.TrimSpace(model.StringValue(pref.Locale))
|
||||
if locale == "" {
|
||||
locale = language
|
||||
}
|
||||
|
||||
effectivePlanID := user.PlanID
|
||||
var planStartedAt *time.Time
|
||||
var planExpiresAt *time.Time
|
||||
var planTermMonths *int32
|
||||
var planPaymentMethod *string
|
||||
planExpiringSoon := false
|
||||
now := time.Now().UTC()
|
||||
|
||||
subscription, err := model.GetLatestPlanSubscription(ctx, db, user.ID)
|
||||
if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return nil, err
|
||||
}
|
||||
if err == nil {
|
||||
startedAt := subscription.StartedAt.UTC()
|
||||
expiresAt := subscription.ExpiresAt.UTC()
|
||||
termMonths := subscription.TermMonths
|
||||
paymentMethod := normalizePlanPaymentMethod(subscription.PaymentMethod)
|
||||
|
||||
planStartedAt = &startedAt
|
||||
planExpiresAt = &expiresAt
|
||||
planTermMonths = &termMonths
|
||||
planPaymentMethod = &paymentMethod
|
||||
|
||||
if expiresAt.After(now) {
|
||||
effectivePlanID = &subscription.PlanID
|
||||
planExpiringSoon = isPlanExpiringSoon(expiresAt, now)
|
||||
} else {
|
||||
effectivePlanID = nil
|
||||
}
|
||||
}
|
||||
|
||||
return &userPayload{
|
||||
ID: user.ID,
|
||||
Email: user.Email,
|
||||
Username: user.Username,
|
||||
Avatar: user.Avatar,
|
||||
Role: user.Role,
|
||||
GoogleID: user.GoogleID,
|
||||
StorageUsed: user.StorageUsed,
|
||||
PlanID: effectivePlanID,
|
||||
PlanStartedAt: planStartedAt,
|
||||
PlanExpiresAt: planExpiresAt,
|
||||
PlanTermMonths: planTermMonths,
|
||||
PlanPaymentMethod: planPaymentMethod,
|
||||
PlanExpiringSoon: planExpiringSoon,
|
||||
WalletBalance: walletBalance,
|
||||
Language: language,
|
||||
Locale: locale,
|
||||
CreatedAt: user.CreatedAt,
|
||||
UpdatedAt: user.UpdatedAt,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func normalizePlanPaymentMethod(value string) string {
|
||||
switch strings.ToLower(strings.TrimSpace(value)) {
|
||||
case "topup":
|
||||
return "topup"
|
||||
default:
|
||||
return "wallet"
|
||||
}
|
||||
}
|
||||
|
||||
func isPlanExpiringSoon(expiresAt time.Time, now time.Time) bool {
|
||||
hoursUntilExpiry := expiresAt.Sub(now).Hours()
|
||||
const thresholdHours = 7 * 24
|
||||
return hoursUntilExpiry > 0 && hoursUntilExpiry <= thresholdHours
|
||||
}
|
||||
Reference in New Issue
Block a user