173 lines
8.9 KiB
Go
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")
|
|
}
|