Files
silo-server/internal/watchtogether/service_test.go
b250dbb59b fix(watchtogether): harden realtime sync, room lifecycle, and lobby UX (#273)
* fix(watchtogether): harden realtime sync, room lifecycle, and lobby UX

Remediates all findings from a deep review of the Watch Together feature.

Server:
- Serialize every websocket write (pong/error replies bypassed the write
  mutex, racing broadcasts on the same gorilla conn)
- Send room_closed with a reason on terminal connect failures so clients
  stop reconnecting to dead rooms
- Persist room state outside the service-wide mutex via a shared
  generation-CAS helper; drop ~450 lines of dead duplicated methods
- Measure transport latency from server-side ping/pong RTT (was one-way
  client-clock delta, poisoned by clock skew) and clamp the lead time
- Re-evaluate readiness when a waiting participant disconnects and add a
  30s waiting deadline that skips stragglers (activates ignoreWait)
- Guard the host-disconnect close timer against reconnect races
- Clamp buffering-report anchor moves; clear stale member sessions on
  selection change
- Janitor: evict empty live rooms and close rooms idle >24h
- Snapshot gains an additive members list with profile display names

Web:
- Surface terminal room errors (REST 404/410/403 and WS error codes) as
  closedReason instead of reconnecting forever on "Connecting..."
- Memoize the playback-sync hook and narrow VideoPlayer's video-listener
  effect deps to stop re-subscribing 13 listeners on every render
- Preserve invite-link destination through login/profile guards
- Lobby: terminal ended/missing-token states with CTAs, End-room confirm
  dialog, toast feedback via shared action helpers (dedup with player),
  participant list with guest Leave, mobile-visible connection status,
  document title, a11y labels/focus reveal, unified status dot component
- Delete dead useWatchTogetherRoom hook (345 lines, zero importers)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(watchtogether): guard indexed access in join-page keyboard nav for noUncheckedIndexedAccess

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(watchtogether): reconcile CAS conflicts without regressing newer state; roll back unpersisted waiting-resume

Addresses Codex review on PR #273:
- persistRoomChangeLocked now undoes the failed writer's optimistic
  generation increment and only adopts the refreshed database row when it
  is at least as new as the local copy, so a stale conflict refresh can
  no longer overwrite a concurrent writer's newer in-memory state (and a
  failed write can no longer leave a phantom generation)
- maybeResumeFromWaitingLocked restores the waiting state and re-arms the
  deadline when the resume transition fails to persist, instead of
  broadcasting a resume the database never recorded

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-02 11:34:02 -04:00

774 lines
22 KiB
Go

