Add unit tests for player configurations and referral system

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

1
.gitignore vendored
View File

@@ -30,6 +30,5 @@ go.work.sum
# OS-specific junk # OS-specific junk
.DS_Store .DS_Store
Thumbs.db Thumbs.db
server
# Module cache (if you're using GOPATH, which is rare now) # Module cache (if you're using GOPATH, which is rare now)
Godeps/ Godeps/

74
cmd/server/main.go Normal file
View File

@@ -0,0 +1,74 @@
package main
import (
"context"
"log"
"net"
"os"
"os/signal"
"syscall"
"stream.api/internal/adapters/redis"
"stream.api/internal/config"
"stream.api/internal/database/query"
"stream.api/internal/transport/grpc"
"stream.api/pkg/database"
"stream.api/pkg/logger"
)
func main() {
// 1. Load Config
cfg, err := config.LoadConfig()
if err != nil {
// Use default if env/file issues, usually LoadConfig returns error only on serious issues
// But here if it returns error we might want to panic
log.Fatalf("Failed to load config: %v", err)
}
// 2. Connect DB
db, err := database.Connect(cfg.Database.DSN)
if err != nil {
log.Fatalf("Failed to connect to database: %v", err)
}
// Initialize generated query
query.SetDefault(db)
// TODO: Tách database migration ra luồng riêng nếu cần.
// 3. Connect Redis (Cache Interface)
rdb, err := redis.NewAdapter(cfg.Redis.Addr, cfg.Redis.Password, cfg.Redis.DB)
if err != nil {
log.Fatalf("Failed to connect to redis: %v", err)
}
defer rdb.Close() // Ensure we close cache on exit
// 4. Initialize Components
appLogger := logger.NewLogger(cfg.Server.Mode)
module, err := grpc.NewGRPCModule(context.Background(), cfg, db, rdb, appLogger)
if err != nil {
log.Fatalf("Failed to setup gRPC runtime module: %v", err)
}
grpcListener, err := net.Listen("tcp", ":"+cfg.Server.GRPCPort)
if err != nil {
log.Fatalf("Failed to listen on gRPC port %s: %v", cfg.Server.GRPCPort, err)
}
go func() {
log.Printf("Starting gRPC server on port %s", cfg.Server.GRPCPort)
if err := module.ServeGRPC(grpcListener); err != nil {
log.Fatalf("Failed to run gRPC server: %v", err)
}
}()
quit := make(chan os.Signal, 1)
signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM)
<-quit
log.Println("Shutting down gRPC server...")
module.Shutdown()
_ = grpcListener.Close()
log.Println("Server exiting")
}

5
go.mod
View File

@@ -63,7 +63,6 @@ require (
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
github.com/rogpeppe/go-internal v1.14.1 // indirect github.com/rogpeppe/go-internal v1.14.1 // indirect
github.com/sagikazarmark/locafero v0.11.0 // indirect github.com/sagikazarmark/locafero v0.11.0 // indirect
github.com/sony/gobreaker v1.0.0
github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8 // indirect github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8 // indirect
github.com/spf13/afero v1.15.0 // indirect github.com/spf13/afero v1.15.0 // indirect
github.com/spf13/cast v1.10.0 // indirect github.com/spf13/cast v1.10.0 // indirect
@@ -79,11 +78,11 @@ require (
golang.org/x/text v0.33.0 // indirect golang.org/x/text v0.33.0 // indirect
golang.org/x/tools v0.41.0 // indirect golang.org/x/tools v0.41.0 // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20251202230838-ff82c1b0f217 // indirect google.golang.org/genproto/googleapis/rpc v0.0.0-20251202230838-ff82c1b0f217 // indirect
gorm.io/datatypes v1.2.4 // indirect gorm.io/datatypes v1.2.4
gorm.io/driver/mysql v1.5.7 // indirect gorm.io/driver/mysql v1.5.7 // indirect
gorm.io/hints v1.1.0 // indirect gorm.io/hints v1.1.0 // indirect
modernc.org/libc v1.67.6 // indirect modernc.org/libc v1.67.6 // indirect
modernc.org/mathutil v1.7.1 // indirect modernc.org/mathutil v1.7.1 // indirect
modernc.org/memory v1.11.0 // indirect modernc.org/memory v1.11.0 // indirect
modernc.org/sqlite v1.46.1 // indirect modernc.org/sqlite v1.46.1
) )

26
go.sum
View File

