对接
This commit is contained in:
@@ -24,6 +24,7 @@ var (
|
||||
ErrCategoryDisabled = kratoserrors.BadRequest("LIVE_CATEGORY_DISABLED", "category is disabled")
|
||||
ErrRoomCategoryLocked = kratoserrors.BadRequest("LIVE_ROOM_CATEGORY_LOCKED", "live room category cannot be changed while broadcasting")
|
||||
ErrRoomInvalidArgument = kratoserrors.BadRequest("LIVE_ROOM_INVALID_ARGUMENT", "invalid live room data")
|
||||
ErrRoomDisabled = kratoserrors.BadRequest("LIVE_ROOM_DISABLED", "live room is disabled")
|
||||
ErrRoomNotLive = kratoserrors.BadRequest("LIVE_ROOM_NOT_LIVE", "live room is not broadcasting")
|
||||
ErrAnchorNotFound = kratoserrors.BadRequest("LIVE_ANCHOR_NOT_FOUND", "anchor user not found")
|
||||
)
|
||||
@@ -152,8 +153,8 @@ func (uc *RoomUsecase) List(ctx context.Context, filter RoomFilter, options List
|
||||
filter.Name = strings.TrimSpace(filter.Name)
|
||||
filter.AnchorName = strings.TrimSpace(filter.AnchorName)
|
||||
options = normalizeListOptions(options)
|
||||
if filter.Status == RoomStatusLive || filter.Status == RoomStatusOffline {
|
||||
return uc.listByRuntimeStatus(ctx, filter, options)
|
||||
if filter.Status == RoomStatusLive || filter.Status == RoomStatusOffline || ordersByViewerCount(options.OrderBy) {
|
||||
return uc.listByRuntime(ctx, filter, options)
|
||||
}
|
||||
rooms, total, err := uc.repo.ListRooms(ctx, filter, options)
|
||||
if err != nil {
|
||||
@@ -162,10 +163,15 @@ func (uc *RoomUsecase) List(ctx context.Context, filter RoomFilter, options List
|
||||
return uc.attachRuntime(ctx, rooms), total, nil
|
||||
}
|
||||
|
||||
func (uc *RoomUsecase) listByRuntimeStatus(ctx context.Context, filter RoomFilter, options ListOptions) ([]*Room, int64, error) {
|
||||
func (uc *RoomUsecase) listByRuntime(ctx context.Context, filter RoomFilter, options ListOptions) ([]*Room, int64, error) {
|
||||
all := make([]*Room, 0)
|
||||
storageOrder := options.OrderBy
|
||||
if ordersByViewerCount(storageOrder) {
|
||||
// Runtime sorting happens after loading, so keep storage batches stable.
|
||||
storageOrder = "id_asc"
|
||||
}
|
||||
for offset := 0; ; offset += 100 {
|
||||
batch, total, err := uc.repo.ListRooms(ctx, filter, ListOptions{Offset: offset, Limit: 100, OrderBy: options.OrderBy})
|
||||
batch, total, err := uc.repo.ListRooms(ctx, filter, ListOptions{Offset: offset, Limit: 100, OrderBy: storageOrder})
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
@@ -176,12 +182,13 @@ func (uc *RoomUsecase) listByRuntimeStatus(ctx context.Context, filter RoomFilte
|
||||
}
|
||||
all = uc.attachRuntime(ctx, all)
|
||||
matched := make([]*Room, 0, len(all))
|
||||
filterRuntimeStatus := filter.Status == RoomStatusLive || filter.Status == RoomStatusOffline
|
||||
for _, room := range all {
|
||||
if room.Status == filter.Status {
|
||||
if !filterRuntimeStatus || room.Status == filter.Status {
|
||||
matched = append(matched, room)
|
||||
}
|
||||
}
|
||||
if strings.EqualFold(options.OrderBy, "viewer_count_asc") || strings.EqualFold(options.OrderBy, "viewer_count_desc") {
|
||||
if ordersByViewerCount(options.OrderBy) {
|
||||
sort.SliceStable(matched, func(i, j int) bool {
|
||||
if matched[i].ViewerCount == matched[j].ViewerCount {
|
||||
return matched[i].ID < matched[j].ID
|
||||
@@ -203,6 +210,10 @@ func (uc *RoomUsecase) listByRuntimeStatus(ctx context.Context, filter RoomFilte
|
||||
return matched[options.Offset:end], total, nil
|
||||
}
|
||||
|
||||
func ordersByViewerCount(order string) bool {
|
||||
return strings.EqualFold(order, "viewer_count_asc") || strings.EqualFold(order, "viewer_count_desc")
|
||||
}
|
||||
|
||||
func (uc *RoomUsecase) Get(ctx context.Context, id uint) (*Room, error) {
|
||||
if id == 0 {
|
||||
return nil, ErrRoomNotFound
|
||||
@@ -219,15 +230,15 @@ func (uc *RoomUsecase) GetPublic(ctx context.Context, id uint) (*Room, error) {
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !room.Enabled || room.Status != RoomStatusLive {
|
||||
return nil, ErrRoomNotLive
|
||||
if !room.Enabled {
|
||||
return nil, ErrRoomDisabled
|
||||
}
|
||||
category, err := uc.repo.GetCategory(ctx, room.CategoryID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !category.Enabled {
|
||||
return nil, ErrRoomNotLive
|
||||
return nil, ErrCategoryDisabled
|
||||
}
|
||||
return room, nil
|
||||
}
|
||||
@@ -237,7 +248,14 @@ func (uc *RoomUsecase) IsLive(ctx context.Context, id uint) (bool, error) {
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
return room.Enabled && room.Status == RoomStatusLive, nil
|
||||
if !room.Enabled || room.Status != RoomStatusLive {
|
||||
return false, nil
|
||||
}
|
||||
category, err := uc.repo.GetCategory(ctx, room.CategoryID)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
return category.Enabled, nil
|
||||
}
|
||||
|
||||
func (uc *RoomUsecase) Create(ctx context.Context, room *Room) (*Room, error) {
|
||||
|
||||
@@ -40,6 +40,12 @@ func (r *catalogRepoStub) ListRooms(_ context.Context, filter RoomFilter, option
|
||||
if filter.Enabled != nil && room.Enabled != *filter.Enabled {
|
||||
continue
|
||||
}
|
||||
if filter.Status == RoomStatusDisabled && room.Enabled {
|
||||
continue
|
||||
}
|
||||
if (filter.Status == RoomStatusLive || filter.Status == RoomStatusOffline) && !room.Enabled {
|
||||
continue
|
||||
}
|
||||
if filter.CategoryEnabled != nil {
|
||||
category, err := r.GetCategory(context.Background(), room.CategoryID)
|
||||
if err != nil || category.Enabled != *filter.CategoryEnabled {
|
||||
@@ -105,19 +111,26 @@ func TestRoomUsecaseFiltersSortsAndPaginatesLiveRooms(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestRoomUsecaseIsLiveRequiresEnabledOnlinePublisher(t *testing.T) {
|
||||
catalog := &catalogRepoStub{rooms: []*Room{
|
||||
{ID: 1, Enabled: true},
|
||||
{ID: 2, Enabled: true},
|
||||
{ID: 3, Enabled: false},
|
||||
}}
|
||||
func TestRoomUsecaseIsLiveRequiresEnabledRoomCategoryAndOnlinePublisher(t *testing.T) {
|
||||
catalog := &catalogRepoStub{
|
||||
categories: []*Category{{ID: 1, Enabled: true}, {ID: 2, Enabled: false}},
|
||||
rooms: []*Room{
|
||||
{ID: 1, CategoryID: 1, Enabled: true},
|
||||
{ID: 2, CategoryID: 1, Enabled: true, ReplayURL: "https://example.test/replay.mp4"},
|
||||
{ID: 3, CategoryID: 1, Enabled: false},
|
||||
{ID: 4, CategoryID: 2, Enabled: true},
|
||||
{ID: 5, CategoryID: 3, Enabled: true},
|
||||
},
|
||||
}
|
||||
streams := NewStreamUsecase(&testRepo{streams: map[string]*Stream{
|
||||
"room-1": {StreamID: "room-1", PublisherOnline: true},
|
||||
"room-3": {StreamID: "room-3", PublisherOnline: true},
|
||||
"room-4": {StreamID: "room-4", PublisherOnline: true},
|
||||
"room-5": {StreamID: "room-5", PublisherOnline: true},
|
||||
}})
|
||||
rooms := NewRoomUsecase(catalog, streams)
|
||||
|
||||
for roomID, want := range map[uint]bool{1: true, 2: false, 3: false} {
|
||||
for roomID, want := range map[uint]bool{1: true, 2: false, 3: false, 4: false} {
|
||||
got, err := rooms.IsLive(context.Background(), roomID)
|
||||
if err != nil {
|
||||
t.Fatalf("IsLive(%d) error = %v", roomID, err)
|
||||
@@ -126,16 +139,21 @@ func TestRoomUsecaseIsLiveRequiresEnabledOnlinePublisher(t *testing.T) {
|
||||
t.Errorf("IsLive(%d) = %v, want %v", roomID, got, want)
|
||||
}
|
||||
}
|
||||
if live, err := rooms.IsLive(context.Background(), 5); live || !errors.Is(err, ErrCategoryNotFound) {
|
||||
t.Fatalf("missing category IsLive(5) = %v, err=%v", live, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRoomUsecaseGetPublicRequiresLiveRoomAndEnabledCategory(t *testing.T) {
|
||||
func TestRoomUsecaseGetPublicIncludesOfflineRoomsAndRequiresEnabledCategory(t *testing.T) {
|
||||
catalog := &catalogRepoStub{
|
||||
categories: []*Category{{ID: 1, Enabled: true}, {ID: 2, Enabled: false}},
|
||||
rooms: []*Room{
|
||||
{ID: 1, CategoryID: 1, Enabled: true},
|
||||
{ID: 2, CategoryID: 1, Enabled: true},
|
||||
{ID: 2, CategoryID: 1, Enabled: true, ReplayURL: "https://example.test/replay.mp4"},
|
||||
{ID: 3, CategoryID: 1, Enabled: false},
|
||||
{ID: 4, CategoryID: 2, Enabled: true},
|
||||
{ID: 5, CategoryID: 1, Enabled: true},
|
||||
{ID: 6, CategoryID: 3, Enabled: true},
|
||||
},
|
||||
}
|
||||
streams := NewStreamUsecase(&testRepo{streams: map[string]*Stream{
|
||||
@@ -145,16 +163,106 @@ func TestRoomUsecaseGetPublicRequiresLiveRoomAndEnabledCategory(t *testing.T) {
|
||||
}})
|
||||
rooms := NewRoomUsecase(catalog, streams)
|
||||
|
||||
item, err := rooms.GetPublic(context.Background(), 1)
|
||||
if err != nil || item.ID != 1 || item.Status != RoomStatusLive {
|
||||
t.Fatalf("GetPublic(1) item=%+v err=%v", item, err)
|
||||
}
|
||||
for _, roomID := range []uint{2, 3, 4} {
|
||||
if _, err = rooms.GetPublic(context.Background(), roomID); !errors.Is(err, ErrRoomNotLive) {
|
||||
t.Errorf("GetPublic(%d) error=%v, want %v", roomID, err, ErrRoomNotLive)
|
||||
for _, test := range []struct {
|
||||
id uint
|
||||
status string
|
||||
replay string
|
||||
}{
|
||||
{id: 1, status: RoomStatusLive},
|
||||
{id: 2, status: RoomStatusOffline, replay: "https://example.test/replay.mp4"},
|
||||
{id: 5, status: RoomStatusOffline},
|
||||
} {
|
||||
item, err := rooms.GetPublic(context.Background(), test.id)
|
||||
if err != nil || item.ID != test.id || item.Status != test.status || item.ReplayURL != test.replay {
|
||||
t.Fatalf("GetPublic(%d) item=%+v err=%v", test.id, item, err)
|
||||
}
|
||||
if _, err = rooms.Get(context.Background(), roomID); err != nil {
|
||||
}
|
||||
for roomID, want := range map[uint]error{0: ErrRoomNotFound, 3: ErrRoomDisabled, 4: ErrCategoryDisabled, 6: ErrCategoryNotFound, 99: ErrRoomNotFound} {
|
||||
if _, err := rooms.GetPublic(context.Background(), roomID); !errors.Is(err, want) {
|
||||
t.Errorf("GetPublic(%d) error=%v, want %v", roomID, err, want)
|
||||
}
|
||||
}
|
||||
for _, roomID := range []uint{3, 4, 6} {
|
||||
if _, err := rooms.Get(context.Background(), roomID); err != nil {
|
||||
t.Errorf("admin Get(%d) error=%v", roomID, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestRoomUsecaseListsPublicRoomsByRuntimeBeforePagination(t *testing.T) {
|
||||
catalog := &catalogRepoStub{
|
||||
categories: []*Category{{ID: 1, Enabled: true}, {ID: 2, Enabled: false}, {ID: 3, Enabled: true}},
|
||||
rooms: []*Room{
|
||||
{ID: 1, CategoryID: 1, Enabled: true, ReplayURL: "https://example.test/replay.mp4"},
|
||||
{ID: 2, CategoryID: 1, Enabled: true},
|
||||
{ID: 3, CategoryID: 1, Enabled: true},
|
||||
{ID: 4, CategoryID: 1, Enabled: false},
|
||||
{ID: 5, CategoryID: 2, Enabled: true},
|
||||
{ID: 6, CategoryID: 1, Enabled: true},
|
||||
{ID: 7, CategoryID: 3, Enabled: true},
|
||||
},
|
||||
}
|
||||
streams := NewStreamUsecase(&testRepo{streams: map[string]*Stream{
|
||||
"room-2": {StreamID: "room-2", PublisherOnline: true, SubscriberCount: 100},
|
||||
"room-3": {StreamID: "room-3", PublisherOnline: true, SubscriberCount: 200},
|
||||
"room-4": {StreamID: "room-4", PublisherOnline: true, SubscriberCount: 10000},
|
||||
"room-5": {StreamID: "room-5", PublisherOnline: true, SubscriberCount: 1000},
|
||||
"room-7": {StreamID: "room-7", PublisherOnline: true, SubscriberCount: 50},
|
||||
}})
|
||||
rooms := NewRoomUsecase(catalog, streams)
|
||||
for _, test := range []struct {
|
||||
name string
|
||||
status string
|
||||
categoryID uint
|
||||
order string
|
||||
offset int
|
||||
limit int
|
||||
total int64
|
||||
want []uint
|
||||
}{
|
||||
{name: "all default", limit: 10, total: 5, want: []uint{1, 2, 3, 6, 7}},
|
||||
{name: "all popular", order: "viewer_count_desc", limit: 10, total: 5, want: []uint{3, 2, 7, 1, 6}},
|
||||
{name: "all popular page", order: "viewer_count_desc", offset: 1, limit: 2, total: 5, want: []uint{2, 7}},
|
||||
{name: "all ascending", order: "VIEWER_COUNT_ASC", limit: 10, total: 5, want: []uint{1, 6, 7, 2, 3}},
|
||||
{name: "live only", status: RoomStatusLive, order: "viewer_count_desc", limit: 10, total: 3, want: []uint{3, 2, 7}},
|
||||
{name: "offline replay and empty", status: RoomStatusOffline, order: "viewer_count_desc", limit: 10, total: 2, want: []uint{1, 6}},
|
||||
{name: "category filter", categoryID: 1, order: "viewer_count_desc", limit: 10, total: 4, want: []uint{3, 2, 1, 6}},
|
||||
{name: "disabled excluded", status: RoomStatusDisabled, order: "viewer_count_desc", limit: 10},
|
||||
{name: "past final page", order: "viewer_count_desc", offset: 5, limit: 2, total: 5},
|
||||
} {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
enabled := true
|
||||
items, total, err := rooms.List(context.Background(), RoomFilter{Status: test.status, CategoryID: test.categoryID, Enabled: &enabled, CategoryEnabled: &enabled}, ListOptions{Offset: test.offset, Limit: test.limit, OrderBy: test.order})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if total != test.total || len(items) != len(test.want) {
|
||||
t.Fatalf("total=%d items=%+v, want total=%d ids=%v", total, items, test.total, test.want)
|
||||
}
|
||||
for index, id := range test.want {
|
||||
if items[index].ID != id {
|
||||
t.Fatalf("item[%d]=%d, want %d", index, items[index].ID, id)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestRoomUsecasePopularSortIncludesRoomsBeyondStorageBatch(t *testing.T) {
|
||||
catalog := &catalogRepoStub{categories: []*Category{{ID: 1, Enabled: true}}}
|
||||
for id := uint(1); id <= 105; id++ {
|
||||
catalog.rooms = append(catalog.rooms, &Room{ID: id, CategoryID: 1, Enabled: true})
|
||||
}
|
||||
streams := NewStreamUsecase(&testRepo{streams: map[string]*Stream{
|
||||
"room-105": {StreamID: "room-105", PublisherOnline: true, SubscriberCount: 500},
|
||||
}})
|
||||
rooms := NewRoomUsecase(catalog, streams)
|
||||
enabled := true
|
||||
items, total, err := rooms.List(context.Background(), RoomFilter{Enabled: &enabled, CategoryEnabled: &enabled}, ListOptions{Limit: 2, OrderBy: "viewer_count_desc"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if total != 105 || len(items) != 2 || items[0].ID != 105 || items[1].ID != 1 {
|
||||
t.Fatalf("popular rooms total=%d items=%+v", total, items)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,11 +4,11 @@ import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/clause"
|
||||
bizgift "kra/internal/biz/gift"
|
||||
"kra/internal/data/ledger"
|
||||
"kra/internal/utils/serial"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
@@ -26,7 +26,7 @@ type giftPO struct {
|
||||
Description string `gorm:"size:255"`
|
||||
Tiers string `gorm:"type:text"`
|
||||
Sort int `gorm:"default:0;index"`
|
||||
Enabled bool `gorm:"default:true;index"`
|
||||
Enabled bool `gorm:"index"`
|
||||
}
|
||||
|
||||
func (giftPO) TableName() string { return "live_gifts" }
|
||||
@@ -244,7 +244,11 @@ func (r *repo) Send(ctx context.Context, userID, roomID, giftID uint, quantity i
|
||||
}
|
||||
}
|
||||
roomIDCopy, giftIDCopy := room.ID, gift.ID
|
||||
row := ledger.GiftSendPO{SerialNo: newNumber("DS"), UserID: userID, RoomID: &roomIDCopy, RoomName: room.Name, AnchorUserID: room.AnchorUserID, AnchorName: room.AnchorName, GiftID: &giftIDCopy, GiftName: gift.Name, GiftIcon: gift.Icon, GiftPrice: gift.Price, Quantity: quantity, DiamondTotal: total, RateCents: rate, EquivalentCents: total * rate}
|
||||
serialNo, err := serial.New("DS")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
row := ledger.GiftSendPO{SerialNo: serialNo, UserID: userID, RoomID: &roomIDCopy, RoomName: room.Name, AnchorUserID: room.AnchorUserID, AnchorName: room.AnchorName, GiftID: &giftIDCopy, GiftName: gift.Name, GiftIcon: gift.Icon, GiftPrice: gift.Price, Quantity: quantity, DiamondTotal: total, RateCents: rate, EquivalentCents: total * rate}
|
||||
if err := tx.Create(&row).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -295,6 +299,3 @@ func sendOrder(v string) string {
|
||||
}
|
||||
return "live_gift_sends.created_at DESC,live_gift_sends.id DESC"
|
||||
}
|
||||
func newNumber(prefix string) string {
|
||||
return fmt.Sprintf("%s%s%06d", prefix, time.Now().Format("20060102150405"), time.Now().UnixNano()%1000000)
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ package gift
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"regexp"
|
||||
"testing"
|
||||
|
||||
bizgift "kra/internal/biz/gift"
|
||||
@@ -116,3 +117,184 @@ func TestSeedDefaultCatalogAddsMissingGiftsWithoutOverwriting(t *testing.T) {
|
||||
t.Fatalf("existing gift was overwritten: %+v", rose)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGiftRepoInsufficientBalanceDoesNotWriteLedger(t *testing.T) {
|
||||
db := openGiftTestDB(t)
|
||||
repo := NewRepo(testProvider{db: db})
|
||||
if err := db.Exec(`INSERT INTO sys_users(id,username,nick_name) VALUES (1,'viewer','Viewer'),(2,'anchor','Anchor')`).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := db.Exec(`INSERT INTO live_rooms(id,name,anchor_user_id,enabled) VALUES (7,'Room',2,1)`).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := db.Create(&ledger.WalletPO{UserID: 1, DiamondBalance: 11}).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
item, err := repo.Save(context.Background(), &bizgift.Gift{Name: "Star", Icon: "star", Price: 7, Tiers: []int{1, 10}, Enabled: true})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_, err = repo.Send(context.Background(), 1, 7, item.ID, 3)
|
||||
var insufficient *bizgift.InsufficientBalanceError
|
||||
if !errors.Is(err, bizgift.ErrInsufficientBalance) || !errors.As(err, &insufficient) {
|
||||
t.Fatalf("insufficient balance error = %v", err)
|
||||
}
|
||||
if insufficient.Required != 21 || insufficient.Balance != 11 || insufficient.Missing != 10 {
|
||||
t.Fatalf("incorrect recharge shortfall: %+v", insufficient)
|
||||
}
|
||||
var wallet ledger.WalletPO
|
||||
if err := db.Where("user_id = ?", 1).First(&wallet).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var sends int64
|
||||
if err := db.Model(&ledger.GiftSendPO{}).Count(&sends).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if wallet.DiamondBalance != 11 || wallet.TotalSpentDiamonds != 0 || sends != 0 {
|
||||
t.Fatalf("failed gift changed ledger: wallet=%+v sends=%d", wallet, sends)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGiftRepoWalletFailureRollsBackGiftReceipt(t *testing.T) {
|
||||
db := openGiftTestDB(t)
|
||||
repo := NewRepo(testProvider{db: db})
|
||||
if err := db.Exec(`INSERT INTO sys_users(id,username,nick_name) VALUES (1,'viewer','Viewer'),(2,'anchor','Anchor')`).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := db.Exec(`INSERT INTO live_rooms(id,name,anchor_user_id,enabled) VALUES (7,'Room',2,1)`).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := db.Create(&ledger.WalletPO{UserID: 1, DiamondBalance: 100}).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
item, err := repo.Save(context.Background(), &bizgift.Gift{Name: "Star", Icon: "star", Price: 7, Tiers: []int{1, 10}, Enabled: true})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := db.Exec(`CREATE TRIGGER reject_wallet_update BEFORE UPDATE ON live_wallets BEGIN SELECT RAISE(ABORT, 'forced wallet failure'); END`).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := repo.Send(context.Background(), 1, 7, item.ID, 3); err == nil {
|
||||
t.Fatal("forced wallet update failure was ignored")
|
||||
}
|
||||
var wallet ledger.WalletPO
|
||||
if err := db.Where("user_id = ?", 1).First(&wallet).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var sends int64
|
||||
if err := db.Model(&ledger.GiftSendPO{}).Count(&sends).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if wallet.DiamondBalance != 100 || wallet.TotalSpentDiamonds != 0 || sends != 0 {
|
||||
t.Fatalf("wallet failure was not rolled back: wallet=%+v sends=%d", wallet, sends)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGiftRepoCreateDisabledGiftCannotExposeOrDebitIt(t *testing.T) {
|
||||
db := openGiftTestDB(t)
|
||||
if err := db.Table("live_gifts").AutoMigrate(&struct {
|
||||
Enabled bool `gorm:"default:true"`
|
||||
}{}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
repo := NewRepo(testProvider{db: db})
|
||||
ctx := context.Background()
|
||||
active, err := repo.Save(ctx, &bizgift.Gift{Name: "Active", Icon: "active.png", Price: 1, Tiers: []int{1}, Enabled: true})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
disabled, err := repo.Save(ctx, &bizgift.Gift{Name: "Disabled", Icon: "disabled.png", Price: 5, Tiers: []int{1}, Enabled: false})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if disabled.Enabled {
|
||||
t.Fatal("a newly created disabled gift was enabled")
|
||||
}
|
||||
reloaded, err := repo.Get(ctx, disabled.ID)
|
||||
if err != nil || reloaded.Enabled {
|
||||
t.Fatalf("reloaded disabled gift=%+v error=%v", reloaded, err)
|
||||
}
|
||||
enabled := true
|
||||
items, total, err := repo.List(ctx, bizgift.Filter{Enabled: &enabled}, bizgift.ListOptions{Limit: 10})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if total != 1 || len(items) != 1 || items[0].ID != active.ID {
|
||||
t.Fatalf("public gifts include a disabled gift: total=%d items=%+v", total, items)
|
||||
}
|
||||
if err = repo.SetEnabled(ctx, active.ID, false); !errors.Is(err, bizgift.ErrLastEnabled) {
|
||||
t.Fatalf("disabled gift counted as an enabled alternative: error=%v", err)
|
||||
}
|
||||
if err = db.Exec(`INSERT INTO sys_users(id,username,nick_name) VALUES (1,'viewer','Viewer'),(2,'anchor','Anchor')`).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err = db.Exec(`INSERT INTO live_rooms(id,name,anchor_user_id,enabled) VALUES (7,'Room',2,1)`).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err = db.Create(&ledger.WalletPO{UserID: 1, DiamondBalance: 100}).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err = repo.Send(ctx, 1, 7, disabled.ID, 1); !errors.Is(err, bizgift.ErrSendNotAllowed) {
|
||||
t.Fatalf("disabled gift could be sent: error=%v", err)
|
||||
}
|
||||
var wallet ledger.WalletPO
|
||||
if err = db.Where("user_id = ?", 1).First(&wallet).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var sends int64
|
||||
if err = db.Model(&ledger.GiftSendPO{}).Count(&sends).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if wallet.DiamondBalance != 100 || wallet.TotalSpentDiamonds != 0 || sends != 0 {
|
||||
t.Fatalf("disabled gift changed the ledger: wallet=%+v sends=%d", wallet, sends)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGiftRepoConsecutiveSendsHaveUniqueShortSerialNumbers(t *testing.T) {
|
||||
db := openGiftTestDB(t)
|
||||
ctx := context.Background()
|
||||
repo := NewRepo(testProvider{db: db})
|
||||
if err := db.Exec(`INSERT INTO sys_users(id,username,nick_name) VALUES (1,'viewer','Viewer'),(2,'anchor','Anchor')`).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := db.Exec(`INSERT INTO live_rooms(id,name,anchor_user_id,enabled) VALUES (7,'Room',2,1)`).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
const count = 100
|
||||
if err := db.Create(&ledger.WalletPO{UserID: 1, DiamondBalance: count}).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := db.Create(&ledger.SettingPO{Key: "diamond_rate_cents", Value: "10"}).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
gift, err := repo.Save(ctx, &bizgift.Gift{Name: "Gift", Icon: "gift.png", Price: 1, Tiers: []int{1}, Enabled: true})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
pattern := regexp.MustCompile(`^DS[A-Z2-7]{26}$`)
|
||||
seen := make(map[string]struct{}, count)
|
||||
for index := 0; index < count; index++ {
|
||||
sent, err := repo.Send(ctx, 1, 7, gift.ID, 1)
|
||||
if err != nil {
|
||||
t.Fatalf("gift %d failed: %v", index, err)
|
||||
}
|
||||
if !pattern.MatchString(sent.SerialNo) || len(sent.SerialNo) > 32 {
|
||||
t.Fatalf("invalid gift serial: %q", sent.SerialNo)
|
||||
}
|
||||
if _, exists := seen[sent.SerialNo]; exists {
|
||||
t.Fatalf("duplicate gift serial: %q", sent.SerialNo)
|
||||
}
|
||||
seen[sent.SerialNo] = struct{}{}
|
||||
}
|
||||
var receipts int64
|
||||
if err := db.Model(&ledger.GiftSendPO{}).Count(&receipts).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var wallet ledger.WalletPO
|
||||
if err := db.Where("user_id = ?", 1).First(&wallet).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if receipts != count || wallet.DiamondBalance != 0 || wallet.TotalSpentDiamonds != count {
|
||||
t.Fatalf("consecutive gifts did not reconcile: receipts=%d wallet=%+v", receipts, wallet)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -19,7 +19,7 @@ type RechargePlanPO struct {
|
||||
Sort int `gorm:"index"`
|
||||
Diamonds int64
|
||||
AmountCents int64
|
||||
Enabled bool `gorm:"default:true"`
|
||||
Enabled bool
|
||||
}
|
||||
|
||||
func (RechargePlanPO) TableName() string { return "live_recharge_plans" }
|
||||
|
||||
@@ -23,7 +23,7 @@ type categoryPO struct {
|
||||
Name string `gorm:"size:40;uniqueIndex"`
|
||||
Icon string `gorm:"size:500"`
|
||||
Sort int `gorm:"default:0;index"`
|
||||
Enabled bool `gorm:"default:true;index"`
|
||||
Enabled bool `gorm:"index"`
|
||||
}
|
||||
|
||||
func (categoryPO) TableName() string { return "live_categories" }
|
||||
@@ -41,7 +41,7 @@ type roomPO struct {
|
||||
AnchorUserID uint `gorm:"index"`
|
||||
StreamURL string `gorm:"size:1000"`
|
||||
ReplayURL string `gorm:"size:1000"`
|
||||
Enabled bool `gorm:"default:true;index"`
|
||||
Enabled bool `gorm:"index"`
|
||||
}
|
||||
|
||||
func (roomPO) TableName() string { return "live_rooms" }
|
||||
@@ -171,7 +171,7 @@ func (r *catalogRepo) DeleteCategory(ctx context.Context, id uint) error {
|
||||
func (r *catalogRepo) ListRooms(ctx context.Context, filter bizlive.RoomFilter, options bizlive.ListOptions) ([]*bizlive.Room, int64, error) {
|
||||
db := r.db(ctx)
|
||||
query := db.Model(&roomPO{}).
|
||||
Joins("LEFT JOIN live_categories ON live_categories.id = live_rooms.category_id").
|
||||
Joins("LEFT JOIN live_categories ON live_categories.id = live_rooms.category_id AND live_categories.deleted_at IS NULL").
|
||||
Joins("LEFT JOIN sys_users ON sys_users.id = live_rooms.anchor_user_id AND sys_users.deleted_at IS NULL")
|
||||
if filter.Name != "" {
|
||||
value := "%" + filter.Name + "%"
|
||||
@@ -219,7 +219,7 @@ func (r *catalogRepo) GetRoom(ctx context.Context, id uint) (*bizlive.Room, erro
|
||||
var row roomRow
|
||||
err := r.db(ctx).Model(&roomPO{}).
|
||||
Select("live_rooms.*, live_categories.name AS category_name, COALESCE(NULLIF(sys_users.nick_name, ''), sys_users.username) AS anchor_name, sys_users.header_img AS anchor_avatar").
|
||||
Joins("LEFT JOIN live_categories ON live_categories.id = live_rooms.category_id").
|
||||
Joins("LEFT JOIN live_categories ON live_categories.id = live_rooms.category_id AND live_categories.deleted_at IS NULL").
|
||||
Joins("LEFT JOIN sys_users ON sys_users.id = live_rooms.anchor_user_id AND sys_users.deleted_at IS NULL").
|
||||
Where("live_rooms.id = ?", id).Scan(&row).Error
|
||||
if err != nil {
|
||||
|
||||
@@ -2,6 +2,7 @@ package live
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"testing"
|
||||
|
||||
bizlive "kra/internal/biz/live"
|
||||
@@ -44,7 +45,7 @@ func TestCatalogRepoScansCategoryAndRoomFields(t *testing.T) {
|
||||
if err := db.Create(&category).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
room := roomPO{Name: "Test Room", CategoryID: category.ID, CoverURL: "cover.png", Intro: "intro", Sort: 2, AnchorUserID: 2, Enabled: true}
|
||||
room := roomPO{Name: "Test Room", CategoryID: category.ID, CoverURL: "cover.png", Intro: "intro", Sort: 2, AnchorUserID: 2, ReplayURL: "https://example.test/replay.mp4", Enabled: true}
|
||||
if err := db.Create(&room).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -70,7 +71,7 @@ func TestCatalogRepoScansCategoryAndRoomFields(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got.ID != room.ID || got.Name != room.Name || got.AnchorUserID != 2 || got.AnchorAvatar != "avatar.png" || !got.Enabled {
|
||||
if got.ID != room.ID || got.Name != room.Name || got.AnchorUserID != 2 || got.AnchorAvatar != "avatar.png" || got.ReplayURL != room.ReplayURL || !got.Enabled {
|
||||
t.Fatalf("room get scan result=%+v", got)
|
||||
}
|
||||
}
|
||||
@@ -110,3 +111,158 @@ func TestCatalogRepoFiltersRoomsByCategoryEnabled(t *testing.T) {
|
||||
t.Fatalf("enabled-category rooms total=%d rooms=%+v", total, rooms)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCatalogRepoPublicVisibilityFiltersBeforePagination(t *testing.T) {
|
||||
db := openCatalogTestDB(t)
|
||||
if err := db.Exec(`INSERT INTO sys_users(id, username, nick_name, header_img) VALUES (2, 'anchor', 'Anchor', 'avatar.png')`).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
visible := categoryPO{Name: "Visible", Enabled: true}
|
||||
disabled := categoryPO{Name: "Disabled", Enabled: true}
|
||||
deleted := categoryPO{Name: "Deleted", Enabled: true}
|
||||
for _, category := range []*categoryPO{&visible, &disabled, &deleted} {
|
||||
if err := db.Create(category).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
if err := db.Model(&disabled).Update("enabled", false).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := db.Delete(&deleted).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
rooms := []*roomPO{
|
||||
{Name: "Offline Room", CategoryID: visible.ID, Enabled: true},
|
||||
{Name: "Replay Room", CategoryID: visible.ID, Enabled: true, ReplayURL: "https://example.test/replay.mp4"},
|
||||
{Name: "Disabled Room", CategoryID: visible.ID, Enabled: true},
|
||||
{Name: "Disabled Category", CategoryID: disabled.ID, Enabled: true},
|
||||
{Name: "Deleted Category", CategoryID: deleted.ID, Enabled: true},
|
||||
{Name: "Missing Category", CategoryID: 999, Enabled: true},
|
||||
{Name: "Deleted Room", CategoryID: visible.ID, Enabled: true},
|
||||
}
|
||||
for _, room := range rooms {
|
||||
room.AnchorUserID = 2
|
||||
room.CoverURL = "cover.png"
|
||||
if err := db.Create(room).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
if err := db.Model(rooms[2]).Update("enabled", false).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := db.Delete(rooms[6]).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
repo := NewCatalogRepo(catalogTestProvider{db: db})
|
||||
enabled := true
|
||||
filter := bizlive.RoomFilter{Enabled: &enabled, CategoryEnabled: &enabled}
|
||||
items, total, err := repo.ListRooms(context.Background(), filter, bizlive.ListOptions{Limit: 10, OrderBy: "id_asc"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if total != 2 || len(items) != 2 || items[0].ID != rooms[0].ID || items[1].ID != rooms[1].ID {
|
||||
t.Fatalf("public candidates total=%d items=%+v", total, items)
|
||||
}
|
||||
if items[1].ReplayURL != rooms[1].ReplayURL {
|
||||
t.Fatalf("replay URL=%q, want %q", items[1].ReplayURL, rooms[1].ReplayURL)
|
||||
}
|
||||
items, total, err = repo.ListRooms(context.Background(), filter, bizlive.ListOptions{Offset: 1, Limit: 1, OrderBy: "id_asc"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if total != 2 || len(items) != 1 || items[0].ID != rooms[1].ID {
|
||||
t.Fatalf("public page total=%d items=%+v", total, items)
|
||||
}
|
||||
filter.CategoryID = disabled.ID
|
||||
items, total, err = repo.ListRooms(context.Background(), filter, bizlive.ListOptions{Limit: 10})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if total != 0 || len(items) != 0 {
|
||||
t.Fatalf("disabled category returned rooms: total=%d items=%+v", total, items)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCatalogRepoCreatePreservesExplicitEnabledState(t *testing.T) {
|
||||
db := openCatalogTestDB(t)
|
||||
// Existing installations still have the old database-level default.
|
||||
for _, table := range []string{"live_categories", "live_rooms"} {
|
||||
if err := db.Table(table).AutoMigrate(&struct {
|
||||
Enabled bool `gorm:"default:true"`
|
||||
}{}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
if err := db.Exec(`INSERT INTO sys_users(id, username, nick_name, header_img) VALUES (2, 'anchor', 'Anchor', 'avatar.png')`).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
repo := NewCatalogRepo(catalogTestProvider{db: db})
|
||||
ctx := context.Background()
|
||||
for _, enabled := range []bool{false, true} {
|
||||
name := "Disabled"
|
||||
if enabled {
|
||||
name = "Enabled"
|
||||
}
|
||||
t.Run(name+" category", func(t *testing.T) {
|
||||
created, err := repo.CreateCategory(ctx, &bizlive.Category{Name: name, Enabled: enabled})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if created.Enabled != enabled {
|
||||
t.Fatalf("created category enabled=%v, want %v", created.Enabled, enabled)
|
||||
}
|
||||
reloaded, err := repo.GetCategory(ctx, created.ID)
|
||||
if err != nil || reloaded.Enabled != enabled {
|
||||
t.Fatalf("reloaded category=%+v error=%v, want enabled=%v", reloaded, err, enabled)
|
||||
}
|
||||
})
|
||||
}
|
||||
enabled := true
|
||||
categories, total, err := repo.ListCategories(ctx, bizlive.CategoryFilter{Enabled: &enabled}, bizlive.ListOptions{Limit: 10})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if total != 1 || len(categories) != 1 || categories[0].Name != "Enabled" {
|
||||
t.Fatalf("public categories include a disabled new category: total=%d categories=%+v", total, categories)
|
||||
}
|
||||
categoryID := categories[0].ID
|
||||
streams := bizlive.NewStreamUsecase(NewStreamRepo())
|
||||
rooms := bizlive.NewRoomUsecase(repo, streams)
|
||||
for _, enabled := range []bool{false, true} {
|
||||
name := "Disabled"
|
||||
if enabled {
|
||||
name = "Enabled"
|
||||
}
|
||||
t.Run(name+" room", func(t *testing.T) {
|
||||
created, err := repo.CreateRoom(ctx, &bizlive.Room{
|
||||
Name: name, CategoryID: categoryID, AnchorUserID: 2,
|
||||
CoverURL: "cover.png", ReplayURL: "https://example.test/replay.mp4", Enabled: enabled,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if created.Enabled != enabled {
|
||||
t.Fatalf("created room enabled=%v, want %v", created.Enabled, enabled)
|
||||
}
|
||||
reloaded, err := repo.GetRoom(ctx, created.ID)
|
||||
if err != nil || reloaded.Enabled != enabled {
|
||||
t.Fatalf("reloaded room=%+v error=%v, want enabled=%v", reloaded, err, enabled)
|
||||
}
|
||||
public, err := rooms.GetPublic(ctx, created.ID)
|
||||
if enabled {
|
||||
if err != nil || public.Status != bizlive.RoomStatusOffline {
|
||||
t.Fatalf("enabled offline public room=%+v error=%v", public, err)
|
||||
}
|
||||
} else if public != nil || !errors.Is(err, bizlive.ErrRoomDisabled) {
|
||||
t.Fatalf("disabled new room passed public/publisher authorization: room=%+v error=%v", public, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
public, total, err := repo.ListRooms(ctx, bizlive.RoomFilter{Enabled: &enabled, CategoryEnabled: &enabled}, bizlive.ListOptions{Limit: 10})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if total != 1 || len(public) != 1 || public[0].Name != "Enabled" {
|
||||
t.Fatalf("public list includes a disabled new room: total=%d rooms=%+v", total, public)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,7 +3,6 @@ package recharge
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
@@ -13,6 +12,7 @@ import (
|
||||
|
||||
biz "kra/internal/biz/recharge"
|
||||
"kra/internal/data/ledger"
|
||||
"kra/internal/utils/serial"
|
||||
)
|
||||
|
||||
type Provider interface{ DB() *gorm.DB }
|
||||
@@ -39,7 +39,7 @@ func (r *repo) SavePlans(ctx context.Context, plans []*biz.Plan) error {
|
||||
return err
|
||||
}
|
||||
for _, p := range plans {
|
||||
if err := tx.Create(&ledger.RechargePlanPO{Sort: p.Sort, Diamonds: p.Diamonds, AmountCents: p.AmountCents, Enabled: true}).Error; err != nil {
|
||||
if err := tx.Create(&ledger.RechargePlanPO{Sort: p.Sort, Diamonds: p.Diamonds, AmountCents: p.AmountCents, Enabled: p.Enabled}).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
@@ -127,7 +127,11 @@ func (r *repo) Create(ctx context.Context, userID, planID uint) (*biz.Order, err
|
||||
rate = v
|
||||
}
|
||||
}
|
||||
v := ledger.RechargeOrderPO{OrderNo: number("CZ"), UserID: userID, Diamonds: plan.Diamonds, AmountCents: plan.AmountCents, RateCents: rate}
|
||||
orderNo, err := serial.New("CZ")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
v := ledger.RechargeOrderPO{OrderNo: orderNo, UserID: userID, Diamonds: plan.Diamonds, AmountCents: plan.AmountCents, RateCents: rate}
|
||||
if err := r.db(ctx).Create(&v).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -185,7 +189,3 @@ func orderBy(v string) string {
|
||||
}
|
||||
return "live_recharge_orders.created_at DESC"
|
||||
}
|
||||
|
||||
func number(p string) string {
|
||||
return fmt.Sprintf("%s%s%06d", p, time.Now().Format("20060102150405"), time.Now().UnixNano()%1000000)
|
||||
}
|
||||
|
||||
@@ -2,6 +2,8 @@ package recharge
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"regexp"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
@@ -95,3 +97,174 @@ func TestListReadsPaymentStateFromKRAOrders(t *testing.T) {
|
||||
t.Fatalf("payment state was not joined: %+v", item)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMarkPaidWalletFailureRollsBackCreditMarkerAndAllowsRetry(t *testing.T) {
|
||||
db, err := gorm.Open(sqlite.Open("file:"+t.Name()+"?mode=memory&cache=shared"), &gorm.Config{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err = db.AutoMigrate(&ledger.RechargeOrderPO{}, &ledger.WalletPO{}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
order := ledger.RechargeOrderPO{OrderNo: "CZ-ROLLBACK", UserID: 3, Diamonds: 60, AmountCents: 600}
|
||||
if err = db.Create(&order).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err = db.Create(&ledger.WalletPO{UserID: 3, DiamondBalance: 10}).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err = db.Exec(`CREATE TRIGGER reject_wallet_update BEFORE UPDATE ON live_wallets BEGIN SELECT RAISE(ABORT, 'forced wallet failure'); END`).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
repo := NewRepo(testProvider{db: db})
|
||||
if _, err = repo.MarkPaid(context.Background(), order.OrderNo); err == nil {
|
||||
t.Fatal("forced wallet update failure was ignored")
|
||||
}
|
||||
var stored ledger.RechargeOrderPO
|
||||
if err = db.First(&stored, order.ID).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var wallet ledger.WalletPO
|
||||
if err = db.Where("user_id = ?", 3).First(&wallet).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if stored.CreditedAt != nil || wallet.DiamondBalance != 10 || wallet.TotalRechargeCents != 0 {
|
||||
t.Fatalf("failed fulfillment changed credit marker or wallet: order=%+v wallet=%+v", stored, wallet)
|
||||
}
|
||||
if err = db.Exec(`DROP TRIGGER reject_wallet_update`).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for attempt := 0; attempt < 2; attempt++ {
|
||||
if _, err = repo.MarkPaid(context.Background(), order.OrderNo); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
if err = db.Where("user_id = ?", 3).First(&wallet).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err = db.First(&stored, order.ID).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if stored.CreditedAt == nil || wallet.DiamondBalance != 70 || wallet.TotalRechargeCents != 600 {
|
||||
t.Fatalf("retry did not credit exactly once: order=%+v wallet=%+v", stored, wallet)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRechargePlanStoragePreservesExplicitDisabledState(t *testing.T) {
|
||||
db, err := gorm.Open(sqlite.Open("file:"+t.Name()+"?mode=memory&cache=shared"), &gorm.Config{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err = db.AutoMigrate(&ledger.RechargePlanPO{}, &ledger.RechargeOrderPO{}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err = db.Table("live_recharge_plans").AutoMigrate(&struct {
|
||||
Enabled bool `gorm:"default:true"`
|
||||
}{}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
disabled := ledger.RechargePlanPO{Sort: 1, Diamonds: 60, AmountCents: 600, Enabled: false}
|
||||
if err = db.Create(&disabled).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var stored ledger.RechargePlanPO
|
||||
if err = db.First(&stored, disabled.ID).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if stored.Enabled {
|
||||
t.Fatal("storage default enabled an explicitly disabled recharge plan")
|
||||
}
|
||||
repo := NewRepo(testProvider{db: db})
|
||||
items, err := repo.Plans(context.Background())
|
||||
if err != nil || len(items) != 0 {
|
||||
t.Fatalf("public plans include a disabled plan: plans=%+v error=%v", items, err)
|
||||
}
|
||||
if _, err = repo.Create(context.Background(), 1, disabled.ID); !errors.Is(err, biz.ErrInvalid) {
|
||||
t.Fatalf("disabled recharge plan accepted an order: error=%v", err)
|
||||
}
|
||||
var orders int64
|
||||
if err = db.Model(&ledger.RechargeOrderPO{}).Count(&orders).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if orders != 0 {
|
||||
t.Fatalf("disabled recharge plan created %d orders", orders)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRechargeRepoSavePlansPreservesDomainEnabledValues(t *testing.T) {
|
||||
db, err := gorm.Open(sqlite.Open("file:"+t.Name()+"?mode=memory&cache=shared"), &gorm.Config{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err = db.AutoMigrate(&ledger.RechargePlanPO{}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
repo := NewRepo(testProvider{db: db})
|
||||
if err = repo.SavePlans(context.Background(), []*biz.Plan{
|
||||
{Sort: 1, Diamonds: 60, AmountCents: 600, Enabled: false},
|
||||
{Sort: 2, Diamonds: 300, AmountCents: 3000, Enabled: true},
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var stored []ledger.RechargePlanPO
|
||||
if err = db.Order("sort ASC").Find(&stored).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(stored) != 2 || stored[0].Enabled || !stored[1].Enabled {
|
||||
t.Fatalf("saved plans lost explicit enabled values: %+v", stored)
|
||||
}
|
||||
items, err := repo.Plans(context.Background())
|
||||
if err != nil || len(items) != 1 || items[0].Diamonds != 300 {
|
||||
t.Fatalf("public plan filter returned disabled entries: plans=%+v error=%v", items, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRechargeRepoConsecutiveOrdersHaveUniquePaymentSafeNumbers(t *testing.T) {
|
||||
db, err := gorm.Open(sqlite.Open("file:"+t.Name()+"?mode=memory&cache=shared"), &gorm.Config{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err = db.AutoMigrate(&ledger.RechargePlanPO{}, &ledger.RechargeOrderPO{}, &ledger.SettingPO{}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, statement := range []string{
|
||||
`CREATE TABLE sys_users (id integer primary key, username text, nick_name text)`,
|
||||
`CREATE TABLE pay_orders (trade_no text, business_type text, business_id text, provider text, provider_trade_no text, payment_status text, fulfillment_status text, refund_status text)`,
|
||||
`INSERT INTO sys_users(id,username,nick_name) VALUES (3,'buyer','Buyer')`,
|
||||
} {
|
||||
if err = db.Exec(statement).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
if err = db.Create(&ledger.SettingPO{Key: "diamond_rate_cents", Value: "10"}).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
plan := ledger.RechargePlanPO{Sort: 1, Diamonds: 60, AmountCents: 600, Enabled: true}
|
||||
if err = db.Create(&plan).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
repo := NewRepo(testProvider{db: db})
|
||||
pattern := regexp.MustCompile(`^CZ[A-Z2-7]{26}$`)
|
||||
const count = 100
|
||||
seen := make(map[string]struct{}, count)
|
||||
for index := 0; index < count; index++ {
|
||||
order, err := repo.Create(context.Background(), 3, plan.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("order %d failed: %v", index, err)
|
||||
}
|
||||
if !pattern.MatchString(order.OrderNo) || len(order.OrderNo) > 32 {
|
||||
t.Fatalf("invalid recharge order number: %q", order.OrderNo)
|
||||
}
|
||||
if _, exists := seen[order.OrderNo]; exists {
|
||||
t.Fatalf("duplicate recharge order number: %q", order.OrderNo)
|
||||
}
|
||||
seen[order.OrderNo] = struct{}{}
|
||||
}
|
||||
var orders int64
|
||||
if err = db.Model(&ledger.RechargeOrderPO{}).Count(&orders).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if orders != count {
|
||||
t.Fatalf("created orders=%d, want %d", orders, count)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -322,6 +322,11 @@ func (m *Manager) handleChat(session *melody.Session, payload chatSendPayload) {
|
||||
m.send(session, "Error", map[string]any{"code": "CHAT_UNAVAILABLE", "message": "chat is only available in live rooms"})
|
||||
return
|
||||
}
|
||||
available, err := m.roomCatalog.IsLive(context.Background(), roomID)
|
||||
if err != nil || !available {
|
||||
m.send(session, "Error", map[string]any{"code": "ROOM_UNAVAILABLE", "message": "live room is unavailable"})
|
||||
return
|
||||
}
|
||||
item, err := m.chat.Send(context.Background(), roomID, p.userID, payload.Content)
|
||||
if err != nil {
|
||||
m.send(session, "Error", map[string]any{"code": "INVALID_CHAT", "message": err.Error()})
|
||||
@@ -379,13 +384,10 @@ func (m *Manager) authorizeRoomPublisher(streamID string, userID uint) error {
|
||||
if err != nil || id == 0 {
|
||||
return errors.New("invalid live room stream id")
|
||||
}
|
||||
item, err := m.roomCatalog.Get(context.Background(), uint(id))
|
||||
item, err := m.roomCatalog.GetPublic(context.Background(), uint(id))
|
||||
if err != nil {
|
||||
return errors.New("live room is unavailable")
|
||||
}
|
||||
if !item.Enabled {
|
||||
return errors.New("live room is disabled")
|
||||
}
|
||||
if item.AnchorUserID != userID {
|
||||
return errors.New("current user is not the room anchor")
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ package live
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"sync"
|
||||
"testing"
|
||||
|
||||
@@ -9,6 +10,7 @@ import (
|
||||
"github.com/pion/webrtc/v4"
|
||||
|
||||
bizlive "kra/internal/biz/live"
|
||||
bizchat "kra/internal/biz/livechat"
|
||||
platformws "kra/pkg/websocket"
|
||||
)
|
||||
|
||||
@@ -39,28 +41,41 @@ func (h *liveHubStub) Send(session *platformws.Session, message []byte) error {
|
||||
|
||||
type liveCatalogStub struct {
|
||||
bizlive.CatalogRepo
|
||||
room *bizlive.Room
|
||||
err error
|
||||
room *bizlive.Room
|
||||
category *bizlive.Category
|
||||
err error
|
||||
categoryErr error
|
||||
}
|
||||
|
||||
func (s *liveCatalogStub) GetRoom(context.Context, uint) (*bizlive.Room, error) {
|
||||
func (s *liveCatalogStub) GetRoom(_ context.Context, id uint) (*bizlive.Room, error) {
|
||||
if s.err != nil {
|
||||
return nil, s.err
|
||||
}
|
||||
if s.room == nil || s.room.ID != id {
|
||||
return nil, bizlive.ErrRoomNotFound
|
||||
}
|
||||
copy := *s.room
|
||||
return ©, nil
|
||||
}
|
||||
|
||||
func (s *liveCatalogStub) GetCategory(context.Context, uint) (*bizlive.Category, error) {
|
||||
if s.categoryErr != nil {
|
||||
return nil, s.categoryErr
|
||||
}
|
||||
if s.category != nil {
|
||||
copy := *s.category
|
||||
return ©, nil
|
||||
}
|
||||
return &bizlive.Category{ID: s.room.CategoryID, Enabled: true}, nil
|
||||
}
|
||||
|
||||
type liveStreamStub struct {
|
||||
bizlive.StreamRepo
|
||||
mu sync.Mutex
|
||||
streams map[string]*bizlive.Stream
|
||||
users map[uint]struct{}
|
||||
registerCalls int
|
||||
mu sync.Mutex
|
||||
streams map[string]*bizlive.Stream
|
||||
users map[uint]struct{}
|
||||
registerCalls int
|
||||
publisherRegisterCalls int
|
||||
}
|
||||
|
||||
func (s *liveStreamStub) Get(_ context.Context, streamID string) (*bizlive.Stream, error) {
|
||||
@@ -86,6 +101,38 @@ func (s *liveStreamStub) List(context.Context) ([]*bizlive.Stream, error) {
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
|
||||
func (s *liveStreamStub) RegisterPublisher(_ context.Context, streamID string, userID uint) (*bizlive.Stream, error) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
if s.streams == nil {
|
||||
s.streams = make(map[string]*bizlive.Stream)
|
||||
}
|
||||
stream := s.streams[streamID]
|
||||
if stream == nil {
|
||||
stream = &bizlive.Stream{StreamID: streamID}
|
||||
s.streams[streamID] = stream
|
||||
}
|
||||
if stream.PublisherOnline {
|
||||
return nil, bizlive.ErrPublisherExists
|
||||
}
|
||||
s.publisherRegisterCalls++
|
||||
stream.PublisherOnline, stream.PublisherID = true, userID
|
||||
copy := *stream
|
||||
return ©, nil
|
||||
}
|
||||
|
||||
func (s *liveStreamStub) UnregisterPublisher(_ context.Context, streamID string, userID uint) error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
stream := s.streams[streamID]
|
||||
if stream == nil || !stream.PublisherOnline || stream.PublisherID != userID {
|
||||
return bizlive.ErrPublisherNotFound
|
||||
}
|
||||
stream.PublisherOnline, stream.PublisherID = false, 0
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *liveStreamStub) RegisterSubscriber(_ context.Context, streamID string, userID uint) (*bizlive.Stream, error) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
@@ -97,15 +144,49 @@ func (s *liveStreamStub) RegisterSubscriber(_ context.Context, streamID string,
|
||||
return nil, bizlive.ErrSubscriberExists
|
||||
}
|
||||
s.users[userID] = struct{}{}
|
||||
if stream := s.streams[streamID]; stream != nil {
|
||||
stream.SubscriberCount = len(s.users)
|
||||
}
|
||||
return &bizlive.Stream{StreamID: streamID, SubscriberCount: len(s.users)}, nil
|
||||
}
|
||||
|
||||
func (s *liveStreamStub) UnregisterSubscriber(_ context.Context, streamID string, userID uint) error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
if _, exists := s.users[userID]; !exists {
|
||||
return bizlive.ErrSubscriberNotFound
|
||||
}
|
||||
delete(s.users, userID)
|
||||
if stream := s.streams[streamID]; stream != nil {
|
||||
stream.SubscriberCount = len(s.users)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
type liveChatStub struct {
|
||||
bizchat.Repo
|
||||
cleared []uint
|
||||
messages []*bizchat.Message
|
||||
}
|
||||
|
||||
func (s *liveChatStub) Clear(_ context.Context, roomID uint) error {
|
||||
s.cleared = append(s.cleared, roomID)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *liveChatStub) CreateChat(_ context.Context, roomID, userID uint, content string) (*bizchat.Message, error) {
|
||||
item := &bizchat.Message{ID: uint(len(s.messages) + 1), RoomID: roomID, UserID: userID, Content: content, Type: bizchat.TypeChat}
|
||||
s.messages = append(s.messages, item)
|
||||
return item, nil
|
||||
}
|
||||
|
||||
func newAnonymousTestManager(catalog bizlive.CatalogRepo, streams bizlive.StreamRepo) *Manager {
|
||||
streamUC := bizlive.NewStreamUsecase(streams)
|
||||
return &Manager{
|
||||
hub: &liveHubStub{},
|
||||
streams: streamUC,
|
||||
roomCatalog: bizlive.NewRoomUsecase(catalog, streamUC),
|
||||
chat: bizchat.NewUsecase(&liveChatStub{}),
|
||||
rooms: make(map[string]*room),
|
||||
peers: make(map[*melody.Session]*peer),
|
||||
observers: make(map[*melody.Session]struct{}),
|
||||
@@ -132,6 +213,11 @@ func TestAnonymousSubscribersReceiveDistinctRuntimeIdentities(t *testing.T) {
|
||||
if len(streams.users) != 2 {
|
||||
t.Fatalf("subscriber count = %d, want 2", len(streams.users))
|
||||
}
|
||||
for _, session := range []*melody.Session{first, second} {
|
||||
if payload := lastMessagePayload(t, manager, session, "Joined"); payload["userId"] != float64(0) {
|
||||
t.Fatalf("anonymous runtime identity leaked in response: %+v", payload)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestSubscriberCannotCreateUnknownCatalogRoom(t *testing.T) {
|
||||
@@ -147,7 +233,7 @@ func TestSubscriberCannotCreateUnknownCatalogRoom(t *testing.T) {
|
||||
|
||||
func TestSubscriberCannotCreateOfflineRuntimeState(t *testing.T) {
|
||||
streams := &liveStreamStub{}
|
||||
manager := newAnonymousTestManager(&liveCatalogStub{room: &bizlive.Room{ID: 7, CategoryID: 1, Enabled: true}}, streams)
|
||||
manager := newAnonymousTestManager(&liveCatalogStub{room: &bizlive.Room{ID: 7, CategoryID: 1, Enabled: true, ReplayURL: "https://example.test/replay.mp4"}}, streams)
|
||||
session := &melody.Session{Keys: map[string]any{}}
|
||||
|
||||
manager.join(session, joinPayload{Role: "subscriber", StreamID: "room-7"})
|
||||
@@ -155,6 +241,9 @@ func TestSubscriberCannotCreateOfflineRuntimeState(t *testing.T) {
|
||||
if manager.peer(session) != nil || len(manager.rooms) != 0 || len(streams.users) != 0 || streams.registerCalls != 0 {
|
||||
t.Fatalf("offline room created runtime state: peers=%d rooms=%d users=%d registrations=%d", len(manager.peers), len(manager.rooms), len(streams.users), streams.registerCalls)
|
||||
}
|
||||
if payload := lastMessagePayload(t, manager, session, "JoinRejected"); payload["code"] != "ROOM_UNAVAILABLE" {
|
||||
t.Fatalf("offline join rejection=%+v", payload)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAnonymousPublisherIsRejected(t *testing.T) {
|
||||
@@ -168,6 +257,198 @@ func TestAnonymousPublisherIsRejected(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestBoundAnchorCanPublishOfflineRoomAndDisconnectRestoresReplay(t *testing.T) {
|
||||
catalog := &liveCatalogStub{room: &bizlive.Room{ID: 7, CategoryID: 1, AnchorUserID: 42, Enabled: true, ReplayURL: "https://example.test/replay.mp4"}}
|
||||
streams := &liveStreamStub{}
|
||||
manager := newAnonymousTestManager(catalog, streams)
|
||||
chat := &liveChatStub{}
|
||||
manager.chat = bizchat.NewUsecase(chat)
|
||||
publisher := &melody.Session{Keys: map[string]any{"user_id": uint(42)}}
|
||||
viewer := &melody.Session{Keys: map[string]any{"user_id": uint(9)}}
|
||||
|
||||
before, err := manager.roomCatalog.GetPublic(context.Background(), 7)
|
||||
if err != nil || before.Status != bizlive.RoomStatusOffline {
|
||||
t.Fatalf("offline public room=%+v error=%v", before, err)
|
||||
}
|
||||
manager.join(publisher, joinPayload{Role: "publisher", StreamID: "room-7"})
|
||||
peer := manager.peer(publisher)
|
||||
if peer == nil || peer.role != "publisher" || !peer.authenticated || peer.userID != 42 {
|
||||
t.Fatalf("bound anchor did not join offline room: peer=%+v", peer)
|
||||
}
|
||||
if payload := lastMessagePayload(t, manager, publisher, "Joined"); payload["role"] != "publisher" || payload["streamId"] != "room-7" {
|
||||
t.Fatalf("publisher join response=%+v", payload)
|
||||
}
|
||||
live, err := manager.roomCatalog.GetPublic(context.Background(), 7)
|
||||
if err != nil || live.Status != bizlive.RoomStatusLive || streams.publisherRegisterCalls != 1 {
|
||||
t.Fatalf("published room=%+v registrations=%d error=%v", live, streams.publisherRegisterCalls, err)
|
||||
}
|
||||
manager.join(viewer, joinPayload{Role: "subscriber", StreamID: "room-7"})
|
||||
if manager.peer(viewer) == nil {
|
||||
t.Fatal("viewer could not join published room")
|
||||
}
|
||||
if payload := lastMessagePayload(t, manager, viewer, "Joined"); payload["userId"] != float64(9) {
|
||||
t.Fatalf("authenticated viewer response=%+v", payload)
|
||||
}
|
||||
manager.handleChat(viewer, chatSendPayload{Content: "Hello"})
|
||||
if len(chat.messages) != 1 || chat.messages[0].RoomID != 7 || chat.messages[0].UserID != 9 {
|
||||
t.Fatalf("live chat messages=%+v", chat.messages)
|
||||
}
|
||||
|
||||
manager.onDisconnect(publisher)
|
||||
after, err := manager.roomCatalog.GetPublic(context.Background(), 7)
|
||||
if err != nil || after.Status != bizlive.RoomStatusOffline || after.ReplayURL != before.ReplayURL {
|
||||
t.Fatalf("room after disconnect=%+v error=%v", after, err)
|
||||
}
|
||||
if payload := lastMessagePayload(t, manager, viewer, "PublisherStatus"); payload["ready"] != false || payload["reason"] != "disconnected" {
|
||||
t.Fatalf("offline notification=%+v", payload)
|
||||
}
|
||||
if available, err := manager.roomCatalog.IsLive(context.Background(), 7); err != nil || available {
|
||||
t.Fatalf("room remained live after disconnect: live=%v error=%v", available, err)
|
||||
}
|
||||
manager.handleChat(viewer, chatSendPayload{Content: "Not live"})
|
||||
if len(chat.messages) != 1 {
|
||||
t.Fatalf("offline message was persisted: %+v", chat.messages)
|
||||
}
|
||||
if payload := lastMessagePayload(t, manager, viewer, "Error"); payload["code"] != "ROOM_UNAVAILABLE" {
|
||||
t.Fatalf("offline chat rejection=%+v", payload)
|
||||
}
|
||||
manager.onDisconnect(viewer)
|
||||
if len(manager.peers) != 0 || len(manager.rooms) != 0 || len(streams.users) != 0 {
|
||||
t.Fatalf("disconnected peers remain: peers=%d rooms=%d users=%d", len(manager.peers), len(manager.rooms), len(streams.users))
|
||||
}
|
||||
if len(chat.cleared) != 2 || chat.cleared[0] != 7 || chat.cleared[1] != 7 {
|
||||
t.Fatalf("chat reset rooms=%v", chat.cleared)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPublisherJoinRequiresEnabledRoomCategoryAndBoundAnchor(t *testing.T) {
|
||||
for _, test := range []struct {
|
||||
name string
|
||||
userID uint
|
||||
enabled bool
|
||||
category *bizlive.Category
|
||||
roomErr error
|
||||
categoryErr error
|
||||
code string
|
||||
}{
|
||||
{name: "anonymous", enabled: true, code: "AUTH_REQUIRED"},
|
||||
{name: "wrong anchor", userID: 9, enabled: true, code: "PUBLISH_FORBIDDEN"},
|
||||
{name: "disabled room", userID: 42, code: "PUBLISH_FORBIDDEN"},
|
||||
{name: "disabled category", userID: 42, enabled: true, category: &bizlive.Category{ID: 1, Enabled: false}, code: "PUBLISH_FORBIDDEN"},
|
||||
{name: "missing room", userID: 42, enabled: true, roomErr: bizlive.ErrRoomNotFound, code: "PUBLISH_FORBIDDEN"},
|
||||
{name: "missing category", userID: 42, enabled: true, categoryErr: bizlive.ErrCategoryNotFound, code: "PUBLISH_FORBIDDEN"},
|
||||
} {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
streams := &liveStreamStub{}
|
||||
manager := newAnonymousTestManager(&liveCatalogStub{
|
||||
room: &bizlive.Room{ID: 7, CategoryID: 1, Enabled: test.enabled, AnchorUserID: 42},
|
||||
category: test.category,
|
||||
err: test.roomErr,
|
||||
categoryErr: test.categoryErr,
|
||||
}, streams)
|
||||
session := &melody.Session{Keys: map[string]any{"user_id": test.userID}}
|
||||
|
||||
manager.join(session, joinPayload{Role: "publisher", StreamID: "room-7"})
|
||||
|
||||
if manager.peer(session) != nil || len(manager.rooms) != 0 || streams.publisherRegisterCalls != 0 {
|
||||
t.Fatalf("forbidden publisher created state: peers=%d rooms=%d registrations=%d", len(manager.peers), len(manager.rooms), streams.publisherRegisterCalls)
|
||||
}
|
||||
if payload := lastMessagePayload(t, manager, session, "JoinRejected"); payload["code"] != test.code {
|
||||
t.Fatalf("publisher rejection=%+v, want code=%s", payload, test.code)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestSubscriberJoinCannotBypassDisabledRoomOrCategory(t *testing.T) {
|
||||
for _, test := range []struct {
|
||||
name string
|
||||
enabled bool
|
||||
category *bizlive.Category
|
||||
}{
|
||||
{name: "disabled room"},
|
||||
{name: "disabled category", enabled: true, category: &bizlive.Category{ID: 1, Enabled: false}},
|
||||
} {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
streams := &liveStreamStub{streams: map[string]*bizlive.Stream{"room-7": {StreamID: "room-7", PublisherOnline: true}}}
|
||||
manager := newAnonymousTestManager(&liveCatalogStub{
|
||||
room: &bizlive.Room{ID: 7, CategoryID: 1, Enabled: test.enabled},
|
||||
category: test.category,
|
||||
}, streams)
|
||||
session := &melody.Session{Keys: map[string]any{"user_id": uint(9)}}
|
||||
|
||||
manager.join(session, joinPayload{Role: "subscriber", StreamID: "room-7"})
|
||||
|
||||
if manager.peer(session) != nil || len(manager.rooms) != 0 || streams.registerCalls != 0 {
|
||||
t.Fatalf("unavailable room created subscriber state: peers=%d rooms=%d registrations=%d", len(manager.peers), len(manager.rooms), streams.registerCalls)
|
||||
}
|
||||
if payload := lastMessagePayload(t, manager, session, "JoinRejected"); payload["code"] != "ROOM_UNAVAILABLE" {
|
||||
t.Fatalf("subscriber rejection=%+v", payload)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestChatRechecksRoomAndCategoryVisibilityAfterJoin(t *testing.T) {
|
||||
for _, disableCategory := range []bool{false, true} {
|
||||
name := "disabled room"
|
||||
if disableCategory {
|
||||
name = "disabled category"
|
||||
}
|
||||
t.Run(name, func(t *testing.T) {
|
||||
catalog := &liveCatalogStub{
|
||||
room: &bizlive.Room{ID: 7, CategoryID: 1, Enabled: true},
|
||||
category: &bizlive.Category{ID: 1, Enabled: true},
|
||||
}
|
||||
streams := &liveStreamStub{streams: map[string]*bizlive.Stream{"room-7": {StreamID: "room-7", PublisherOnline: true}}}
|
||||
manager := newAnonymousTestManager(catalog, streams)
|
||||
chat := &liveChatStub{}
|
||||
manager.chat = bizchat.NewUsecase(chat)
|
||||
session := &melody.Session{Keys: map[string]any{"user_id": uint(9)}}
|
||||
manager.join(session, joinPayload{Role: "subscriber", StreamID: "room-7"})
|
||||
if manager.peer(session) == nil {
|
||||
t.Fatal("viewer did not join enabled room")
|
||||
}
|
||||
if disableCategory {
|
||||
catalog.category.Enabled = false
|
||||
} else {
|
||||
catalog.room.Enabled = false
|
||||
}
|
||||
|
||||
manager.handleChat(session, chatSendPayload{Content: "Hello"})
|
||||
|
||||
if len(chat.messages) != 0 {
|
||||
t.Fatalf("unavailable chat was persisted: %+v", chat.messages)
|
||||
}
|
||||
if payload := lastMessagePayload(t, manager, session, "Error"); payload["code"] != "ROOM_UNAVAILABLE" {
|
||||
t.Fatalf("unavailable chat rejection=%+v", payload)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func lastMessagePayload(t *testing.T, manager *Manager, session *melody.Session, msgType string) map[string]any {
|
||||
t.Helper()
|
||||
hub := manager.hub.(*liveHubStub)
|
||||
hub.mu.Lock()
|
||||
defer hub.mu.Unlock()
|
||||
messages := hub.messages[session]
|
||||
for index := len(messages) - 1; index >= 0; index-- {
|
||||
var msg struct {
|
||||
Type string `json:"type"`
|
||||
Payload map[string]any `json:"payload"`
|
||||
}
|
||||
if err := json.Unmarshal(messages[index], &msg); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if msg.Type == msgType {
|
||||
return msg.Payload
|
||||
}
|
||||
}
|
||||
t.Fatalf("missing %s message in %q", msgType, messages)
|
||||
return nil
|
||||
}
|
||||
|
||||
func TestClosedSupersededPeerConnectionDoesNotRemovePeer(t *testing.T) {
|
||||
oldPC, err := webrtc.NewPeerConnection(webrtc.Configuration{})
|
||||
if err != nil {
|
||||
|
||||
@@ -83,7 +83,7 @@ func (h *Live) Like(c *gin.Context) {
|
||||
// Do not create runtime stream state for arbitrary room IDs. The stream
|
||||
// repository is process-local, so validating the catalog first also keeps
|
||||
// bogus likes from accumulating forever.
|
||||
room, err := h.catalog.GetRoom(c.Request.Context(), id)
|
||||
room, err := h.catalog.GetPublicRoom(c.Request.Context(), id)
|
||||
if err != nil {
|
||||
businessFail(c, err)
|
||||
return
|
||||
|
||||
@@ -10,7 +10,18 @@ import (
|
||||
|
||||
type CatalogService struct {
|
||||
categories *bizlive.CategoryUsecase
|
||||
rooms *bizlive.RoomUsecase
|
||||
rooms roomCatalogUsecase
|
||||
}
|
||||
|
||||
type roomCatalogUsecase interface {
|
||||
List(context.Context, bizlive.RoomFilter, bizlive.ListOptions) ([]*bizlive.Room, int64, error)
|
||||
Get(context.Context, uint) (*bizlive.Room, error)
|
||||
GetPublic(context.Context, uint) (*bizlive.Room, error)
|
||||
Create(context.Context, *bizlive.Room) (*bizlive.Room, error)
|
||||
Update(context.Context, *bizlive.Room) (*bizlive.Room, error)
|
||||
Delete(context.Context, uint) error
|
||||
SetEnabled(context.Context, uint, bool) error
|
||||
SaveSort(context.Context, map[uint]int) error
|
||||
}
|
||||
|
||||
func NewCatalogService(categories *bizlive.CategoryUsecase, rooms *bizlive.RoomUsecase) *CatalogService {
|
||||
@@ -78,7 +89,7 @@ func (s *CatalogService) ListRooms(ctx context.Context, input *dto.LiveRoomListR
|
||||
}
|
||||
func (s *CatalogService) ListPublicRooms(ctx context.Context, input *dto.LiveRoomListRequest) ([]*dto.LiveRoomResponse, int64, error) {
|
||||
enabled := true
|
||||
items, total, err := s.rooms.List(ctx, bizlive.RoomFilter{Name: input.Name, CategoryID: input.CategoryID, Status: bizlive.RoomStatusLive, Enabled: &enabled, CategoryEnabled: &enabled}, liveListOptions(input.LiveListRequest))
|
||||
items, total, err := s.rooms.List(ctx, bizlive.RoomFilter{Name: input.Name, CategoryID: input.CategoryID, Status: input.Status, Enabled: &enabled, CategoryEnabled: &enabled}, liveListOptions(input.LiveListRequest))
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
package live
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"testing"
|
||||
|
||||
bizlive "kra/internal/biz/live"
|
||||
"kra/internal/service/dto"
|
||||
)
|
||||
|
||||
type roomCatalogStub struct {
|
||||
roomCatalogUsecase
|
||||
filter bizlive.RoomFilter
|
||||
options bizlive.ListOptions
|
||||
items []*bizlive.Room
|
||||
total int64
|
||||
publicID uint
|
||||
item *bizlive.Room
|
||||
err error
|
||||
}
|
||||
|
||||
func (s *roomCatalogStub) List(_ context.Context, filter bizlive.RoomFilter, options bizlive.ListOptions) ([]*bizlive.Room, int64, error) {
|
||||
s.filter, s.options = filter, options
|
||||
return s.items, s.total, s.err
|
||||
}
|
||||
|
||||
func (s *roomCatalogStub) GetPublic(_ context.Context, id uint) (*bizlive.Room, error) {
|
||||
s.publicID = id
|
||||
return s.item, s.err
|
||||
}
|
||||
|
||||
func TestListPublicRoomsPreservesOptionalStatusAndEnforcesVisibility(t *testing.T) {
|
||||
for _, status := range []string{"", bizlive.RoomStatusLive, bizlive.RoomStatusOffline, bizlive.RoomStatusDisabled} {
|
||||
t.Run("status="+status, func(t *testing.T) {
|
||||
rooms := &roomCatalogStub{
|
||||
items: []*bizlive.Room{{ID: 7, Status: bizlive.RoomStatusOffline, ReplayURL: "https://example.test/replay.mp4"}},
|
||||
total: 21,
|
||||
}
|
||||
service := &CatalogService{rooms: rooms}
|
||||
disabled := false
|
||||
result, total, err := service.ListPublicRooms(context.Background(), &dto.LiveRoomListRequest{
|
||||
LiveListRequest: dto.LiveListRequest{Page: 2, PageSize: 20, OrderBy: "viewer_count_desc"},
|
||||
Name: "Room",
|
||||
CategoryID: 3,
|
||||
Status: status,
|
||||
Enabled: &disabled,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if rooms.filter.Status != status || rooms.filter.Name != "Room" || rooms.filter.CategoryID != 3 {
|
||||
t.Fatalf("public filter=%+v", rooms.filter)
|
||||
}
|
||||
if rooms.filter.Enabled == nil || !*rooms.filter.Enabled || rooms.filter.CategoryEnabled == nil || !*rooms.filter.CategoryEnabled {
|
||||
t.Fatalf("public visibility filter=%+v", rooms.filter)
|
||||
}
|
||||
if rooms.options != (bizlive.ListOptions{Offset: 20, Limit: 20, OrderBy: "viewer_count_desc"}) {
|
||||
t.Fatalf("list options=%+v", rooms.options)
|
||||
}
|
||||
if total != 21 || len(result) != 1 || result[0].ID != 7 || result[0].Status != bizlive.RoomStatusOffline || result[0].ReplayURL != rooms.items[0].ReplayURL {
|
||||
t.Fatalf("public response total=%d rooms=%+v", total, result)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetPublicRoomPreservesOfflineReplayAndAnchorDetails(t *testing.T) {
|
||||
rooms := &roomCatalogStub{item: &bizlive.Room{
|
||||
ID: 7,
|
||||
Name: "Offline Room",
|
||||
CategoryID: 3,
|
||||
AnchorUserID: 42,
|
||||
ReplayURL: "https://example.test/replay.mp4",
|
||||
Enabled: true,
|
||||
Status: bizlive.RoomStatusOffline,
|
||||
}}
|
||||
service := &CatalogService{rooms: rooms}
|
||||
|
||||
result, err := service.GetPublicRoom(context.Background(), 7)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if rooms.publicID != 7 || result.StreamID != "room-7" || result.Status != bizlive.RoomStatusOffline || result.AnchorUserID != 42 || result.ReplayURL != rooms.item.ReplayURL {
|
||||
t.Fatalf("public detail=%+v requestedID=%d", result, rooms.publicID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPublicCatalogPropagatesUsecaseErrors(t *testing.T) {
|
||||
service := &CatalogService{rooms: &roomCatalogStub{err: bizlive.ErrRoomDisabled}}
|
||||
if items, total, err := service.ListPublicRooms(context.Background(), &dto.LiveRoomListRequest{}); items != nil || total != 0 || !errors.Is(err, bizlive.ErrRoomDisabled) {
|
||||
t.Fatalf("ListPublicRooms items=%+v total=%d error=%v", items, total, err)
|
||||
}
|
||||
if item, err := service.GetPublicRoom(context.Background(), 7); item != nil || !errors.Is(err, bizlive.ErrRoomDisabled) {
|
||||
t.Fatalf("GetPublicRoom item=%+v error=%v", item, err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
package serial
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"encoding/base32"
|
||||
"fmt"
|
||||
)
|
||||
|
||||
// New appends a 26-character base32 value with 128 random bits to prefix.
|
||||
func New(prefix string) (string, error) {
|
||||
var entropy [16]byte
|
||||
if _, err := rand.Read(entropy[:]); err != nil {
|
||||
return "", fmt.Errorf("generate serial number: %w", err)
|
||||
}
|
||||
return prefix + base32.StdEncoding.WithPadding(base32.NoPadding).EncodeToString(entropy[:]), nil
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
package serial
|
||||
|
||||
import (
|
||||
"encoding/base32"
|
||||
"regexp"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
)
|
||||
|
||||
var validSerial = regexp.MustCompile(`^(DS|CZ)[A-Z2-7]{26}$`)
|
||||
|
||||
func TestNewPreservesPrefixAndPaymentSafeLength(t *testing.T) {
|
||||
for _, prefix := range []string{"DS", "CZ"} {
|
||||
t.Run(prefix, func(t *testing.T) {
|
||||
value, err := New(prefix)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(value) != 28 || !strings.HasPrefix(value, prefix) || !validSerial.MatchString(value) {
|
||||
t.Fatalf("unexpected serial format: %q", value)
|
||||
}
|
||||
entropy, err := base32.StdEncoding.WithPadding(base32.NoPadding).DecodeString(strings.TrimPrefix(value, prefix))
|
||||
if err != nil || len(entropy) != 16 {
|
||||
t.Fatalf("serial does not carry 128 bits: bytes=%d error=%v", len(entropy), err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewIsUniqueAcrossRapidSequentialCalls(t *testing.T) {
|
||||
const count = 10000
|
||||
for _, prefix := range []string{"DS", "CZ"} {
|
||||
t.Run(prefix, func(t *testing.T) {
|
||||
seen := make(map[string]struct{}, count)
|
||||
for index := 0; index < count; index++ {
|
||||
value, err := New(prefix)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, exists := seen[value]; exists {
|
||||
t.Fatalf("duplicate serial at call %d: %q", index, value)
|
||||
}
|
||||
seen[value] = struct{}{}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewIsUniqueAcrossParallelCalls(t *testing.T) {
|
||||
const workers, perWorker = 32, 500
|
||||
type result struct {
|
||||
value string
|
||||
err error
|
||||
}
|
||||
start := make(chan struct{})
|
||||
results := make(chan result, workers)
|
||||
var group sync.WaitGroup
|
||||
for worker := 0; worker < workers; worker++ {
|
||||
group.Add(1)
|
||||
go func(worker int) {
|
||||
defer group.Done()
|
||||
prefix := "DS"
|
||||
if worker%2 == 0 {
|
||||
prefix = "CZ"
|
||||
}
|
||||
<-start
|
||||
for index := 0; index < perWorker; index++ {
|
||||
value, err := New(prefix)
|
||||
results <- result{value: value, err: err}
|
||||
}
|
||||
}(worker)
|
||||
}
|
||||
go func() {
|
||||
group.Wait()
|
||||
close(results)
|
||||
}()
|
||||
close(start)
|
||||
seen := make(map[string]struct{}, workers*perWorker)
|
||||
for result := range results {
|
||||
if result.err != nil {
|
||||
t.Errorf("generate parallel serial: %v", result.err)
|
||||
continue
|
||||
}
|
||||
if !validSerial.MatchString(result.value) || len(result.value) > 32 {
|
||||
t.Errorf("invalid parallel serial format: %q", result.value)
|
||||
}
|
||||
if _, exists := seen[result.value]; exists {
|
||||
t.Errorf("duplicate parallel serial: %q", result.value)
|
||||
}
|
||||
seen[result.value] = struct{}{}
|
||||
}
|
||||
if len(seen) != workers*perWorker {
|
||||
t.Fatalf("unique serials=%d, want %d", len(seen), workers*perWorker)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user