package watchtogether
import (
"context"
"errors"
"testing"
"time"
"github.com/Silo-Server/silo-server/internal/models"
"github.com/Silo-Server/silo-server/internal/playback"
)
type stubRepo struct {
room Room
// anchorErr, when set, is returned from UpdateAnchor to simulate a
// database failure.
anchorErr error
}
func (s *stubRepo) CreateRoom(_ context.Context, room Room) (*Room, error) {
s.room = room
copy := s.room
return &copy, nil
}
func (s *stubRepo) GetRoomByID(context.Context, string) (*Room, error) {
room := s.room
return &room, nil
}
func (s *stubRepo) GetRoomByCode(context.Context, string) (*Room, error) {
room := s.room
return &room, nil
}
func (s *stubRepo) GetRoomByJoinToken(context.Context, string) (*Room, error) {
room := s.room
return &room, nil
}
func (s *stubRepo) ListIdleRoomIDs(context.Context, time.Time, int) ([]string, error) {
return nil, nil
}
func (s *stubRepo) UpdatePolicy(_ context.Context, _ string, policy GuestControlPolicy, generation int64, expectedGeneration int64) (*Room, error) {
if s.room.Generation != expectedGeneration {
return nil, ErrRoomStateConflict
}
s.room.GuestControlPolicy = policy
s.room.Generation = generation
room := s.room
return &room, nil
}
func (s *stubRepo) UpdateAnchor(
_ context.Context,
_ string,
positionSeconds float64,
isPaused bool,
playbackState RoomPlaybackState,
resumeOnReady bool,
updatedAt time.Time,
generation int64,
expectedGeneration int64,
) (*Room, error) {
if s.anchorErr != nil {
return nil, s.anchorErr
}
if s.room.Generation != expectedGeneration {
return nil, ErrRoomStateConflict
}
s.room.AnchorPositionSeconds = positionSeconds
s.room.IsPaused = isPaused
s.room.PlaybackState = playbackState
s.room.ResumeOnReady = resumeOnReady
s.room.AnchorUpdatedAt = updatedAt
s.room.Generation = generation
room := s.room
return &room, nil
}
func (s *stubRepo) CloseRoom(_ context.Context, _ string, closedAt time.Time) (*Room, error) {
s.room.Phase = RoomPhaseEnded
s.room.ClosedAt = &closedAt
room := s.room
return &room, nil
}
func (s *stubRepo) UpdateSelection(
_ context.Context,
_ string,
selection SelectItemInput,
phase RoomPhase,
playbackState RoomPlaybackState,
resumeOnReady bool,
anchorPosition float64,
isPaused bool,
anchorUpdatedAt time.Time,
selectionRevision int64,
generation int64,
expectedGeneration int64,
) (*Room, error) {
if s.room.Generation != expectedGeneration {
return nil, ErrRoomStateConflict
}
s.room.Phase = phase
s.room.PlaybackState = playbackState
s.room.ResumeOnReady = resumeOnReady
s.room.SelectedContentID = &selection.ContentID
s.room.SelectedFileID = selection.FileID
s.room.SelectedLibraryID = selection.LibraryID
s.room.AnchorPositionSeconds = anchorPosition
s.room.IsPaused = isPaused
s.room.AnchorUpdatedAt = anchorUpdatedAt
s.room.SelectionRevision = selectionRevision
s.room.Generation = generation
room := s.room
return &room, nil
}
type stubSessions struct {
session *playback.Session
}
func (s *stubSessions) GetSession(string) (*playback.Session, error) {
if s.session == nil {
return nil, playback.ErrSessionNotFound
}
cp := *s.session
return &cp, nil
}
type stubFiles struct {
file *models.MediaFile
}
func (s *stubFiles) GetByID(context.Context, int) (*models.MediaFile, error) {
if s.file == nil {
return nil, errors.New("missing file")
}
cp := *s.file
return &cp, nil
}
type stubConn struct{}
func (stubConn) WriteJSON(any) error { return nil }
func (stubConn) Close() error { return nil }
type recordingConn struct {
payloads []map[string]any
}
func (c *recordingConn) WriteJSON(v any) error {
payload, ok := v.(map[string]any)
if !ok {
return nil
}
copyPayload := make(map[string]any, len(payload))
for key, value := range payload {
copyPayload[key] = value
}
c.payloads = append(c.payloads, copyPayload)
return nil
}
func (c *recordingConn) Close() error { return nil }
type stubSelectionResolver struct {
resolved *ResolvedSelection
err error
}
func (s *stubSelectionResolver) ResolveSelection(context.Context, int, string, SelectItemInput) (*ResolvedSelection, error) {
if s.err != nil {
return nil, s.err
}
return s.resolved, nil
}
func baseRoom(now time.Time) Room {
return Room{
ID: "room-1",
Code: "ROOM1234",
JoinToken: "TOKEN1234",
HostUserID: 7,
HostProfileID: "host",
Phase: RoomPhasePlaying,
PlaybackState: RoomPlaybackStatePlaying,
ResumeOnReady: false,
SelectionMode: RoomSelectionModeHostPick,
SelectionRevision: 1,
SelectedContentID: stringPtr("movie-1"),
GuestControlPolicy: GuestControlPolicyHostOnly,
AnchorPositionSeconds: 10,
IsPaused: false,
AnchorUpdatedAt: now.Add(-10 * time.Second),
Generation: 1,
CreatedAt: now.Add(-20 * time.Second),
}
}
func newServiceForTest(now time.Time, repo *stubRepo, sessions *stubSessions, files *stubFiles, resolver WatchTogetherSelectionResolver) *Service {
service := NewService(repo, sessions, files, resolver, nil, nil)
service.hostDisconnectTTL = time.Hour
service.now = func() time.Time { return now }
service.rooms[repo.room.ID] = &liveRoom{
room: repo.room,
members: make(map[string]*memberState),
}
return service
}
func registrationFor(roomID string, userID int, profileID string, conn RoomConnection) *Registration {
return &Registration{
roomID: roomID,
memberKey: buildMemberKey(userID, profileID),
connection: conn,
}
}
func stringPtr(value string) *string {
return &value
}
func TestGuestPlayPausePolicyStillRejectsGuestSeek(t *testing.T) {
now := time.Date(2026, 4, 9, 12, 0, 20, 0, time.UTC)
repo := &stubRepo{room: baseRoom(now)}
repo.room.GuestControlPolicy = GuestControlPolicyGuestPlayPause
conn := &recordingConn{}
service := newServiceForTest(
now,
repo,
&stubSessions{},
&stubFiles{file: &models.MediaFile{ID: 42, ContentID: "movie-1"}},
nil,
)
service.rooms[repo.room.ID].members[buildMemberKey(8, "guest")] = &memberState{
userID: 8,
profileID: "guest",
sessionID: "session-1",
connection: conn,
}
position := 120.0
reg := registrationFor(repo.room.ID, 8, "guest", conn)
_, err := service.HandleTransportRequestForConnection(context.Background(), reg, 8, "guest", TransportRequest{
Action: TransportActionSeek,
PositionSeconds: &position,
IsPaused: false,
})
if !errors.Is(err, ErrTransportNotAllowed) {
t.Fatalf("HandleTransportRequestForConnection(guest seek) error = %v, want ErrTransportNotAllowed", err)
}
}
func TestGuestDriftTriggersCorrection(t *testing.T) {
now := time.Date(2026, 4, 9, 12, 0, 20, 0, time.UTC)
repo := &stubRepo{room: baseRoom(now)}
conn := &recordingConn{}
service := newServiceForTest(
now,
repo,
&stubSessions{session: &playback.Session{
ID: "session-1",
UserID: 8,
ProfileID: "guest",
MediaFileID: 42,
}},
&stubFiles{file: &models.MediaFile{ID: 42, ContentID: "movie-1"}},
nil,
)
service.rooms[repo.room.ID].members[buildMemberKey(8, "guest")] = &memberState{
userID: 8,
profileID: "guest",
sessionID: "session-1",
connection: conn,
}
reg := registrationFor(repo.room.ID, 8, "guest", conn)
_, err := service.HandleStateReportForConnection(context.Background(), reg, 8, "guest", StateReport{
SessionID: "session-1",
PositionSeconds: 2,
IsPaused: false,
})
if err != nil {
t.Fatalf("HandleStateReportForConnection() error = %v", err)
}
if len(conn.payloads) == 0 {
t.Fatal("expected correction commands to be dispatched")
}
}
func TestHostAttachKeepsRoomSelectionAnchor(t *testing.T) {
now := time.Date(2026, 4, 9, 12, 0, 20, 0, time.UTC)
repo := &stubRepo{room: baseRoom(now)}
repo.room.AnchorPositionSeconds = 0
repo.room.IsPaused = true
repo.room.AnchorUpdatedAt = now
repo.room.Generation = 1
conn := &recordingConn{}
service := newServiceForTest(
now,
repo,
&stubSessions{session: &playback.Session{
ID: "session-1",
UserID: 7,
ProfileID: "host",
MediaFileID: 42,
Position: 318,
IsPaused: false,
}},
&stubFiles{file: &models.MediaFile{ID: 42, ContentID: "movie-1"}},
nil,
)
service.rooms[repo.room.ID].members[buildMemberKey(7, "host")] = &memberState{
userID: 7,
profileID: "host",
connection: conn,
}
reg := registrationFor(repo.room.ID, 7, "host", conn)
snapshot, err := service.AttachSessionForConnection(context.Background(), reg, 7, "host", "session-1")
if err != nil {
t.Fatalf("AttachSessionForConnection() error = %v", err)
}
if snapshot.AnchorPositionSeconds != 0 {
t.Fatalf("snapshot anchor = %v, want 0", snapshot.AnchorPositionSeconds)
}
if !snapshot.IsPaused {
t.Fatal("snapshot should remain paused")
}
if repo.room.Generation != 1 {
t.Fatalf("generation = %d, want 1", repo.room.Generation)
}
if len(conn.payloads) == 0 {
t.Fatal("expected room sync commands to be dispatched")
}
}
func TestHostAttachKeepsRoomSelectionAnchorEvenWhenGuestAttached(t *testing.T) {
now := time.Date(2026, 4, 9, 12, 0, 20, 0, time.UTC)
repo := &stubRepo{room: baseRoom(now)}
repo.room.AnchorPositionSeconds = 0
repo.room.IsPaused = true
repo.room.AnchorUpdatedAt = now
repo.room.Generation = 1
service := newServiceForTest(
now,
repo,
&stubSessions{session: &playback.Session{
ID: "host-session",
UserID: 7,
ProfileID: "host",
MediaFileID: 42,
Position: 318,
IsPaused: false,
}},
&stubFiles{file: &models.MediaFile{ID: 42, ContentID: "movie-1"}},
nil,
)
service.rooms[repo.room.ID].members[buildMemberKey(8, "guest")] = &memberState{
userID: 8,
profileID: "guest",
sessionID: "guest-session",
connection: stubConn{},
}
service.rooms[repo.room.ID].members[buildMemberKey(7, "host")] = &memberState{
userID: 7,
profileID: "host",
connection: stubConn{},
}
reg := registrationFor(repo.room.ID, 7, "host", stubConn{})
snapshot, err := service.AttachSessionForConnection(context.Background(), reg, 7, "host", "host-session")
if err != nil {
t.Fatalf("AttachSessionForConnection() error = %v", err)
}
if snapshot.AnchorPositionSeconds != 0 {
t.Fatalf("snapshot anchor = %v, want 0", snapshot.AnchorPositionSeconds)
}
if !snapshot.IsPaused {
t.Fatal("snapshot should remain paused")
}
}
func TestAttachSessionAcceptsEpisodeContentID(t *testing.T) {
now := time.Date(2026, 4, 10, 12, 0, 20, 0, time.UTC)
repo := &stubRepo{room: baseRoom(now)}
repo.room.SelectedContentID = stringPtr("episode-19")
repo.room.AnchorPositionSeconds = 0
repo.room.IsPaused = true
repo.room.AnchorUpdatedAt = now
repo.room.Generation = 1
service := newServiceForTest(
now,
repo,
&stubSessions{session: &playback.Session{
ID: "host-session",
UserID: 7,
ProfileID: "host",
MediaFileID: 42,
Position: 75,
IsPaused: false,
}},
&stubFiles{file: &models.MediaFile{
ID: 42,
ContentID: "series-1",
EpisodeID: "episode-19",
}},
nil,
)
service.rooms[repo.room.ID].members[buildMemberKey(7, "host")] = &memberState{
userID: 7,
profileID: "host",
connection: stubConn{},
}
reg := registrationFor(repo.room.ID, 7, "host", stubConn{})
snapshot, err := service.AttachSessionForConnection(context.Background(), reg, 7, "host", "host-session")
if err != nil {
t.Fatalf("AttachSessionForConnection() error = %v", err)
}
if snapshot.AttachedSessionID != "host-session" {
t.Fatalf("attached session = %q, want host-session", snapshot.AttachedSessionID)
}
if snapshot.AnchorPositionSeconds != 0 {
t.Fatalf("snapshot anchor = %v, want 0", snapshot.AnchorPositionSeconds)
}
}
func TestCreateRoomStartsInLobbyWithoutSelection(t *testing.T) {
now := time.Date(2026, 4, 10, 12, 0, 20, 0, time.UTC)
repo := &stubRepo{}
service := NewService(repo, &stubSessions{}, &stubFiles{}, nil, nil, nil)
service.now = func() time.Time { return now }
room, err := service.CreateRoom(context.Background(), CreateRoomInput{
HostUserID: 7,
HostProfileID: "host",
})
if err != nil {
t.Fatalf("CreateRoom() error = %v", err)
}
if room.Phase != RoomPhaseLobby {
t.Fatalf("phase = %q, want %q", room.Phase, RoomPhaseLobby)
}
if room.SelectionRevision != 0 {
t.Fatalf("selection revision = %d, want 0", room.SelectionRevision)
}
if room.SelectedContentID != nil {
t.Fatalf("selected content = %v, want nil", *room.SelectedContentID)
}
}
func TestHostCanSelectItemFromLobby(t *testing.T) {
now := time.Date(2026, 4, 10, 12, 0, 20, 0, time.UTC)
repo := &stubRepo{room: Room{
ID: "room-1",
Code: "ROOM1234",
JoinToken: "TOKEN1234",
HostUserID: 7,
HostProfileID: "host",
Phase: RoomPhaseLobby,
SelectionMode: RoomSelectionModeHostPick,
GuestControlPolicy: GuestControlPolicyHostOnly,
IsPaused: true,
AnchorUpdatedAt: now,
Generation: 1,
CreatedAt: now,
}}
service := newServiceForTest(
now,
repo,
&stubSessions{},
&stubFiles{},
&stubSelectionResolver{resolved: &ResolvedSelection{
ContentID: "movie-2",
FileID: intPtr(55),
LibraryID: intPtr(6),
}},
)
snapshot, err := service.SelectItem(context.Background(), "room-1", 7, "host", SelectItemInput{
ContentID: "movie-2",
})
if err != nil {
t.Fatalf("SelectItem() error = %v", err)
}
if snapshot.Phase != RoomPhasePlaying {
t.Fatalf("phase = %q, want %q", snapshot.Phase, RoomPhasePlaying)
}
if snapshot.SelectionRevision != 1 {
t.Fatalf("selection revision = %d, want 1", snapshot.SelectionRevision)
}
if snapshot.SelectedContentID == nil || *snapshot.SelectedContentID != "movie-2" {
t.Fatalf("selected content = %v, want movie-2", snapshot.SelectedContentID)
}
if snapshot.AnchorPositionSeconds != 0 {
t.Fatalf("anchor = %v, want 0", snapshot.AnchorPositionSeconds)
}
if !snapshot.IsPaused {
t.Fatal("room should stay paused while waiting for participants to get ready")
}
if snapshot.PlaybackState != RoomPlaybackStateWaiting {
t.Fatalf("playback state = %q, want %q", snapshot.PlaybackState, RoomPlaybackStateWaiting)
}
}
func TestSelectItemClearsStaleMemberSessions(t *testing.T) {
now := time.Date(2026, 4, 10, 12, 0, 20, 0, time.UTC)
repo := &stubRepo{room: baseRoom(now)}
service := newServiceForTest(
now,
repo,
&stubSessions{},
&stubFiles{},
&stubSelectionResolver{resolved: &ResolvedSelection{ContentID: "movie-2"}},
)
guest := &memberState{
userID: 8,
profileID: "guest",
sessionID: "old-session",
isReady: true,
ignoreWait: true,
connection: stubConn{},
}
service.rooms[repo.room.ID].members[buildMemberKey(8, "guest")] = guest
_, err := service.SelectItem(context.Background(), "room-1", 7, "host", SelectItemInput{
ContentID: "movie-2",
})
if err != nil {
t.Fatalf("SelectItem() error = %v", err)
}
if guest.sessionID != "" {
t.Fatalf("guest session = %q, want cleared", guest.sessionID)
}
if guest.isReady || guest.ignoreWait {
t.Fatal("guest readiness flags should reset on new selection")
}
}
func TestGuestCannotSelectItem(t *testing.T) {
now := time.Date(2026, 4, 10, 12, 0, 20, 0, time.UTC)
repo := &stubRepo{room: baseRoom(now)}
service := newServiceForTest(
now,
repo,
&stubSessions{},
&stubFiles{},
&stubSelectionResolver{resolved: &ResolvedSelection{ContentID: "movie-2"}},
)
_, err := service.SelectItem(context.Background(), "room-1", 8, "guest", SelectItemInput{
ContentID: "movie-2",
})
if !errors.Is(err, ErrRoomForbidden) {
t.Fatalf("SelectItem() error = %v, want ErrRoomForbidden", err)
}
}
func TestSelectItemRejectsInvalidSelection(t *testing.T) {
now := time.Date(2026, 4, 10, 12, 0, 20, 0, time.UTC)
repo := &stubRepo{room: baseRoom(now)}
service := newServiceForTest(
now,
repo,
&stubSessions{},
&stubFiles{},
&stubSelectionResolver{err: ErrInvalidSelection},
)
_, err := service.SelectItem(context.Background(), "room-1", 7, "host", SelectItemInput{
ContentID: "series-1",
})
if !errors.Is(err, ErrInvalidSelection) {
t.Fatalf("SelectItem() error = %v, want ErrInvalidSelection", err)
}
}
func TestAttachSessionEnforcesSelectedFileID(t *testing.T) {
now := time.Date(2026, 4, 10, 12, 0, 20, 0, time.UTC)
repo := &stubRepo{room: baseRoom(now)}
repo.room.SelectedFileID = intPtr(99)
service := newServiceForTest(
now,
repo,
&stubSessions{session: &playback.Session{
ID: "host-session",
UserID: 7,
ProfileID: "host",
MediaFileID: 42,
}},
&stubFiles{file: &models.MediaFile{ID: 42, ContentID: "movie-1"}},
nil,
)
service.rooms[repo.room.ID].members[buildMemberKey(7, "host")] = &memberState{
userID: 7,
profileID: "host",
connection: stubConn{},
}
reg := registrationFor(repo.room.ID, 7, "host", stubConn{})
_, err := service.AttachSessionForConnection(context.Background(), reg, 7, "host", "host-session")
if !errors.Is(err, ErrSessionMismatch) {
t.Fatalf("AttachSessionForConnection() error = %v, want ErrSessionMismatch", err)
}
}
func TestDisconnectOfLastUnreadyMemberResumesWaitingRoom(t *testing.T) {
now := time.Date(2026, 4, 10, 12, 0, 20, 0, time.UTC)
repo := &stubRepo{room: baseRoom(now)}
repo.room.PlaybackState = RoomPlaybackStateWaiting
repo.room.IsPaused = true
repo.room.ResumeOnReady = true
service := newServiceForTest(now, repo, &stubSessions{}, &stubFiles{}, nil)
hostConn := &recordingConn{}
service.rooms[repo.room.ID].members[buildMemberKey(7, "host")] = &memberState{
userID: 7,
profileID: "host",
sessionID: "host-session",
isReady: true,
connection: hostConn,
}
guestConn := &recordingConn{}
service.rooms[repo.room.ID].members[buildMemberKey(8, "guest")] = &memberState{
userID: 8,
profileID: "guest",
sessionID: "guest-session",
isReady: false,
connection: guestConn,
}
service.Disconnect(registrationFor(repo.room.ID, 8, "guest", guestConn), false)
if repo.room.PlaybackState != RoomPlaybackStatePlaying {
t.Fatalf("playback state = %q, want %q", repo.room.PlaybackState, RoomPlaybackStatePlaying)
}
foundCommand := false
for _, payload := range hostConn.payloads {
if payload["type"] == "transport_command" {
foundCommand = true
}
}
if !foundCommand {
t.Fatal("expected a resume transport command for the remaining member")
}
}
func TestBufferingReportCannotTeleportRoomAnchor(t *testing.T) {
now := time.Date(2026, 4, 10, 12, 0, 20, 0, time.UTC)
repo := &stubRepo{room: baseRoom(now)}
service := newServiceForTest(now, repo, &stubSessions{}, &stubFiles{}, nil)
guestConn := &recordingConn{}
service.rooms[repo.room.ID].members[buildMemberKey(8, "guest")] = &memberState{
userID: 8,
profileID: "guest",
sessionID: "guest-session",
connection: guestConn,
}
// Anchor was 10s, 10s ago and playing: expected position is ~20s. A
// report claiming 500s must be clamped back to the expected position.
reg := registrationFor(repo.room.ID, 8, "guest", guestConn)
snapshot, err := service.HandleBufferingForConnection(context.Background(), reg, 8, "guest", StateReport{
SessionID: "guest-session",
PositionSeconds: 500,
IsPaused: false,
})
if err != nil {
t.Fatalf("HandleBufferingForConnection() error = %v", err)
}
if snapshot.PlaybackState != RoomPlaybackStateWaiting {
t.Fatalf("playback state = %q, want %q", snapshot.PlaybackState, RoomPlaybackStateWaiting)
}
if snapshot.AnchorPositionSeconds > 20.001 || snapshot.AnchorPositionSeconds < 19.999 {
t.Fatalf("anchor = %v, want ~20 (clamped)", snapshot.AnchorPositionSeconds)
}
}
func TestPingIsClampedToMaxTransportLead(t *testing.T) {
now := time.Date(2026, 4, 10, 12, 0, 20, 0, time.UTC)
repo := &stubRepo{room: baseRoom(now)}
service := newServiceForTest(now, repo, &stubSessions{}, &stubFiles{}, nil)
guestConn := &recordingConn{}
member := &memberState{
userID: 8,
profileID: "guest",
sessionID: "guest-session",
connection: guestConn,
}
service.rooms[repo.room.ID].members[buildMemberKey(8, "guest")] = member
reg := registrationFor(repo.room.ID, 8, "guest", guestConn)
if err := service.HandlePingForConnection(context.Background(), reg, 8, "guest", 3_600_000); err != nil {
t.Fatalf("HandlePingForConnection() error = %v", err)
}
if member.lastPingMS != maxTransportLead.Milliseconds() {
t.Fatalf("lastPingMS = %d, want %d", member.lastPingMS, maxTransportLead.Milliseconds())
}
}
func TestReadyPersistFailureKeepsWaitingState(t *testing.T) {
now := time.Date(2026, 4, 10, 12, 0, 20, 0, time.UTC)
repo := &stubRepo{room: baseRoom(now)}
repo.room.PlaybackState = RoomPlaybackStateWaiting
repo.room.IsPaused = true
repo.room.ResumeOnReady = true
repo.anchorErr = errors.New("database unavailable")
service := newServiceForTest(now, repo, &stubSessions{}, &stubFiles{}, nil)
hostConn := &recordingConn{}
service.rooms[repo.room.ID].members[buildMemberKey(7, "host")] = &memberState{
userID: 7,
profileID: "host",
sessionID: "host-session",
connection: hostConn,
}
reg := registrationFor(repo.room.ID, 7, "host", hostConn)
snapshot, err := service.HandleReadyForConnection(context.Background(), reg, 7, "host", StateReport{
SessionID: "host-session",
})
if err != nil {
t.Fatalf("HandleReadyForConnection() error = %v", err)
}
if snapshot.PlaybackState != RoomPlaybackStateWaiting {
t.Fatalf("playback state = %q, want %q (resume must not be announced when persistence failed)",
snapshot.PlaybackState, RoomPlaybackStateWaiting)
}
live := service.rooms[repo.room.ID]
if live.room.Generation != repo.room.Generation {
t.Fatalf("live generation = %d, want %d (failed write must not leave a phantom generation)",
live.room.Generation, repo.room.Generation)
}
if live.waitingTimer == nil {
t.Fatal("waiting deadline should stay armed so the resume is retried")
}
}
func TestStaleLiveConflictAdoptsDatabaseRow(t *testing.T) {
now := time.Date(2026, 4, 10, 12, 0, 20, 0, time.UTC)
repo := &stubRepo{room: baseRoom(now)}
service := newServiceForTest(now, repo, &stubSessions{}, &stubFiles{}, nil)
// The database row has moved ahead of the cached live copy.
repo.room.Generation = 5
hostConn := &recordingConn{}
service.rooms[repo.room.ID].members[buildMemberKey(7, "host")] = &memberState{
userID: 7,
profileID: "host",
sessionID: "host-session",
connection: hostConn,
}
reg := registrationFor(repo.room.ID, 7, "host", hostConn)
snapshot, err := service.HandleTransportRequestForConnection(context.Background(), reg, 7, "host", TransportRequest{
Action: TransportActionPause,
})
if err != nil {
t.Fatalf("HandleTransportRequestForConnection() error = %v", err)
}
if snapshot.Generation != 5 {
t.Fatalf("snapshot generation = %d, want 5 (conflict must adopt the newer database row)", snapshot.Generation)
}
}
func intPtr(value int) *int {
return &value
}