@@ -78,10 +78,14 @@ github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek
github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps= github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps=
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs=
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg= github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k=
github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM=
github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM= github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg= github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo= github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo=
@@ -122,8 +126,6 @@ github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0t
github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc=
github.com/sagikazarmark/locafero v0.11.0 h1:1iurJgmM9G3PA/I+wWYIOw/5SyBtxapeHDcg+AAIFXc= github.com/sagikazarmark/locafero v0.11.0 h1:1iurJgmM9G3PA/I+wWYIOw/5SyBtxapeHDcg+AAIFXc=
github.com/sagikazarmark/locafero v0.11.0/go.mod h1:nVIGvgyzw595SUSUE6tvCp3YYTeHs15MvlmU87WwIik= github.com/sagikazarmark/locafero v0.11.0/go.mod h1:nVIGvgyzw595SUSUE6tvCp3YYTeHs15MvlmU87WwIik=
github.com/sony/gobreaker v1.0.0 h1:feX5fGGXSl3dYd4aHZItw+FpHLvvoaqkawKjVNiFMNQ=
github.com/sony/gobreaker v1.0.0/go.mod h1:ZKptC7FHNvhBz7dN2LGjPVBz2sZJmc0/PkyDJOjmxWY=
github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8 h1:+jumHNA0Wrelhe64i8F6HNlS8pkoyMv5sreGx2Ry5Rw= github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8 h1:+jumHNA0Wrelhe64i8F6HNlS8pkoyMv5sreGx2Ry5Rw=
github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8/go.mod h1:3n1Cwaq1E1/1lhQhtRK2ts/ZwZEhjcQeJQ1RuC6Q/8U= github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8/go.mod h1:3n1Cwaq1E1/1lhQhtRK2ts/ZwZEhjcQeJQ1RuC6Q/8U=
github.com/spf13/afero v1.15.0 h1:b/YBCLWAJdFWJTN9cLhiXXcD7mzKn9Dm86dNnfyQw1I= github.com/spf13/afero v1.15.0 h1:b/YBCLWAJdFWJTN9cLhiXXcD7mzKn9Dm86dNnfyQw1I=
@@ -216,11 +218,31 @@ gorm.io/hints v1.1.0 h1:Lp4z3rxREufSdxn4qmkK3TLDltrM10FLTHiuqwDPvXw=
gorm.io/hints v1.1.0/go.mod h1:lKQ0JjySsPBj3uslFzY3JhYDtqEwzm+G1hv8rWujB6Y= gorm.io/hints v1.1.0/go.mod h1:lKQ0JjySsPBj3uslFzY3JhYDtqEwzm+G1hv8rWujB6Y=
gorm.io/plugin/dbresolver v1.6.2 h1:F4b85TenghUeITqe3+epPSUtHH7RIk3fXr5l83DF8Pc= gorm.io/plugin/dbresolver v1.6.2 h1:F4b85TenghUeITqe3+epPSUtHH7RIk3fXr5l83DF8Pc=
gorm.io/plugin/dbresolver v1.6.2/go.mod h1:tctw63jdrOezFR9HmrKnPkmig3m5Edem9fdxk9bQSzM= gorm.io/plugin/dbresolver v1.6.2/go.mod h1:tctw63jdrOezFR9HmrKnPkmig3m5Edem9fdxk9bQSzM=
modernc.org/cc/v4 v4.27.1 h1:9W30zRlYrefrDV2JE2O8VDtJ1yPGownxciz5rrbQZis=
modernc.org/cc/v4 v4.27.1/go.mod h1:uVtb5OGqUKpoLWhqwNQo/8LwvoiEBLvZXIQ/SmO6mL0=
modernc.org/ccgo/v4 v4.30.1 h1:4r4U1J6Fhj98NKfSjnPUN7Ze2c6MnAdL0hWw6+LrJpc=
modernc.org/ccgo/v4 v4.30.1/go.mod h1:bIOeI1JL54Utlxn+LwrFyjCx2n2RDiYEaJVSrgdrRfM=
modernc.org/fileutil v1.3.40 h1:ZGMswMNc9JOCrcrakF1HrvmergNLAmxOPjizirpfqBA=
modernc.org/fileutil v1.3.40/go.mod h1:HxmghZSZVAz/LXcMNwZPA/DRrQZEVP9VX0V4LQGQFOc=
modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI=
modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito=
modernc.org/gc/v3 v3.1.1 h1:k8T3gkXWY9sEiytKhcgyiZ2L0DTyCQ/nvX+LoCljoRE=
modernc.org/gc/v3 v3.1.1/go.mod h1:HFK/6AGESC7Ex+EZJhJ2Gni6cTaYpSMmU/cT9RmlfYY=
modernc.org/goabi0 v0.2.0 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks=
modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI=
modernc.org/libc v1.67.6 h1:eVOQvpModVLKOdT+LvBPjdQqfrZq+pC39BygcT+E7OI= modernc.org/libc v1.67.6 h1:eVOQvpModVLKOdT+LvBPjdQqfrZq+pC39BygcT+E7OI=
modernc.org/libc v1.67.6/go.mod h1:JAhxUVlolfYDErnwiqaLvUqc8nfb2r6S6slAgZOnaiE= modernc.org/libc v1.67.6/go.mod h1:JAhxUVlolfYDErnwiqaLvUqc8nfb2r6S6slAgZOnaiE=
modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU= modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU=
modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg= modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg=
modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI= modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI=
modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw= modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw=
modernc.org/opt v0.1.4 h1:2kNGMRiUjrp4LcaPuLY2PzUfqM/w9N23quVwhKt5Qm8=
modernc.org/opt v0.1.4/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns=
modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w=
modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE=
modernc.org/sqlite v1.46.1 h1:eFJ2ShBLIEnUWlLy12raN0Z1plqmFX9Qe3rjQTKt6sU= modernc.org/sqlite v1.46.1 h1:eFJ2ShBLIEnUWlLy12raN0Z1plqmFX9Qe3rjQTKt6sU=
modernc.org/sqlite v1.46.1/go.mod h1:CzbrU2lSB1DKUusvwGz7rqEKIq+NUd8GWuBBZDs9/nA= modernc.org/sqlite v1.46.1/go.mod h1:CzbrU2lSB1DKUusvwGz7rqEKIq+NUd8GWuBBZDs9/nA=
modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0=
modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A=
modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=
modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM=

