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") }