This commit is contained in:
Yvan
2026-09-22 20:20:23 +08:00
parent bf33e87d72
commit ceaea78b0f
42 changed files with 2845 additions and 154 deletions
+21
View File
@@ -0,0 +1,21 @@
package main
import (
"fmt"
"path/filepath"
"github.com/glebarez/sqlite"
"gorm.io/gorm"
)
func openFixtureDB() (*gorm.DB, error) {
path, err := filepath.Abs(filepath.Join(outputDir, "acceptance.db"))
if err != nil {
return nil, err
}
root, err := filepath.Abs(outputDir)
if err != nil || filepath.Dir(path) != root {
return nil, fmt.Errorf("database path is not inside the acceptance directory")
}
return gorm.Open(sqlite.Open(path), &gorm.Config{})
}
+191
View File
@@ -0,0 +1,191 @@
package main
import (
"bytes"
"encoding/json"
"errors"
"fmt"
"io"
"log"
"net/http"
"net/http/cookiejar"
"os"
"path/filepath"
"time"
"kra/internal/service/dto"
)
const (
baseURL = "http://127.0.0.1:18080"
wsURL = "ws://127.0.0.1:18080/ws"
outputDir = ".tmp-live-acceptance"
adminPassword = "LocalAdmin_2026!"
userPassword = "LocalAccept_2026!"
)
type apiClient struct {
http *http.Client
token string
}
type envelope struct {
Code int `json:"code"`
Data json.RawMessage `json:"data"`
Msg string `json:"msg"`
}
type account struct {
Username string `json:"username"`
Password string `json:"password"`
ID uint `json:"id"`
}
type fixtures struct {
BaseURL string `json:"baseUrl"`
Database string `json:"database"`
CreatedAt string `json:"createdAt"`
Admin account `json:"admin"`
Anchor account `json:"anchor"`
Viewer account `json:"viewer"`
Other account `json:"other"`
CategoryID uint `json:"categoryId"`
RoomID uint `json:"roomId"`
ReplayID uint `json:"replayRoomId"`
DisabledID uint `json:"disabledRoomId"`
BrowserID uint `json:"browserRoomId,omitempty"`
GiftID uint `json:"giftId"`
Checks []string `json:"setupChecks"`
}
func main() {
log.SetFlags(log.LstdFlags | log.Lshortfile)
if len(os.Args) != 2 {
log.Fatal("usage: go run ./tmp/live_acceptance <setup|verify|publisher|mock-payment|browser-room>")
}
var err error
switch os.Args[1] {
case "setup":
err = setup()
case "verify":
err = verify()
case "publisher":
err = holdPublisher()
case "mock-payment":
err = serveMockPayments()
case "browser-room":
err = setupBrowserRoom()
default:
err = errors.New("unknown command")
}
if err != nil {
log.Fatal(err)
}
}
func newClient() *apiClient {
jar, _ := cookiejar.New(nil)
return &apiClient{http: &http.Client{Jar: jar, Timeout: 30 * time.Second}}
}
func (c *apiClient) call(method, path string, body, out any) error {
var reader io.Reader
if body != nil {
data, err := json.Marshal(body)
if err != nil {
return err
}
reader = bytes.NewReader(data)
}
req, err := http.NewRequest(method, baseURL+path, reader)
if err != nil {
return err
}
req.Header.Set("Content-Type", "application/json")
if c.token != "" {
req.Header.Set("x-token", c.token)
}
response, err := c.http.Do(req)
if err != nil {
return err
}
defer response.Body.Close()
var result envelope
if err = json.NewDecoder(response.Body).Decode(&result); err != nil {
return fmt.Errorf("%s %s status=%d: %w", method, path, response.StatusCode, err)
}
if result.Code != 0 || response.StatusCode >= 400 {
return fmt.Errorf("%s %s status=%d code=%d: %s", method, path, response.StatusCode, result.Code, result.Msg)
}
if out != nil {
return json.Unmarshal(result.Data, out)
}
return nil
}
func login(user account, admin bool) (*apiClient, error) {
c := newClient()
path := "/live/client/auth/login"
if admin {
path = "/base/login"
}
var result struct {
Token string `json:"token"`
}
if err := c.call("POST", path, map[string]any{"username": user.Username, "password": user.Password}, &result); err != nil {
return nil, err
}
if result.Token == "" {
return nil, errors.New("login returned no token")
}
c.token = result.Token
return c, nil
}
func readFixtures() (*fixtures, error) {
raw, err := os.ReadFile(filepath.Join(outputDir, "fixtures.json"))
if err != nil {
return nil, err
}
var f fixtures
if err = json.Unmarshal(raw, &f); err != nil {
return nil, err
}
absolute, err := filepath.Abs(filepath.Join(outputDir, "acceptance.db"))
if err != nil || f.Database != absolute || f.BaseURL != baseURL {
return nil, errors.New("fixture points outside the isolated acceptance environment")
}
return &f, nil
}
func saveJSON(name string, value any) error {
data, err := json.MarshalIndent(value, "", " ")
if err != nil {
return err
}
return os.WriteFile(filepath.Join(outputDir, name), append(data, '\n'), 0600)
}
func must(err error) {
if err != nil {
panic(err)
}
}
func check(condition bool, format string, args ...any) {
if !condition {
panic(fmt.Sprintf(format, args...))
}
}
func roomPath(id uint) string { return fmt.Sprintf("/live/rooms/%d", id) }
func adminRoomPath(id uint) string { return fmt.Sprintf("/live/admin/rooms/%d", id) }
func makeRoom(name string, categoryID, anchorID uint, replay string, enabled bool) dto.LiveRoomRequest {
return dto.LiveRoomRequest{
Name: name, CategoryID: categoryID, AnchorUserID: anchorID,
CoverURL: "/uploads/acceptance-cover.jpg", Intro: "Local isolated acceptance room",
ReplayURL: replay, Enabled: enabled,
}
}
+232
View File
@@ -0,0 +1,232 @@
package main
import (
"crypto"
"crypto/rand"
"crypto/rsa"
"crypto/sha256"
"crypto/x509"
"encoding/base64"
"encoding/json"
"encoding/pem"
"fmt"
"io"
"log"
"net/http"
"net/url"
"os"
"path/filepath"
"sort"
"strings"
"sync"
"time"
)
const mockURL = "http://127.0.0.1:18081"
const mockAppID = "local-acceptance-app"
type mockKeys struct {
MerchantPrivate string `json:"merchantPrivate"`
PlatformPrivate string `json:"platformPrivate"`
PlatformPublic string `json:"platformPublic"`
}
type mockOrder struct {
Number string `json:"number"`
Amount string `json:"amount"`
Paid bool `json:"paid"`
}
type mockPayments struct {
keys *mockKeys
key *rsa.PrivateKey
mu sync.Mutex
orders map[string]mockOrder
}
func newRSAKey() (*rsa.PrivateKey, string, string) {
key, err := rsa.GenerateKey(rand.Reader, 2048)
must(err)
private := string(pem.EncodeToMemory(&pem.Block{Type: "RSA PRIVATE KEY", Bytes: x509.MarshalPKCS1PrivateKey(key)}))
public, err := x509.MarshalPKIXPublicKey(&key.PublicKey)
must(err)
return key, private, string(pem.EncodeToMemory(&pem.Block{Type: "PUBLIC KEY", Bytes: public}))
}
func loadMockKeys() (*mockKeys, error) {
raw, err := os.ReadFile(filepath.Join(outputDir, "mock-payment-keys.json"))
if err == nil {
var keys mockKeys
err = json.Unmarshal(raw, &keys)
return &keys, err
}
if !os.IsNotExist(err) {
return nil, err
}
_, merchant, _ := newRSAKey()
_, private, public := newRSAKey()
keys := &mockKeys{MerchantPrivate: merchant, PlatformPrivate: private, PlatformPublic: public}
return keys, saveJSON("mock-payment-keys.json", keys)
}
func parsePrivate(value string) *rsa.PrivateKey {
block, _ := pem.Decode([]byte(value))
check(block != nil, "invalid local test private key")
key, err := x509.ParsePKCS1PrivateKey(block.Bytes)
must(err)
return key
}
func rsa2Sign(key *rsa.PrivateKey, raw []byte) string {
digest := sha256.Sum256(raw)
signature, err := rsa.SignPKCS1v15(rand.Reader, key, crypto.SHA256, digest[:])
must(err)
return base64.StdEncoding.EncodeToString(signature)
}
func signingFields(values url.Values, includeSignType bool) string {
keys := make([]string, 0, len(values))
for key := range values {
if key != "sign" && (includeSignType || key != "sign_type") && values.Get(key) != "" {
keys = append(keys, key)
}
}
sort.Strings(keys)
parts := make([]string, 0, len(keys))
for _, key := range keys {
parts = append(parts, key+"="+values.Get(key))
}
return strings.Join(parts, "&")
}
func serveMockPayments() error {
keys, err := loadMockKeys()
if err != nil {
return err
}
m := &mockPayments{keys: keys, key: parsePrivate(keys.PlatformPrivate), orders: map[string]mockOrder{}}
if raw, readErr := os.ReadFile(filepath.Join(outputDir, "mock-payment-orders.json")); readErr == nil {
must(json.Unmarshal(raw, &m.orders))
}
mux := http.NewServeMux()
mux.HandleFunc("/health", func(w http.ResponseWriter, _ *http.Request) { _, _ = io.WriteString(w, "local-only-mock") })
mux.HandleFunc("/gateway", m.gateway)
mux.HandleFunc("/confirm", m.confirm)
log.Printf("Local-only signed payment mock listening at %s", mockURL)
server := &http.Server{Addr: "127.0.0.1:18081", Handler: mux, ReadHeaderTimeout: 5 * time.Second}
return server.ListenAndServe()
}
func (m *mockPayments) gateway(w http.ResponseWriter, r *http.Request) {
if err := r.ParseForm(); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
merchant := parsePrivate(m.keys.MerchantPrivate)
signature, err := base64.StdEncoding.DecodeString(r.Form.Get("sign"))
digest := sha256.Sum256([]byte(signingFields(r.Form, true)))
if err != nil || rsa.VerifyPKCS1v15(&merchant.PublicKey, crypto.SHA256, digest[:], signature) != nil || r.Form.Get("app_id") != mockAppID {
http.Error(w, "invalid local merchant signature", http.StatusForbidden)
return
}
var content map[string]any
if err = json.Unmarshal([]byte(r.Form.Get("biz_content")), &content); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
number, _ := content["out_trade_no"].(string)
method := r.Form.Get("method")
m.mu.Lock()
defer m.mu.Unlock()
var response map[string]any
switch method {
case "alipay.trade.precreate":
amount, _ := content["total_amount"].(string)
m.orders[number] = mockOrder{Number: number, Amount: amount}
must(saveJSON("mock-payment-orders.json", m.orders))
response = map[string]any{
"code": "10000", "msg": "Success", "out_trade_no": number,
"qr_code": mockURL + "/confirm?orderNo=" + url.QueryEscape(number),
}
case "alipay.trade.query":
order, found := m.orders[number]
if !found {
http.Error(w, "unknown local test order", http.StatusNotFound)
return
}
status := "WAIT_BUYER_PAY"
if order.Paid {
status = "TRADE_SUCCESS"
}
response = map[string]any{
"code": "10000", "msg": "Success", "trade_no": "LOCAL-" + number, "out_trade_no": number,
"trade_status": status, "total_amount": order.Amount, "buyer_pay_amount": order.Amount,
"receipt_amount": order.Amount, "trans_currency": "CNY",
}
default:
http.Error(w, "method not implemented by local-only mock", http.StatusBadRequest)
return
}
raw, err := json.Marshal(response)
must(err)
envelope := map[string]any{strings.ReplaceAll(method, ".", "_") + "_response": json.RawMessage(raw), "sign": rsa2Sign(m.key, raw)}
w.Header().Set("Content-Type", "application/json")
must(json.NewEncoder(w).Encode(envelope))
}
func (m *mockPayments) confirm(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "local test confirmation requires POST", http.StatusMethodNotAllowed)
return
}
must(r.ParseForm())
number := r.Form.Get("orderNo")
m.mu.Lock()
order, found := m.orders[number]
if !found {
m.mu.Unlock()
http.Error(w, "unknown local test order", http.StatusNotFound)
return
}
order.Paid = true
m.orders[number] = order
must(saveJSON("mock-payment-orders.json", m.orders))
m.mu.Unlock()
fields := url.Values{
"app_id": {mockAppID}, "trade_no": {"LOCAL-" + number}, "out_trade_no": {number},
"trade_status": {"TRADE_SUCCESS"}, "total_amount": {order.Amount}, "sign_type": {"RSA2"},
}
fields.Set("sign", rsa2Sign(m.key, []byte(signingFields(fields, false))))
client := &http.Client{Timeout: 15 * time.Second}
response, err := client.PostForm(baseURL+"/payment/callback/alipay", fields)
if err != nil {
http.Error(w, err.Error(), http.StatusBadGateway)
return
}
defer response.Body.Close()
w.WriteHeader(response.StatusCode)
_, _ = io.Copy(w, response.Body)
}
func configureMockPayment(admin *apiClient) error {
keys, err := loadMockKeys()
if err != nil {
return err
}
response, err := http.Get(mockURL + "/health")
if err != nil {
return fmt.Errorf("start the local mock-payment command before verification: %w", err)
}
defer response.Body.Close()
if response.StatusCode != http.StatusOK {
return fmt.Errorf("local payment mock unavailable")
}
return admin.call("PUT", "/integration/configs/payment/alipay", map[string]any{
"enabled": true,
"config": map[string]any{
"app_id": mockAppID, "private_key": keys.MerchantPrivate, "public_key": keys.PlatformPublic,
"environment": "sandbox", "sign_type": "RSA2", "method": "alipay.trade.precreate", "gateway_url": mockURL + "/gateway",
"notify_url": baseURL + "/payment/callback/alipay",
},
}, nil)
}
+147
View File
@@ -0,0 +1,147 @@
package main
import (
"fmt"
"os"
"path/filepath"
"time"
"kra/internal/service/dto"
)
func setup() error {
if f, err := readFixtures(); err == nil {
fmt.Printf("Existing isolated fixtures retained: %s room=%d replay=%d\n", f.Database, f.RoomID, f.ReplayID)
return nil
}
c := newClient()
var status struct {
NeedInit bool `json:"needInit"`
}
must(c.call("POST", "/init/checkdb", nil, &status))
databaseDir, err := filepath.Abs(outputDir)
if err != nil {
return err
}
if status.NeedInit {
must(c.call("POST", "/init/initdb", dto.DatabaseInitRequest{
DBType: "sqlite", DBName: "acceptance.db", DBPath: databaseDir,
AdminPassword: adminPassword,
}, nil))
}
db, err := openFixtureDB()
if err != nil {
return err
}
// This test-only fixture setting must be seeded before the first login.
// Production configuration and security settings are never touched.
must(db.Exec("UPDATE sys_security_config SET captcha_open = 1000 WHERE id = 1").Error)
sqlDB, err := db.DB()
if err != nil {
return err
}
must(sqlDB.Close())
f := &fixtures{
BaseURL: baseURL, Database: filepath.Join(databaseDir, "acceptance.db"),
CreatedAt: time.Now().Format(time.RFC3339),
Admin: account{Username: "admin", Password: adminPassword, ID: 1},
}
admin, err := login(f.Admin, true)
if err != nil {
return err
}
f.Checks = append(f.Checks, "isolated SQLite initialization API and administrator login")
users := []*account{&f.Anchor, &f.Viewer, &f.Other}
for index, username := range []string{"accept_anchor", "accept_viewer", "accept_other"} {
var auth dto.LiveClientAuthResponse
input := dto.LiveClientRegisterRequest{Username: username, Nickname: username, Password: userPassword, ConfirmPassword: userPassword}
client := newClient()
if err = client.call("POST", "/live/client/auth/register", input, &auth); err != nil {
client, err = login(account{Username: username, Password: userPassword}, false)
if err != nil {
return fmt.Errorf("create fixture %s: %w", username, err)
}
var user dto.LiveClientAccountResponse
must(client.call("GET", "/live/client/me", nil, &user))
auth.User = &user
}
check(auth.User != nil && auth.User.UserID != 0, "register %s returned no user", username)
*users[index] = account{Username: username, Password: userPassword, ID: auth.User.UserID}
}
f.Checks = append(f.Checks, "PC anchor and two viewer registrations")
var integration dto.IntegrationConfigResponse
must(admin.call("GET", "/integration/configs/websocket/melody", nil, &integration))
must(admin.call("PUT", "/integration/configs/websocket/melody", dto.IntegrationConfigRequest{Enabled: true, Config: integration.Config}, nil))
f.Checks = append(f.Checks, "WebSocket integration enabled through administration API")
var category dto.LiveCategoryResponse
categoryInput := dto.LiveCategoryRequest{Name: "API Test", Icon: "video", Sort: 1, Enabled: true}
must(admin.call("POST", "/live/admin/categories", categoryInput, &category))
f.CategoryID = category.ID
categoryInput.Name = "API Live"
must(admin.call("PUT", fmt.Sprintf("/live/admin/categories/%d", category.ID), categoryInput, &category))
f.Checks = append(f.Checks, "category creation and update through administration API")
for _, room := range []struct {
name string
replay string
enabled bool
id *uint
}{
{"Acceptance Live Room", "", true, &f.RoomID},
{"Acceptance Replay Room", "/uploads/acceptance-replay.webm", true, &f.ReplayID},
{"Acceptance Disabled Room", "", false, &f.DisabledID},
} {
var value dto.LiveRoomResponse
must(admin.call("POST", "/live/admin/rooms", makeRoom(room.name, f.CategoryID, f.Anchor.ID, room.replay, room.enabled), &value))
*room.id = value.ID
}
f.Checks = append(f.Checks, "live, recorded and disabled rooms created through administration API")
var gift dto.GiftResponse
must(admin.call("POST", "/live/admin/gifts", dto.GiftRequest{
Name: "API Star", Icon: "star", Description: "Local acceptance gift",
Price: 7, Tiers: []int{1, 10}, Enabled: true, Sort: 1,
}, &gift))
f.GiftID = gift.ID
f.Checks = append(f.Checks, "configured gift created through administration API")
must(admin.call("PUT", "/live/admin/recharge/plans", dto.RechargePlansRequest{Plans: []*dto.RechargePlan{
{ID: 1, Sort: 1, Diamonds: 60, AmountCents: 600, Enabled: true},
{ID: 2, Sort: 2, Diamonds: 300, AmountCents: 3000, Enabled: true},
{ID: 3, Sort: 3, Diamonds: 10, AmountCents: 100, Enabled: true},
}}, nil))
f.Checks = append(f.Checks, "recharge plans configured through administration API")
must(os.MkdirAll(filepath.Join(outputDir, "uploads"), 0700))
cover, err := os.ReadFile("frontend/src/assets/images/tupian/cyber_streamer_1.jpg")
if err != nil {
return err
}
must(os.WriteFile(filepath.Join(outputDir, "uploads", "acceptance-cover.jpg"), cover, 0600))
must(saveJSON("fixtures.json", f))
fmt.Printf("API=%s database=%s\nanchor=%s viewer=%s other=%s password=%s\nroom=%d replay=%d disabled=%d category=%d gift=%d\n",
baseURL, f.Database, f.Anchor.Username, f.Viewer.Username, f.Other.Username, userPassword,
f.RoomID, f.ReplayID, f.DisabledID, f.CategoryID, f.GiftID)
return nil
}
func setupBrowserRoom() error {
f, err := readFixtures()
if err != nil {
return err
}
if f.BrowserID == 0 {
admin, err := login(f.Admin, true)
if err != nil {
return err
}
var room dto.LiveRoomResponse
must(admin.call("POST", "/live/admin/rooms", makeRoom("Acceptance Browser Room", f.CategoryID, f.Anchor.ID, "", true), &room))
f.BrowserID = room.ID
must(saveJSON("fixtures.json", f))
}
fmt.Printf("Dedicated browser publisher room=%d anchor=%s password=%s; API verification uses room=%d\n",
f.BrowserID, f.Anchor.Username, f.Anchor.Password, f.RoomID)
return nil
}
+109
View File
@@ -0,0 +1,109 @@
package main
import (
"fmt"
"time"
"kra/internal/data/ledger"
"kra/internal/service/dto"
"gorm.io/gorm"
)
type acceptanceReport struct {
StartedAt string `json:"startedAt"`
Checks []string `json:"checks"`
Findings []string `json:"findings"`
Records map[string]any `json:"records"`
Database string `json:"database"`
}
func verify() (err error) {
f, err := readFixtures()
if err != nil {
return err
}
report := &acceptanceReport{StartedAt: time.Now().Format(time.RFC3339), Database: f.Database, Records: map[string]any{}}
defer func() {
if recovered := recover(); recovered != nil {
report.Findings = append(report.Findings, fmt.Sprint(recovered))
}
if saveErr := saveJSON("report.json", report); saveErr != nil {
err = saveErr
return
}
if len(report.Findings) > 0 {
err = fmt.Errorf("%d passed groups, %d findings; inspect %s/report.json", len(report.Checks), len(report.Findings), outputDir)
}
}()
admin, err := login(f.Admin, true)
must(err)
viewer, err := login(f.Viewer, false)
must(err)
other, err := login(f.Other, false)
must(err)
anchor, err := login(f.Anchor, false)
must(err)
public := newClient()
db, err := openFixtureDB()
must(err)
sqlDB, err := db.DB()
must(err)
defer sqlDB.Close()
must(admin.call("PATCH", adminRoomPath(f.DisabledID)+"/status", dto.LiveStatusRequest{Enabled: false}, nil))
var list struct {
List []*dto.LiveRoomResponse `json:"list"`
Total int64 `json:"total"`
}
must(public.call("GET", fmt.Sprintf("/live/rooms?categoryId=%d&pageSize=100", f.CategoryID), nil, &list))
foundLive, foundReplay := false, false
for _, item := range list.List {
check(item.Enabled && item.ID != f.DisabledID, "public list exposed a disabled room: %+v", item)
foundLive = foundLive || item.ID == f.RoomID
foundReplay = foundReplay || item.ID == f.ReplayID
}
check(foundLive && foundReplay, "public listing omitted enabled live/replay fixtures: %+v", list)
var replay dto.LiveRoomResponse
must(public.call("GET", roomPath(f.ReplayID), nil, &replay))
check(replay.Status == "offline" && replay.ReplayURL != "", "offline replay inaccessible: %+v", replay)
check(public.call("GET", roomPath(f.DisabledID), nil, nil) != nil, "disabled room was exposed publicly")
passed(report, "public list includes offline and recorded rooms and excludes disabled rooms")
verifyCatalog(admin, public, viewer, f, report)
verifyRecharge(admin, viewer, other, f, db, report)
verifyLive(admin, public, anchor, viewer, other, f, db, report)
verifyDisabledAccount(admin, other, f, report)
verifyAdminSurface(admin, viewer, f, db, report)
verifyClientAccount(report)
var final ledger.WalletPO
must(db.Where("user_id = ?", f.Viewer.ID).First(&final).Error)
var chatRows, sends, rechargeOrders int64
must(db.Table("live_chat_messages").Count(&chatRows).Error)
must(db.Model(&ledger.GiftSendPO{}).Where("user_id = ?", f.Viewer.ID).Count(&sends).Error)
must(db.Model(&ledger.RechargeOrderPO{}).Where("user_id = ?", f.Viewer.ID).Count(&rechargeOrders).Error)
check(chatRows == 0, "chat content was persisted: %d rows", chatRows)
report.Records["finalWallet"] = final
report.Records["giftSendRows"] = sends
report.Records["rechargeOrderRows"] = rechargeOrders
report.Records["chatRows"] = chatRows
passed(report, "SQLite wallet, gift-send and recharge records reconciled; chat table remains empty")
fmt.Printf("Completed %d acceptance check groups\n", len(report.Checks))
return nil
}
func passed(report *acceptanceReport, text string) {
report.Checks = append(report.Checks, text)
fmt.Printf("PASS %s\n", text)
must(saveJSON("report.json", report))
}
func wallet(db *gorm.DB, userID uint) ledger.WalletPO {
var value ledger.WalletPO
must(db.Where("user_id = ?", userID).First(&value).Error)
return value
}
func giftCount(db *gorm.DB, userID uint) int64 {
var count int64
must(db.Model(&ledger.GiftSendPO{}).Where("user_id = ?", userID).Count(&count).Error)
return count
}
+81
View File
@@ -0,0 +1,81 @@
package main
import (
"fmt"
"time"
"kra/internal/service/dto"
"gorm.io/gorm"
)
func verifyAdminSurface(admin, viewer *apiClient, f *fixtures, db *gorm.DB, report *acceptanceReport) {
for _, path := range []string{
"/live/admin/dashboard",
"/live/admin/categories?pageSize=100",
"/live/admin/rooms?pageSize=100",
"/live/admin/gifts?pageSize=100",
fmt.Sprintf("/live/admin/gifts/%d", f.GiftID),
"/live/admin/sends?pageSize=100",
"/live/admin/orders?pageSize=100",
"/live/admin/users?pageSize=100",
} {
must(admin.call("GET", path, nil, nil))
check(viewer.call("GET", path, nil, nil) != nil, "client accessed %s", path)
}
must(admin.call("PUT", "/live/admin/rooms/sort", dto.LiveSortRequest{Values: map[uint]int{f.RoomID: 3, f.ReplayID: 4}}, nil))
must(admin.call("PUT", "/live/admin/gifts/sort", dto.LiveSortRequest{Values: map[uint]int{f.GiftID: 1}}, nil))
var room dto.LiveRoomResponse
must(admin.call("GET", adminRoomPath(f.RoomID), nil, &room))
check(room.Sort == 3, "room sorting was not persisted")
var settings dto.PlatformSetting
must(newClient().call("GET", "/live/settings", nil, &settings))
check(settings.DiamondRateCents > 0, "diamond rate missing")
must(admin.call("PUT", "/live/admin/settings", settings, nil))
before := wallet(db, f.Viewer.ID)
path := fmt.Sprintf("/live/admin/users/%d", f.Viewer.ID)
var account dto.AssetUserResponse
must(admin.call("GET", path, nil, &account))
check(account.DiamondBalance == before.DiamondBalance, "admin asset balance differs from ledger")
for _, operation := range []string{"increase", "decrease"} {
must(admin.call("POST", path+"/balance", dto.AssetAdjustmentRequest{Type: operation, Amount: 5, Reason: "local acceptance adjustment"}, &account))
}
check(wallet(db, f.Viewer.ID).DiamondBalance == before.DiamondBalance, "balanced manual adjustments changed wallet")
check(viewer.call("POST", path+"/balance", dto.AssetAdjustmentRequest{Type: "increase", Amount: 5, Reason: "unauthorized adjustment"}, nil) != nil, "client adjusted their own wallet")
passed(report, "all administration list/detail surfaces, sorting, settings and audited balance adjustment; clients denied administration access")
}
func verifyClientAccount(report *acceptanceReport) {
client := newClient()
username := fmt.Sprintf("accept_%d", time.Now().UnixMilli())
var registered dto.LiveClientAuthResponse
must(client.call("POST", "/live/client/auth/register", dto.LiveClientRegisterRequest{
Username: username, Nickname: "Local viewer", Password: userPassword, ConfirmPassword: userPassword,
}, &registered))
check(registered.User != nil && registered.User.DiamondBalance == 100, "new account did not receive exactly 100 diamonds")
client.token = registered.Token
var profile dto.LiveClientAccountResponse
must(client.call("PUT", "/live/client/me", dto.LiveClientProfileRequest{Nickname: "Updated viewer", Avatar: "/uploads/acceptance-cover.jpg"}, &profile))
check(profile.Nickname == "Updated viewer", "profile update not applied")
var balance dto.LiveClientBalanceResponse
must(client.call("GET", "/live/client/balance", nil, &balance))
check(balance.DiamondBalance == 100, "profile update changed wallet")
check(client.call("PUT", "/live/client/me/password", dto.LiveClientPasswordRequest{
OldPassword: "WrongPassword!", NewPassword: "ChangedLocal_2026!", ConfirmPassword: "ChangedLocal_2026!",
}, nil) != nil, "wrong previous password accepted")
var changed dto.LiveClientAuthResponse
must(client.call("PUT", "/live/client/me/password", dto.LiveClientPasswordRequest{
OldPassword: userPassword, NewPassword: "ChangedLocal_2026!", ConfirmPassword: "ChangedLocal_2026!",
}, &changed))
check(changed.Token != "" && changed.Token != registered.Token, "password change did not rotate session")
stale := newClient()
stale.token = registered.Token
check(stale.call("GET", "/live/client/me", nil, nil) != nil, "previous session remained valid after password change")
client.token = changed.Token
must(client.call("GET", "/live/client/me", nil, &profile))
must(client.call("POST", "/live/client/auth/logout", nil, nil))
check(client.call("GET", "/live/client/me", nil, nil) != nil, "logged-out session remained authorized")
report.Records["accountLifecycleUserID"] = profile.UserID
passed(report, "fresh registration grants 100 diamonds; profile/balance, password validation and rotation, expired session and logout")
}
+68
View File
@@ -0,0 +1,68 @@
package main
import (
"fmt"
"time"
"kra/internal/service/dto"
)
func verifyCatalog(admin, public, viewer *apiClient, f *fixtures, report *acceptanceReport) {
var category dto.LiveCategoryResponse
name := fmt.Sprintf("T%d", time.Now().UnixMilli()%100000000)
input := dto.LiveCategoryRequest{Name: name, Icon: "video", Sort: 2, Enabled: false}
must(admin.call("POST", "/live/admin/categories", input, &category))
if category.Enabled {
report.Findings = append(report.Findings, "Creating a category with enabled:false returns enabled:true")
}
input.Enabled = true
must(admin.call("PUT", fmt.Sprintf("/live/admin/categories/%d", category.ID), input, &category))
check(category.Enabled, "category update did not preserve enabled:true")
var room dto.LiveRoomResponse
roomInput := makeRoom("API Disabled Test", category.ID, f.Anchor.ID, "", false)
must(admin.call("POST", "/live/admin/rooms", roomInput, &room))
if room.Enabled {
report.Findings = append(report.Findings, "Creating a room with enabled:false returns enabled:true and exposes the room publicly")
}
must(admin.call("PATCH", adminRoomPath(room.ID)+"/status", dto.LiveStatusRequest{Enabled: false}, nil))
check(public.call("GET", roomPath(room.ID), nil, nil) != nil, "room remains public after disabling")
check(viewer.call("GET", adminRoomPath(room.ID), nil, nil) != nil, "PC viewer accessed an administration room")
roomInput.Enabled = true
roomInput.Intro = "Updated through API"
must(admin.call("PUT", adminRoomPath(room.ID), roomInput, &room))
check(room.Intro == roomInput.Intro && room.Enabled, "room update did not persist")
input.Enabled = false
must(admin.call("PUT", fmt.Sprintf("/live/admin/categories/%d", category.ID), input, nil))
check(public.call("GET", roomPath(room.ID), nil, nil) != nil, "room in a disabled category was exposed")
input.Enabled = true
must(admin.call("PUT", fmt.Sprintf("/live/admin/categories/%d", category.ID), input, nil))
must(admin.call("DELETE", adminRoomPath(room.ID), nil, nil))
check(public.call("GET", roomPath(room.ID), nil, nil) != nil, "deleted room remained public")
must(admin.call("DELETE", fmt.Sprintf("/live/admin/categories/%d", category.ID), nil, nil))
var gift dto.GiftResponse
giftInput := dto.GiftRequest{Name: name, Icon: "star", Price: 9, Tiers: []int{1, 10}, Enabled: true, Sort: 2}
must(admin.call("POST", "/live/admin/gifts", giftInput, &gift))
giftInput.Description = "Updated through API"
must(admin.call("PUT", fmt.Sprintf("/live/admin/gifts/%d", gift.ID), giftInput, &gift))
check(gift.Description == giftInput.Description, "gift update did not persist")
must(admin.call("PATCH", fmt.Sprintf("/live/admin/gifts/%d/status", gift.ID), dto.LiveStatusRequest{Enabled: false}, nil))
must(admin.call("DELETE", fmt.Sprintf("/live/admin/gifts/%d", gift.ID), nil, nil))
passed(report, "administrator category, room and gift CRUD; category/room disabling; client administration access denied")
}
func verifyDisabledAccount(admin, other *apiClient, f *fixtures, report *acceptanceReport) {
path := fmt.Sprintf("/live/admin/users/%d/status", f.Other.ID)
if err := admin.call("PATCH", path, dto.LiveStatusRequest{Enabled: false}, nil); err != nil {
report.Findings = append(report.Findings, "Administrator cannot disable the client user: "+err.Error())
return
}
defer func() {
must(admin.call("PATCH", path, dto.LiveStatusRequest{Enabled: true}, nil))
}()
check(admin.call("PATCH", fmt.Sprintf("/live/admin/users/%d/status", f.Admin.ID), dto.LiveStatusRequest{Enabled: false}, nil) != nil, "administrator disabled their own account")
_, err := login(f.Other, false)
check(err != nil, "disabled account could still log in")
check(other.call("GET", "/live/client/balance", nil, nil) != nil, "disabled account token remained authorized")
passed(report, "disabled user cannot log in or use an existing authenticated API token")
}
+172
View File
@@ -0,0 +1,172 @@
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")
}
+78
View File
@@ -0,0 +1,78 @@
package main
import (
"io"
"net/http"
"net/url"
"strings"
"time"
"kra/internal/data/ledger"
"kra/internal/service/dto"
"gorm.io/gorm"
)
func verifyRecharge(admin, viewer, other *apiClient, f *fixtures, db *gorm.DB, report *acceptanceReport) {
must(configureMockPayment(admin))
var plans struct {
List []dto.RechargePlan `json:"list"`
}
must(newClient().call("GET", "/live/recharge/plans", nil, &plans))
check(len(plans.List) > 0 && plans.List[0].AmountCents > 0, "recharge plans missing")
var providers struct {
List []dto.RechargeProvider `json:"list"`
}
must(newClient().call("GET", "/live/recharge/providers", nil, &providers))
found := false
for _, item := range providers.List {
found = found || item.ID == "alipay"
}
check(found, "local mock payment provider was not available")
before := wallet(db, f.Viewer.ID)
var checkout dto.RechargeCheckoutResponse
must(viewer.call("POST", "/live/recharge/orders", dto.RechargeOrderRequest{
PlanID: plans.List[0].ID, Provider: "alipay", NotifyURL: baseURL + "/payment/callback/alipay",
Extra: map[string]any{"userId": f.Other.ID, "diamonds": 99999},
}, &checkout))
check(checkout.Order != nil && checkout.Payment != nil, "recharge checkout missing order or payment")
order := checkout.Order
check(order.UserID == f.Viewer.ID && order.Diamonds == 60 && order.AmountCents == 600, "untrusted checkout values changed ownership or price: %+v", order)
check(wallet(db, f.Viewer.ID).DiamondBalance == before.DiamondBalance, "pending recharge credited wallet")
path := "/live/recharge/orders/" + order.OrderNo
var owned dto.RechargeOrderResponse
must(viewer.call("GET", path, nil, &owned))
check(other.call("GET", path, nil, nil) != nil, "another user accessed recharge order")
check(other.call("GET", path+"/status", nil, nil) != nil, "another user accessed recharge status")
check(newClient().call("GET", path, nil, nil) != nil, "anonymous user accessed recharge order")
must(admin.call("GET", "/live/admin/orders/"+order.OrderNo, nil, nil))
client := &http.Client{Timeout: 15 * time.Second}
badResponse, err := client.PostForm(baseURL+"/payment/callback/alipay", url.Values{
"app_id": {mockAppID}, "out_trade_no": {order.OrderNo}, "trade_no": {"LOCAL-" + order.OrderNo},
"trade_status": {"TRADE_SUCCESS"}, "total_amount": {"6.00"}, "sign_type": {"RSA2"}, "sign": {"invalid"},
})
must(err)
badBody, err := io.ReadAll(badResponse.Body)
must(err)
must(badResponse.Body.Close())
check(strings.TrimSpace(string(badBody)) != "success", "invalid callback signature was acknowledged")
check(wallet(db, f.Viewer.ID).DiamondBalance == before.DiamondBalance, "invalid callback credited wallet")
passed(report, "public recharge plans/provider, signed local checkout, order ownership and invalid callback rejection")
for repeat := 0; repeat < 2; repeat++ {
response, err := client.PostForm(mockURL+"/confirm", url.Values{"orderNo": {order.OrderNo}})
must(err)
body, err := io.ReadAll(response.Body)
must(err)
must(response.Body.Close())
check(response.StatusCode == 200 && strings.TrimSpace(string(body)) == "success", "local signed callback rejected: status=%d body=%s", response.StatusCode, body)
current := wallet(db, f.Viewer.ID)
check(current.DiamondBalance == before.DiamondBalance+60 && current.TotalRechargeCents == before.TotalRechargeCents+600, "recharge was not credited exactly once: before=%+v after=%+v", before, current)
}
must(viewer.call("GET", path, nil, &owned))
check(owned.PaymentStatus == "paid" && owned.FulfillmentStatus == "succeeded" && owned.CreditedAt != nil, "recharge status was not fulfilled: %+v", owned)
var stored ledger.RechargeOrderPO
must(db.Where("order_no = ?", order.OrderNo).First(&stored).Error)
check(stored.UserID == f.Viewer.ID && stored.CreditedAt != nil, "recharge database ownership or credit marker incorrect")
report.Records["rechargeOrderNo"] = order.OrderNo
passed(report, "valid local RSA2 callback, authoritative signed query and duplicate callback credit wallet only once")
}
+154
View File
@@ -0,0 +1,154 @@
package main
import (
"fmt"
"log"
"net/url"
"sync"
"time"
"github.com/gorilla/websocket"
)
type signalMessage struct {
Type string `json:"type"`
Payload map[string]any `json:"payload"`
}
type signalSocket struct {
conn *websocket.Conn
messages chan signalMessage
done chan struct{}
writeMu sync.Mutex
}
func connectSocket(token string) (*signalSocket, error) {
endpoint := wsURL
if token != "" {
endpoint += "?token=" + url.QueryEscape(token)
}
conn, _, err := websocket.DefaultDialer.Dial(endpoint, nil)
if err != nil {
return nil, err
}
s := &signalSocket{conn: conn, messages: make(chan signalMessage, 512), done: make(chan struct{})}
// The read loop processes WebSocket ping frames, allowing the server to
// receive pongs while HTTP assertions and application heartbeats run.
go func() {
defer close(s.done)
defer close(s.messages)
for {
var message signalMessage
if err := conn.ReadJSON(&message); err != nil {
return
}
select {
case s.messages <- message:
case <-time.After(5 * time.Second):
_ = conn.Close()
return
}
}
}()
go func() {
ticker := time.NewTicker(10 * time.Second)
defer ticker.Stop()
for {
select {
case <-s.done:
return
case <-ticker.C:
if s.write("Heartbeat", map[string]any{}) != nil {
_ = s.conn.Close()
return
}
}
}
}()
return s, nil
}
func (s *signalSocket) write(kind string, payload map[string]any) error {
s.writeMu.Lock()
defer s.writeMu.Unlock()
_ = s.conn.SetWriteDeadline(time.Now().Add(5 * time.Second))
return s.conn.WriteJSON(signalMessage{Type: kind, Payload: payload})
}
func (s *signalSocket) wait(kind string, match func(map[string]any) bool, timeout time.Duration) (signalMessage, error) {
timer := time.NewTimer(timeout)
defer timer.Stop()
for {
select {
case message, ok := <-s.messages:
if !ok {
return signalMessage{}, fmt.Errorf("WebSocket closed waiting for %s", kind)
}
if message.Type == kind && (match == nil || match(message.Payload)) {
return message, nil
}
if message.Type == "JoinRejected" {
return message, fmt.Errorf("join rejected: %v", message.Payload)
}
case <-timer.C:
return signalMessage{}, fmt.Errorf("timed out waiting for %s", kind)
}
}
}
func (s *signalSocket) quietChat(duration time.Duration) error {
timer := time.NewTimer(duration)
defer timer.Stop()
for {
select {
case message, ok := <-s.messages:
if !ok {
return fmt.Errorf("WebSocket closed while checking no chat history")
}
if message.Type == "ChatMessage" || message.Type == "ChatHistory" {
return fmt.Errorf("unexpected chat replay: %s %v", message.Type, message.Payload)
}
case <-timer.C:
return nil
}
}
}
func joinSocket(token, role string, room uint) (*signalSocket, error) {
s, err := connectSocket(token)
if err != nil {
return nil, err
}
if err = s.write("Join", map[string]any{"role": role, "streamId": fmt.Sprintf("room-%d", room)}); err != nil {
_ = s.conn.Close()
return nil, err
}
if _, err = s.wait("Joined", nil, 5*time.Second); err != nil {
_ = s.conn.Close()
return nil, err
}
return s, nil
}
func holdPublisher() error {
f, err := readFixtures()
if err != nil {
return err
}
client, err := login(f.Anchor, false)
if err != nil {
return err
}
socket, err := joinSocket(client.token, "publisher", f.RoomID)
if err != nil {
return err
}
defer socket.conn.Close()
log.Printf("Local fixture publisher joined room=%d with continuous reads and heartbeats", f.RoomID)
for message := range socket.messages {
if message.Type == "Error" || message.Type == "JoinRejected" {
return fmt.Errorf("publisher error: %v", message.Payload)
}
}
return fmt.Errorf("publisher disconnected")
}