View File

@@ -24,7 +24,7 @@ type PlayerConfig struct {
Airplay *bool `gorm:"column:airplay;type:boolean;not null;default:true" json:"airplay"` Airplay *bool `gorm:"column:airplay;type:boolean;not null;default:true" json:"airplay"`
Chromecast *bool `gorm:"column:chromecast;type:boolean;not null;default:true" json:"chromecast"` Chromecast *bool `gorm:"column:chromecast;type:boolean;not null;default:true" json:"chromecast"`
IsActive *bool `gorm:"column:is_active;type:boolean;not null;default:true" json:"is_active"` IsActive *bool `gorm:"column:is_active;type:boolean;not null;default:true" json:"is_active"`
IsDefault bool `gorm:"column:is_default;type:boolean;not null;index:idx_player_configs_is_default,priority:1;index:idx_player_configs_user_default,priority:1" json:"is_default"` IsDefault bool `gorm:"column:is_default;type:boolean;not null;index:idx_player_configs_user_default,priority:1;index:idx_player_configs_is_default,priority:1" json:"is_default"`
CreatedAt *time.Time `gorm:"column:created_at;type:timestamp(3) without time zone;not null;default:CURRENT_TIMESTAMP" json:"created_at"` CreatedAt *time.Time `gorm:"column:created_at;type:timestamp(3) without time zone;not null;default:CURRENT_TIMESTAMP" json:"created_at"`
UpdatedAt time.Time `gorm:"column:updated_at;type:timestamp(3) without time zone;not null" json:"updated_at"` UpdatedAt time.Time `gorm:"column:updated_at;type:timestamp(3) without time zone;not null" json:"updated_at"`
Version *int64 `gorm:"column:version;type:bigint;not null;default:1;version" json:"-"` Version *int64 `gorm:"column:version;type:bigint;not null;default:1;version" json:"-"`

View File

@@ -21,16 +21,16 @@ type User struct {
GoogleID *string `gorm:"column:google_id;type:text;uniqueIndex:user_google_id_key,priority:1" json:"google_id"` GoogleID *string `gorm:"column:google_id;type:text;uniqueIndex:user_google_id_key,priority:1" json:"google_id"`
StorageUsed int64 `gorm:"column:storage_used;type:bigint;not null" json:"storage_used"` StorageUsed int64 `gorm:"column:storage_used;type:bigint;not null" json:"storage_used"`
PlanID *string `gorm:"column:plan_id;type:uuid" json:"plan_id"` PlanID *string `gorm:"column:plan_id;type:uuid" json:"plan_id"`
CreatedAt *time.Time `gorm:"column:created_at;type:timestamp(3) without time zone;not null;default:CURRENT_TIMESTAMP" json:"created_at"`
UpdatedAt time.Time `gorm:"column:updated_at;type:timestamp(3) without time zone;not null" json:"updated_at"`
Version *int64 `gorm:"column:version;type:bigint;not null;default:1;version" json:"-"`
TelegramID *string `gorm:"column:telegram_id;type:character varying" json:"telegram_id"`
ReferredByUserID *string `gorm:"column:referred_by_user_id;type:uuid;index:idx_user_referred_by_user_id,priority:1" json:"referred_by_user_id"` ReferredByUserID *string `gorm:"column:referred_by_user_id;type:uuid;index:idx_user_referred_by_user_id,priority:1" json:"referred_by_user_id"`
ReferralEligible *bool `gorm:"column:referral_eligible;type:boolean;not null;default:true" json:"referral_eligible"` ReferralEligible *bool `gorm:"column:referral_eligible;type:boolean;not null;default:true" json:"referral_eligible"`
ReferralRewardBps *int32 `gorm:"column:referral_reward_bps;type:integer" json:"referral_reward_bps"` ReferralRewardBps *int32 `gorm:"column:referral_reward_bps;type:integer" json:"referral_reward_bps"`
ReferralRewardGrantedAt *time.Time `gorm:"column:referral_reward_granted_at;type:timestamp with time zone" json:"referral_reward_granted_at"` ReferralRewardGrantedAt *time.Time `gorm:"column:referral_reward_granted_at;type:timestamp with time zone" json:"referral_reward_granted_at"`
ReferralRewardPaymentID *string `gorm:"column:referral_reward_payment_id;type:uuid" json:"referral_reward_payment_id"` ReferralRewardPaymentID *string `gorm:"column:referral_reward_payment_id;type:uuid" json:"referral_reward_payment_id"`
ReferralRewardAmount *float64 `gorm:"column:referral_reward_amount;type:numeric(65,30)" json:"referral_reward_amount"` ReferralRewardAmount *float64 `gorm:"column:referral_reward_amount;type:numeric(65,30)" json:"referral_reward_amount"`
CreatedAt *time.Time `gorm:"column:created_at;type:timestamp(3) without time zone;not null;default:CURRENT_TIMESTAMP" json:"created_at"`
UpdatedAt time.Time `gorm:"column:updated_at;type:timestamp(3) without time zone;not null" json:"updated_at"`
Version *int64 `gorm:"column:version;type:bigint;not null;default:1;version" json:"-"`
TelegramID *string `gorm:"column:telegram_id;type:character varying" json:"telegram_id"`
} }
// TableName User's table name // TableName User's table name

