Files
kra-new/tmp/live_acceptance/verify_live.go
T
2026-09-23 17:42:55 +08:00

173 lines
8.9 KiB
Go

package main
import (
"fmt"
"strings"
"time"
"kra/internal/data/ledger"
"kra/internal/service/dto"
"gorm.io/gorm"
)
func verifyLive(admin, public, anchor, viewer, other *apiClient, f *fixtures, db *gorm.DB, report *acceptanceReport) {
defer func() {
must(admin.call("PATCH", adminRoomPath(f.RoomID)+"/status", dto.LiveStatusRequest{Enabled: true}, nil))
must(admin.call("PATCH", fmt.Sprintf("/live/admin/gifts/%d/status", f.GiftID), dto.LiveStatusRequest{Enabled: true}, nil))
}()
for _, token := range []string{"", other.token} {
wrong, err := joinSocket(token, "publisher", f.RoomID)
if wrong != nil {
_ = wrong.conn.Close()
}
check(err != nil, "non-owner/anonymous client was allowed to publish")
}
publisher, err := joinSocket(anchor.token, "publisher", f.RoomID)
must(err)
defer publisher.conn.Close()
var room dto.LiveRoomResponse
must(public.call("GET", roomPath(f.RoomID), nil, &room))
check(room.Status == "live", "registered publisher did not make room live: %+v", room)
anonymous, err := joinSocket("", "subscriber", f.RoomID)
must(err)
defer anonymous.conn.Close()
must(anonymous.write("ChatSend", map[string]any{"content": "anonymous must be rejected"}))
message, err := anonymous.wait("Error", nil, 5*time.Second)
must(err)
check(message.Payload["code"] == "AUTH_REQUIRED", "anonymous chat was not rejected: %+v", message)
first, err := joinSocket(viewer.token, "subscriber", f.RoomID)
must(err)
defer first.conn.Close()
second, err := joinSocket(other.token, "subscriber", f.RoomID)
must(err)
defer second.conn.Close()
for index, sender := range []*signalSocket{first, second} {
content := fmt.Sprintf("acceptance-%d-%d", time.Now().UnixNano(), index)
must(sender.write("ChatSend", map[string]any{"content": content, "userId": f.Anchor.ID}))
for _, receiver := range []*signalSocket{first, second, anonymous} {
broadcast, err := receiver.wait("ChatMessage", func(payload map[string]any) bool { return payload["content"] == content }, 5*time.Second)
must(err)
expectedUser := f.Viewer.ID
if index == 1 {
expectedUser = f.Other.ID
}
check(broadcast.Payload["userId"] == float64(expectedUser), "chat trusted spoofed sender identity: %+v", broadcast)
}
}
late, err := joinSocket("", "subscriber", f.RoomID)
must(err)
defer late.conn.Close()
must(late.quietChat(700 * time.Millisecond))
assertNoHistory(public, f.RoomID)
passed(report, "authenticated owner publishing, anonymous viewing, unauthenticated chat rejection, two-user broadcast and no history replay")
var follow struct {
Followed bool `json:"followed"`
}
must(viewer.call("POST", roomPath(f.RoomID)+"/follow", nil, &follow))
check(follow.Followed, "follow was not created")
must(viewer.call("POST", roomPath(f.RoomID)+"/follow", nil, &follow))
var follows int64
must(db.Table("live_room_follows").Where("user_id = ? AND room_id = ?", f.Viewer.ID, f.RoomID).Count(&follows).Error)
check(follows == 1, "following twice created duplicate rows: %d", follows)
must(viewer.call("DELETE", roomPath(f.RoomID)+"/follow", nil, &follow))
check(!follow.Followed, "unfollow failed")
must(viewer.call("POST", roomPath(f.RoomID)+"/follow", nil, nil))
var likes struct {
LikeCount int `json:"likeCount"`
}
must(public.call("POST", roomPath(f.RoomID)+"/like", nil, &likes))
check(likes.LikeCount == room.LikeCount+1, "like count did not increment")
passed(report, "follow/unfollow idempotency reconciles to one database row and live-room likes increment")
// The old smoke publisher never read ping frames and disconnected at the
// default 60-second pong deadline, hiding gift and live-state regressions.
fmt.Println("Waiting 65 seconds to cross the default WebSocket pong deadline...")
time.Sleep(65 * time.Second)
select {
case <-publisher.done:
panic("publisher disconnected before the 65-second liveness assertion")
default:
}
must(publisher.write("Heartbeat", map[string]any{}))
_, err = publisher.wait("HeartbeatAck", nil, 5*time.Second)
must(err)
must(public.call("GET", roomPath(f.RoomID), nil, &room))
check(room.Status == "live", "room fell offline despite heartbeat/pong reads")
passed(report, "publisher connection remains alive beyond 60 seconds with continuous reads, pongs and heartbeats")
before := wallet(db, f.Viewer.ID)
otherBefore := wallet(db, f.Other.ID)
countBefore := giftCount(db, f.Viewer.ID)
var sent dto.GiftSendResponse
must(viewer.call("POST", roomPath(f.RoomID)+"/gifts", map[string]any{
"giftId": f.GiftID, "quantity": 3, "userId": f.Other.ID,
}, &sent))
check(sent.Quantity == 3 && sent.DiamondTotal == 21 && sent.UserID == f.Viewer.ID, "custom gift send did not use authenticated account and configured price: %+v", sent)
after := wallet(db, f.Viewer.ID)
check(after.DiamondBalance == before.DiamondBalance-21 && after.TotalSpentDiamonds == before.TotalSpentDiamonds+21, "gift wallet change incorrect: before=%+v after=%+v", before, after)
check(wallet(db, f.Other.ID).DiamondBalance == otherBefore.DiamondBalance, "gift altered another user's wallet")
check(giftCount(db, f.Viewer.ID) == countBefore+1, "gift ledger receipt missing")
giftMessage, err := second.wait("GiftMessage", nil, 5*time.Second)
must(err)
check(giftMessage.Payload["quantity"] == float64(3), "gift notification quantity incorrect: %+v", giftMessage)
var stored ledger.GiftSendPO
must(db.First(&stored, sent.ID).Error)
check(stored.Quantity == 3 && stored.DiamondTotal == 21 && stored.UserID == f.Viewer.ID, "gift ledger mismatch: %+v", stored)
passed(report, "custom gift quantity outside presets debits exact amount atomically, records receipt and broadcasts gift")
sendErr := viewer.call("POST", roomPath(f.RoomID)+"/gifts", dto.GiftSendRequest{GiftID: f.GiftID, Quantity: 9999}, nil)
check(sendErr != nil && strings.Contains(sendErr.Error(), "insufficient"), "insufficient-balance response not returned: %v", sendErr)
for _, quantity := range []int{0, -1, 10000} {
check(viewer.call("POST", roomPath(f.RoomID)+"/gifts", dto.GiftSendRequest{GiftID: f.GiftID, Quantity: quantity}, nil) != nil, "invalid gift quantity %d accepted", quantity)
}
must(admin.call("PATCH", fmt.Sprintf("/live/admin/gifts/%d/status", f.GiftID), dto.LiveStatusRequest{Enabled: false}, nil))
check(viewer.call("POST", roomPath(f.RoomID)+"/gifts", dto.GiftSendRequest{GiftID: f.GiftID, Quantity: 1}, nil) != nil, "disabled gift accepted")
must(admin.call("PATCH", fmt.Sprintf("/live/admin/gifts/%d/status", f.GiftID), dto.LiveStatusRequest{Enabled: true}, nil))
check(wallet(db, f.Viewer.ID).DiamondBalance == after.DiamondBalance && giftCount(db, f.Viewer.ID) == countBefore+1, "failed gift request changed wallet or receipt count")
passed(report, "insufficient balance, invalid quantity and disabled gift reject without debit or extra ledger rows")
must(admin.call("PATCH", adminRoomPath(f.RoomID)+"/status", dto.LiveStatusRequest{Enabled: false}, nil))
check(viewer.call("POST", roomPath(f.RoomID)+"/gifts", dto.GiftSendRequest{GiftID: f.GiftID, Quantity: 1}, nil) != nil, "disabled room accepted a gift")
denied, err := joinSocket("", "subscriber", f.RoomID)
if denied != nil {
_ = denied.conn.Close()
}
check(err != nil, "disabled room accepted a new subscriber")
must(admin.call("PATCH", adminRoomPath(f.RoomID)+"/status", dto.LiveStatusRequest{Enabled: true}, nil))
must(publisher.write("Leave", map[string]any{}))
_, err = first.wait("PublisherStatus", func(payload map[string]any) bool {
return payload["ready"] == false && payload["reason"] == "left"
}, 5*time.Second)
must(err)
must(public.call("GET", roomPath(f.RoomID), nil, &room))
check(room.Status == "offline", "room did not become offline after publisher left")
must(first.write("ChatSend", map[string]any{"content": "offline chat must be rejected"}))
message, err = first.wait("Error", nil, 5*time.Second)
must(err)
check(message.Payload["code"] == "ROOM_UNAVAILABLE", "offline room accepted chat: %+v", message)
check(viewer.call("POST", roomPath(f.RoomID)+"/gifts", dto.GiftSendRequest{GiftID: f.GiftID, Quantity: 1}, nil) != nil, "offline room accepted gift")
check(wallet(db, f.Viewer.ID).DiamondBalance == after.DiamondBalance && giftCount(db, f.Viewer.ID) == countBefore+1, "disabled/offline gift requests changed ledger")
assertNoHistory(public, f.RoomID)
restarted, err := joinSocket(anchor.token, "publisher", f.RoomID)
must(err)
defer restarted.conn.Close()
afterRestart, err := joinSocket("", "subscriber", f.RoomID)
must(err)
defer afterRestart.conn.Close()
must(afterRestart.quietChat(700 * time.Millisecond))
assertNoHistory(public, f.RoomID)
must(restarted.write("Leave", map[string]any{}))
passed(report, "disabled/offline room access and gift restrictions; publisher-offline notification; reopen has no previous chat history")
report.Records["lastGiftSendID"] = sent.ID
}
func assertNoHistory(public *apiClient, id uint) {
var history struct {
List []any `json:"list"`
}
must(public.call("GET", roomPath(id)+"/messages", nil, &history))
check(len(history.List) == 0, "HTTP endpoint exposed previous session chat")
}