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:
2026-03-24 16:08:36 +00:00
parent 91e5e3542b
commit e7fdd0e1ab
103 changed files with 9540 additions and 8446 deletions

View File

@@ -0,0 +1,318 @@
package middleware
import (
"context"
"encoding/json"
"fmt"
"testing"
"time"
"github.com/google/uuid"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/metadata"
"google.golang.org/grpc/status"
"gorm.io/driver/sqlite"
"gorm.io/gorm"
_ "modernc.org/sqlite"
"stream.api/internal/database/model"
"stream.api/internal/database/query"
)
type testLogger struct{}
func (testLogger) Info(string, ...any) {}
func (testLogger) Error(string, ...any) {}
func (testLogger) Debug(string, ...any) {}
func (testLogger) Warn(string, ...any) {}
func newAuthenticatorTestDB(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_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 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
)`,
} {
if err := db.Exec(stmt).Error; err != nil {
t.Fatalf("create sqlite schema: %v", err)
}
}
query.SetDefault(db)
return db
}
func newTrustedContext(userID, role string) context.Context {
return metadata.NewIncomingContext(context.Background(), metadata.Pairs(
ActorMarkerMetadataKey, "trusted-marker",
ActorIDMetadataKey, userID,
ActorRoleMetadataKey, role,
ActorEmailMetadataKey, "actor@example.com",
))
}
func TestRequireActor(t *testing.T) {
auth := NewAuthenticator(newAuthenticatorTestDB(t), testLogger{}, "trusted-marker")
t.Run("thiếu metadata", func(t *testing.T) {
_, err := auth.RequireActor(context.Background())
if status.Code(err) != codes.Unauthenticated {
t.Fatalf("code = %v, want %v", status.Code(err), codes.Unauthenticated)
}
})
t.Run("trusted marker sai", func(t *testing.T) {
ctx := metadata.NewIncomingContext(context.Background(), metadata.Pairs(ActorMarkerMetadataKey, "wrong", ActorIDMetadataKey, "u1", ActorRoleMetadataKey, "USER"))
_, err := auth.RequireActor(ctx)
if status.Code(err) != codes.Unauthenticated {
t.Fatalf("code = %v, want %v", status.Code(err), codes.Unauthenticated)
}
})
t.Run("thiếu actor id hoặc role", func(t *testing.T) {
ctx := metadata.NewIncomingContext(context.Background(), metadata.Pairs(ActorMarkerMetadataKey, "trusted-marker", ActorIDMetadataKey, "u1"))
_, err := auth.RequireActor(ctx)
if status.Code(err) != codes.Unauthenticated {
t.Fatalf("code = %v, want %v", status.Code(err), codes.Unauthenticated)
}
})
t.Run("actor hợp lệ", func(t *testing.T) {
actor, err := auth.RequireActor(newTrustedContext("u1", "ADMIN"))
if err != nil {
t.Fatalf("RequireActor() error = %v", err)
}
if actor.UserID != "u1" || actor.Role != "ADMIN" || actor.Email != "actor@example.com" {
t.Fatalf("actor = %#v", actor)
}
})
}
func TestAuthenticate(t *testing.T) {
t.Run("user không tồn tại", func(t *testing.T) {
db := newAuthenticatorTestDB(t)
auth := NewAuthenticator(db, testLogger{}, "trusted-marker")
_, err := auth.Authenticate(newTrustedContext(uuid.NewString(), "USER"))
if status.Code(err) != codes.Unauthenticated {
t.Fatalf("code = %v, want %v", status.Code(err), codes.Unauthenticated)
}
})
t.Run("user bị block", func(t *testing.T) {
db := newAuthenticatorTestDB(t)
blocked := model.User{ID: uuid.NewString(), Email: "blocked@example.com", Role: stringPtr("block")}
if err := db.Create(&blocked).Error; err != nil {
t.Fatalf("create blocked user: %v", err)
}
auth := NewAuthenticator(db, testLogger{}, "trusted-marker")
_, err := auth.Authenticate(newTrustedContext(blocked.ID, "USER"))
if status.Code(err) != codes.PermissionDenied {
t.Fatalf("code = %v, want %v", status.Code(err), codes.PermissionDenied)
}
})
t.Run("subscription active sync user.plan_id", func(t *testing.T) {
db := newAuthenticatorTestDB(t)
planID := uuid.NewString()
user := model.User{ID: uuid.NewString(), Email: "active@example.com", Role: stringPtr("USER")}
if err := db.Create(&user).Error; err != nil {
t.Fatalf("create user: %v", err)
}
subscription := model.PlanSubscription{
ID: uuid.NewString(),
UserID: user.ID,
PaymentID: uuid.NewString(),
PlanID: planID,
TermMonths: 1,
PaymentMethod: "wallet",
WalletAmount: 10,
TopupAmount: 0,
StartedAt: time.Now().UTC().Add(-24 * time.Hour),
ExpiresAt: time.Now().UTC().Add(10 * 24 * time.Hour),
}
if err := db.Create(&subscription).Error; err != nil {
t.Fatalf("create subscription: %v", err)
}
auth := NewAuthenticator(db, testLogger{}, "trusted-marker")
result, err := auth.Authenticate(newTrustedContext(user.ID, "USER"))
if err != nil {
t.Fatalf("Authenticate() error = %v", err)
}
if result.User.PlanID == nil || *result.User.PlanID != planID {
t.Fatalf("plan_id = %v, want %s", result.User.PlanID, planID)
}
})
t.Run("subscription expired clear user.plan_id", func(t *testing.T) {
db := newAuthenticatorTestDB(t)
planID := uuid.NewString()
user := model.User{ID: uuid.NewString(), Email: "expired@example.com", Role: stringPtr("USER"), PlanID: &planID}
if err := db.Create(&user).Error; err != nil {
t.Fatalf("create user: %v", err)
}
subscription := model.PlanSubscription{
ID: uuid.NewString(),
UserID: user.ID,
PaymentID: uuid.NewString(),
PlanID: planID,
TermMonths: 1,
PaymentMethod: "wallet",
WalletAmount: 10,
TopupAmount: 0,
StartedAt: time.Now().UTC().Add(-10 * 24 * time.Hour),
ExpiresAt: time.Now().UTC().Add(-1 * time.Hour),
}
if err := db.Create(&subscription).Error; err != nil {
t.Fatalf("create subscription: %v", err)
}
auth := NewAuthenticator(db, testLogger{}, "trusted-marker")
result, err := auth.Authenticate(newTrustedContext(user.ID, "USER"))
if err != nil {
t.Fatalf("Authenticate() error = %v", err)
}
if result.User.PlanID != nil {
t.Fatalf("plan_id = %v, want nil", result.User.PlanID)
}
})
t.Run("reminder chỉ tạo một lần theo threshold của latest subscription", func(t *testing.T) {
tests := []struct {
name string
expiresIn time.Duration
wantDays float64
wantField string
}{
{name: "7 ngày", expiresIn: 6 * 24 * time.Hour, wantDays: 7, wantField: "reminder_7d_sent_at"},
{name: "3 ngày", expiresIn: 2 * 24 * time.Hour, wantDays: 3, wantField: "reminder_3d_sent_at"},
{name: "1 ngày", expiresIn: 12 * time.Hour, wantDays: 1, wantField: "reminder_1d_sent_at"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
db := newAuthenticatorTestDB(t)
user := model.User{ID: uuid.NewString(), Email: "reminder@example.com", Role: stringPtr("USER")}
if err := db.Create(&user).Error; err != nil {
t.Fatalf("create user: %v", err)
}
now := time.Now().UTC()
subscription := model.PlanSubscription{
ID: uuid.NewString(),
UserID: user.ID,
PaymentID: uuid.NewString(),
PlanID: uuid.NewString(),
TermMonths: 1,
PaymentMethod: "wallet",
WalletAmount: 10,
ExpiresAt: now.Add(tt.expiresIn),
StartedAt: now.Add(-24 * time.Hour),
}
if err := db.Create(&subscription).Error; err != nil {
t.Fatalf("create subscription: %v", err)
}
auth := NewAuthenticator(db, testLogger{}, "trusted-marker")
for range 2 {
if _, err := auth.Authenticate(newTrustedContext(user.ID, "USER")); err != nil {
t.Fatalf("Authenticate() error = %v", err)
}
}
var notifications []model.Notification
if err := db.Order("created_at ASC, id ASC").Find(&notifications, "user_id = ?", user.ID).Error; err != nil {
t.Fatalf("list notifications: %v", err)
}
if len(notifications) != 1 {
t.Fatalf("notification count = %d, want 1", len(notifications))
}
var payload map[string]any
if err := json.Unmarshal([]byte(*notifications[0].Metadata), &payload); err != nil {
t.Fatalf("unmarshal notification metadata: %v", err)
}
if payload["reminder_days"] != tt.wantDays {
t.Fatalf("metadata reminder_days = %v, want %v", payload["reminder_days"], tt.wantDays)
}
var latest model.PlanSubscription
if err := db.First(&latest, "id = ?", subscription.ID).Error; err != nil {
t.Fatalf("load subscription: %v", err)
}
switch tt.wantField {
case "reminder_7d_sent_at":
if latest.Reminder7DSentAt == nil {
t.Fatal("expected reminder_7d_sent_at to be set")
}
case "reminder_3d_sent_at":
if latest.Reminder3DSentAt == nil {
t.Fatal("expected reminder_3d_sent_at to be set")
}
case "reminder_1d_sent_at":
if latest.Reminder1DSentAt == nil {
t.Fatal("expected reminder_1d_sent_at to be set")
}
}
})
}
})
}
func stringPtr(v string) *string { return &v }