View File

@@ -41,6 +41,12 @@ func newUser(db *gorm.DB, opts ...gen.DOOption) user {
_user.UpdatedAt = field.NewTime(tableName, "updated_at") _user.UpdatedAt = field.NewTime(tableName, "updated_at")
_user.Version = field.NewInt64(tableName, "version") _user.Version = field.NewInt64(tableName, "version")
_user.TelegramID = field.NewString(tableName, "telegram_id") _user.TelegramID = field.NewString(tableName, "telegram_id")
_user.ReferredByUserID = field.NewString(tableName, "referred_by_user_id")
_user.ReferralEligible = field.NewBool(tableName, "referral_eligible")
_user.ReferralRewardBps = field.NewInt32(tableName, "referral_reward_bps")
_user.ReferralRewardGrantedAt = field.NewTime(tableName, "referral_reward_granted_at")
_user.ReferralRewardPaymentID = field.NewString(tableName, "referral_reward_payment_id")
_user.ReferralRewardAmount = field.NewFloat64(tableName, "referral_reward_amount")
_user.fillFieldMap() _user.fillFieldMap()
@@ -50,20 +56,26 @@ func newUser(db *gorm.DB, opts ...gen.DOOption) user {
type user struct { type user struct {
userDo userDo userDo userDo
ALL field.Asterisk ALL field.Asterisk
ID field.String ID field.String
Email field.String Email field.String
Password field.String Password field.String
Username field.String Username field.String
Avatar field.String Avatar field.String
Role field.String Role field.String
GoogleID field.String GoogleID field.String
StorageUsed field.Int64 StorageUsed field.Int64
PlanID field.String PlanID field.String
CreatedAt field.Time CreatedAt field.Time
UpdatedAt field.Time UpdatedAt field.Time
Version field.Int64 Version field.Int64
TelegramID field.String TelegramID field.String
ReferredByUserID field.String
ReferralEligible field.Bool
ReferralRewardBps field.Int32
ReferralRewardGrantedAt field.Time
ReferralRewardPaymentID field.String
ReferralRewardAmount field.Float64
fieldMap map[string]field.Expr fieldMap map[string]field.Expr
} }
@@ -93,6 +105,12 @@ func (u *user) updateTableName(table string) *user {
u.UpdatedAt = field.NewTime(table, "updated_at") u.UpdatedAt = field.NewTime(table, "updated_at")
u.Version = field.NewInt64(table, "version") u.Version = field.NewInt64(table, "version")
u.TelegramID = field.NewString(table, "telegram_id") u.TelegramID = field.NewString(table, "telegram_id")
u.ReferredByUserID = field.NewString(table, "referred_by_user_id")
u.ReferralEligible = field.NewBool(table, "referral_eligible")
u.ReferralRewardBps = field.NewInt32(table, "referral_reward_bps")
u.ReferralRewardGrantedAt = field.NewTime(table, "referral_reward_granted_at")
u.ReferralRewardPaymentID = field.NewString(table, "referral_reward_payment_id")
u.ReferralRewardAmount = field.NewFloat64(table, "referral_reward_amount")
u.fillFieldMap() u.fillFieldMap()
@@ -117,7 +135,7 @@ func (u *user) GetFieldByName(fieldName string) (field.OrderExpr, bool) {
} }
func (u *user) fillFieldMap() { func (u *user) fillFieldMap() {
u.fieldMap = make(map[string]field.Expr, 13) u.fieldMap = make(map[string]field.Expr, 19)
u.fieldMap["id"] = u.ID u.fieldMap["id"] = u.ID
u.fieldMap["email"] = u.Email u.fieldMap["email"] = u.Email
u.fieldMap["password"] = u.Password u.fieldMap["password"] = u.Password
@@ -131,6 +149,12 @@ func (u *user) fillFieldMap() {
u.fieldMap["updated_at"] = u.UpdatedAt u.fieldMap["updated_at"] = u.UpdatedAt
u.fieldMap["version"] = u.Version u.fieldMap["version"] = u.Version
u.fieldMap["telegram_id"] = u.TelegramID u.fieldMap["telegram_id"] = u.TelegramID
u.fieldMap["referred_by_user_id"] = u.ReferredByUserID
u.fieldMap["referral_eligible"] = u.ReferralEligible
u.fieldMap["referral_reward_bps"] = u.ReferralRewardBps
u.fieldMap["referral_reward_granted_at"] = u.ReferralRewardGrantedAt
u.fieldMap["referral_reward_payment_id"] = u.ReferralRewardPaymentID
u.fieldMap["referral_reward_amount"] = u.ReferralRewardAmount
} }
func (u user) clone(db *gorm.DB) user { func (u user) clone(db *gorm.DB) user {

View File

@@ -1,3 +1,4 @@
// update lại test sau nhé.
package service package service
import ( import (
@@ -8,7 +9,6 @@ import (
"time" "time"
"github.com/google/uuid" "github.com/google/uuid"
goredis "github.com/redis/go-redis/v9"
"google.golang.org/grpc" "google.golang.org/grpc"
"google.golang.org/grpc/credentials/insecure" "google.golang.org/grpc/credentials/insecure"
"google.golang.org/grpc/metadata" "google.golang.org/grpc/metadata"
@@ -21,7 +21,6 @@ import (
"stream.api/internal/database/query" "stream.api/internal/database/query"
"stream.api/internal/middleware" "stream.api/internal/middleware"
"stream.api/pkg/logger" "stream.api/pkg/logger"
"stream.api/pkg/token"
) )
const testTrustedMarker = "trusted-test-marker" const testTrustedMarker = "trusted-test-marker"
@@ -69,26 +68,7 @@ func (f *fakeCache) Close() error {
return nil return nil
} }
func (fakeTokenProvider) GenerateTokenPair(userID, _, _ string) (*token.TokenPair, error) { // var _ goredis.Client = (*fakeCache)(nil)
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 _ goredis.Client = (*fakeCache)(nil)
func newTestDB(t *testing.T) *gorm.DB { func newTestDB(t *testing.T) *gorm.DB {
t.Helper() t.Helper()
@@ -244,11 +224,10 @@ func newTestAppServices(t *testing.T, db *gorm.DB) *appServices {
} }
return &appServices{ return &appServices{
db: db, db: db,
logger: testLogger{}, logger: testLogger{},
authenticator: middleware.NewAuthenticator(db, testLogger{}, testTrustedMarker), authenticator: middleware.NewAuthenticator(db, testLogger{}, testTrustedMarker),
cache: &fakeCache{values: map[string]string{}}, // cache: &fakeCache{values: map[string]string{}},
tokenProvider: fakeTokenProvider{},
googleUserInfoURL: defaultGoogleUserInfoURL, googleUserInfoURL: defaultGoogleUserInfoURL,
} }
} }

View File

@@ -38,10 +38,6 @@ func (s *appServices) Login(ctx context.Context, req *appv1.LoginRequest) (*appv
return nil, status.Error(codes.Unauthenticated, "Invalid credentials") return nil, status.Error(codes.Unauthenticated, "Invalid credentials")
} }
if err := s.issueSessionCookies(ctx, user); err != nil {
return nil, err
}
payload, err := buildUserPayload(ctx, s.db, user) payload, err := buildUserPayload(ctx, s.db, user)
if err != nil { if err != nil {
return nil, status.Error(codes.Internal, "Failed to build user payload") return nil, status.Error(codes.Internal, "Failed to build user payload")
@@ -304,10 +300,6 @@ func (s *appServices) CompleteGoogleLogin(ctx context.Context, req *appv1.Comple
} }
} }
if err := s.issueSessionCookies(ctx, user); err != nil {
return nil, status.Error(codes.Internal, "session_failed")
}
payload, err := buildUserPayload(ctx, s.db, user) payload, err := buildUserPayload(ctx, s.db, user)
if err != nil { if err != nil {
return nil, status.Error(codes.Internal, "Failed to build user payload") return nil, status.Error(codes.Internal, "Failed to build user payload")

View File

@@ -6,15 +6,14 @@ import (
"golang.org/x/oauth2" "golang.org/x/oauth2"
"golang.org/x/oauth2/google" "golang.org/x/oauth2/google"
"gorm.io/gorm" "gorm.io/gorm"
"stream.api/internal/adapters/redis"
appv1 "stream.api/internal/api/proto/app/v1" appv1 "stream.api/internal/api/proto/app/v1"
"stream.api/internal/config" "stream.api/internal/config"
"stream.api/internal/database/model" "stream.api/internal/database/model"
"stream.api/internal/middleware" "stream.api/internal/middleware"
"stream.api/internal/video" "stream.api/internal/video"
"stream.api/internal/video/runtime/adapters/queue/redis"
"stream.api/pkg/logger" "stream.api/pkg/logger"
"stream.api/pkg/storage" "stream.api/pkg/storage"
"stream.api/pkg/token"
) )
const adTemplateUpgradeRequiredMessage = "Upgrade required to manage Ads & VAST" const adTemplateUpgradeRequiredMessage = "Upgrade required to manage Ads & VAST"
@@ -73,7 +72,6 @@ type appServices struct {
db *gorm.DB db *gorm.DB
logger logger.Logger logger logger.Logger
authenticator *middleware.Authenticator authenticator *middleware.Authenticator
tokenProvider token.Provider
cache *redis.RedisAdapter cache *redis.RedisAdapter
storageProvider storage.Provider storageProvider storage.Provider
videoService *video.Service videoService *video.Service

View File

@@ -959,39 +959,7 @@ func buildPaymentSubscription(input paymentExecutionInput, paymentRecord *model.
ExpiresAt: newExpiry, ExpiresAt: newExpiry,
} }
} }
func (s *appServices) issueSessionCookies(ctx context.Context, user *model.User) error {
if user == nil {
return status.Error(codes.Unauthenticated, "Unauthorized")
}
tokenPair, err := s.tokenProvider.GenerateTokenPair(user.ID, user.Email, safeRole(user.Role))
if err != nil {
s.logger.Error("Token generation failed", "error", err)
return status.Error(codes.Internal, "Error generating tokens")
}
if err := s.cache.Set(ctx, "refresh_uuid:"+tokenPair.RefreshUUID, user.ID, time.Until(time.Unix(tokenPair.RtExpires, 0))); err != nil {
s.logger.Error("Session storage failed", "error", err)
return status.Error(codes.Internal, "Error storing session")
}
if err := grpc.SetHeader(ctx, metadata.Pairs(
"set-cookie", buildTokenCookie("access_token", tokenPair.AccessToken, int(tokenPair.AtExpires-time.Now().Unix())),
"set-cookie", buildTokenCookie("refresh_token", tokenPair.RefreshToken, int(tokenPair.RtExpires-time.Now().Unix())),
)); err != nil {
s.logger.Error("Failed to set gRPC auth headers", "error", err)
}
return nil
}
func buildTokenCookie(name string, value string, maxAge int) string {
return (&http.Cookie{
Name: name,
Value: value,
Path: "/",
MaxAge: maxAge,
HttpOnly: true,
}).String()
}
func messageResponse(message string) *appv1.MessageResponse { func messageResponse(message string) *appv1.MessageResponse {
return &appv1.MessageResponse{Message: message} return &appv1.MessageResponse{Message: message}
} }

View File

@@ -1 +0,0 @@
package grpc

View File

@@ -6,11 +6,11 @@ import (
grpcpkg "google.golang.org/grpc" grpcpkg "google.golang.org/grpc"
"gorm.io/gorm" "gorm.io/gorm"
redisadapter "stream.api/internal/adapters/redis"
"stream.api/internal/config" "stream.api/internal/config"
"stream.api/internal/service" "stream.api/internal/service"
"stream.api/internal/video" "stream.api/internal/video"
runtime "stream.api/internal/video/runtime" runtime "stream.api/internal/video/runtime"
redisadapter "stream.api/internal/video/runtime/adapters/queue/redis"
runtimegrpc "stream.api/internal/video/runtime/grpc" runtimegrpc "stream.api/internal/video/runtime/grpc"
"stream.api/internal/video/runtime/services" "stream.api/internal/video/runtime/services"
"stream.api/pkg/logger" "stream.api/pkg/logger"

View File

@@ -1,33 +1,15 @@
package video package video
import ( import (
"context"
runtimedomain "stream.api/internal/video/runtime/domain"
runtimeservices "stream.api/internal/video/runtime/services" runtimeservices "stream.api/internal/video/runtime/services"
) )
type Job = runtimedomain.Job
type AgentWithStats = runtimeservices.AgentWithStats type AgentWithStats = runtimeservices.AgentWithStats
type PaginatedJobs = runtimeservices.PaginatedJobs type PaginatedJobs = runtimeservices.PaginatedJobs
var ErrInvalidJobCursor = runtimeservices.ErrInvalidJobCursor var ErrInvalidJobCursor = runtimeservices.ErrInvalidJobCursor
type JobService interface {
CreateJob(ctx context.Context, userID string, videoID string, name string, config []byte, priority int, timeLimit int64) (*Job, error)
ListJobs(ctx context.Context, offset, limit int) (*PaginatedJobs, error)
ListJobsByAgent(ctx context.Context, agentID string, offset, limit int) (*PaginatedJobs, error)
ListJobsByCursor(ctx context.Context, agentID string, cursor string, pageSize int) (*PaginatedJobs, error)
GetJob(ctx context.Context, id string) (*Job, error)
CancelJob(ctx context.Context, id string) error
RetryJob(ctx context.Context, id string) (*Job, error)
SubscribeJobLogs(ctx context.Context, jobID string) (<-chan runtimedomain.LogEntry, error)
SubscribeJobUpdates(ctx context.Context) (<-chan string, error)
SubscribeSystemResources(ctx context.Context) (<-chan runtimedomain.SystemResource, error)
}
type AgentRuntime interface { type AgentRuntime interface {
ListAgentsWithStats() []*AgentWithStats ListAgentsWithStats() []*AgentWithStats
SendCommand(agentID string, cmd string) bool SendCommand(agentID string, cmd string) bool

View File

@@ -1,7 +1,5 @@
package domain package domain
import "time"
type JobStatus string type JobStatus string
const ( const (
@@ -11,28 +9,3 @@ const (
JobStatusFailure JobStatus = "failure" JobStatusFailure JobStatus = "failure"
JobStatusCancelled JobStatus = "cancelled" JobStatusCancelled JobStatus = "cancelled"
) )
type Job struct {
ID string `json:"id" gorm:"primaryKey;type:uuid;default:gen_random_uuid()"`
Status JobStatus `json:"status" gorm:"type:varchar(32);index"`
Priority int `json:"priority" gorm:"default:0;index"`
UserID string `json:"user_id" gorm:"index"`
VideoID string `json:"video_id,omitempty"`
Name string `json:"name"`
TimeLimit int64 `json:"time_limit"`
InputURL string `json:"input_url"`
OutputURL string `json:"output_url"`
TotalDuration int64 `json:"total_duration"`
CurrentTime int64 `json:"current_time"`
Progress float64 `json:"progress"`
AgentID *string `json:"agent_id" gorm:"type:uuid;index"`
Logs string `json:"logs" gorm:"type:text"`
Config string `json:"config" gorm:"type:text"`
Cancelled bool `json:"cancelled" gorm:"default:false"`
RetryCount int `json:"retry_count" gorm:"default:0"`
MaxRetries int `json:"max_retries" gorm:"default:3"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
func (Job) TableName() string { return "render_jobs" }

View File

@@ -1,76 +0,0 @@
//go:build ignore
// +build ignore
package runtime
import (
"net/http"
"time"
"github.com/gin-gonic/gin"
"github.com/prometheus/client_golang/prometheus"
"github.com/prometheus/client_golang/prometheus/promhttp"
)
func (m *Module) MetricsHandler() gin.HandlerFunc {
return gin.WrapH(promhttp.Handler())
}
// HandleLive godoc
// @Summary Liveness health check
// @Description Returns liveness status for the API and render module
// @Tags health
// @Produce json
// @Success 200 {object} map[string]string
// @Failure 503 {object} map[string]string
// @Router /health/live [get]
func (m *Module) HandleLive(c *gin.Context) {
status, code := m.healthService.SimpleHealthCheck(c.Request.Context())
c.JSON(code, gin.H{"status": status})
}
// HandleReady godoc
// @Summary Readiness health check
// @Description Returns readiness status including render gRPC availability flag
// @Tags health
// @Produce json
// @Success 200 {object} map[string]interface{}
// @Failure 503 {object} map[string]interface{}
// @Router /health/ready [get]
func (m *Module) HandleReady(c *gin.Context) {
status, code := m.healthService.SimpleHealthCheck(c.Request.Context())
c.JSON(code, gin.H{"status": status, "grpc_enabled": m.grpcServer != nil})
}
// HandleDetailed godoc
// @Summary Detailed health check
// @Description Returns detailed health state for database, redis, and render dependencies
// @Tags health
// @Produce json
// @Success 200 {object} services.HealthReport
// @Router /health/detailed [get]
func (m *Module) HandleDetailed(c *gin.Context) {
c.JSON(http.StatusOK, m.healthService.CheckHealth(c.Request.Context()))
}
var (
httpRequests = prometheus.NewCounterVec(prometheus.CounterOpts{Name: "stream_api_http_requests_total", Help: "Total HTTP requests."}, []string{"method", "path", "status"})
httpDuration = prometheus.NewHistogramVec(prometheus.HistogramOpts{Name: "stream_api_http_request_duration_seconds", Help: "HTTP request duration."}, []string{"method", "path"})
)
func init() {
prometheus.MustRegister(httpRequests, httpDuration)
}
func MetricsMiddleware() gin.HandlerFunc {
return func(c *gin.Context) {
start := time.Now()
c.Next()
path := c.FullPath()
if path == "" {
path = c.Request.URL.Path
}
httpRequests.WithLabelValues(c.Request.Method, path, http.StatusText(c.Writer.Status())).Inc()
httpDuration.WithLabelValues(c.Request.Method, path).Observe(time.Since(start).Seconds())
}
}

View File

@@ -1,26 +0,0 @@
package token
// TokenPair contains the access and refresh tokens
type TokenPair struct {
AccessToken string
RefreshToken string
AccessUUID string
RefreshUUID string
AtExpires int64
RtExpires int64
}
// Claims defines the JWT claims (User)
type Claims struct {
UserID string `json:"user_id"`
Email string `json:"email"`
Role string `json:"role"`
TokenID string `json:"token_id"`
}
// Provider defines the interface for token operations
type Provider interface {
GenerateTokenPair(userID, email, role string) (*TokenPair, error)
ParseToken(tokenString string) (*Claims, error)
ParseMapToken(tokenString string) (map[string]interface{}, error)
}