Files
silo-server/internal/watchtogether/vote_selection_test.go
172beb99ef fix(watch-together): stop a dropped socket reading as the host leaving (#487)
* feat(watch-together): make vote rooms actually vote

selection_mode has been stored, normalized and published since the
feature landed, and nothing has ever read it. A "vote" room behaved
exactly like a host_pick one: members could suggest and vote, the tally
was recorded and broadcast, and then the host promoted whatever they
liked regardless of it.

In a vote room the host now starts the winner rather than choosing it.
Promoting anything other than the leading suggestion is refused, because
being able to overrule the tally makes the mode host_pick with extra
steps and turns the vote counts on everyone else's screen into
decoration.

The winner is the head of the repository's existing ordering
(vote_count DESC, created_at ASC): most votes, ties to whoever suggested
first — deterministic, and re-suggesting a title cannot jump the queue.

A room where nobody has voted has no winner and says so, rather than
quietly promoting the oldest suggestion as though a vote had happened.

host_pick rooms are untouched: the host still promotes freely.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Db4dSxN9tH8yN7uUP549tK

* fix(watch-together): close the second door into a vote room's selection

Gating PromoteSuggestion left SelectItem wide open: it is host-only but
was not gated by selection mode, so the host of a vote room could set any
title directly and bypass the vote entirely. Enforcing the tally on one
path and not the other makes the vote counts on everyone else's screen
decoration.

A vote room now refuses a direct selection outright. The winner is the
only way in.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Db4dSxN9tH8yN7uUP549tK

* fix(watch-together): stop a dropped socket reading as the host leaving

hostDisconnectTTL was 15 seconds, which treated any transient drop as a
departure. An explicit leave and an explicit close already tear the room
down immediately, so this timer only ever covers a host who has NOT said
they are going — and at 15s a host who backgrounded the app, moved
between screens, or hit a brief network blip lost the room for everyone
with a "host_left" nobody could explain.

Two minutes survives a reconnect or an app switch, and is short enough
that a genuinely departed host does not leave a room open all evening.
The janitor still reaps idle rooms independently.

This matters for what the clients are growing into: a room you stay in
while you browse for something to suggest. A client that drops its socket
when the lobby leaves composition should cost you a reconnect, not the
room.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Db4dSxN9tH8yN7uUP549tK

* fix(watch-together): let a vote room actually start its winner

The vote gate landed on both doors into a room's selection, but promoting
the winner walks through SelectItem to commit — so the gate meant to stop
the host bypassing the vote also stopped the vote itself. Vote rooms could
not start playback by any route.

Split the commit path: SelectItem keeps the gate for direct requests, and
PromoteSuggestion goes through the internal path once it has confirmed the
suggestion is the winner. Map ErrVoteRoomSelection in the promote handler
too, so a future regression there reads as a conflict rather than a 500.

Add service-level tests for both gates — the previous tests only covered
the pure winnerFrom helper, which is why the suite stayed green while vote
rooms were non-functional.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: rxwatcher <rxwatcher@users.noreply.github.com>
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-authored-by: Quick <31828688+Quick104@users.noreply.github.com>
2026-07-26 22:01:31 -04:00

143 lines
5.1 KiB
Go

package watchtogether
import (
"context"
"errors"
"testing"
"time"
)
// stubSuggestions serves an already-ordered list, the way the repository's
// "vote_count DESC, created_at ASC" query does.
type stubSuggestions struct {
ordered []Suggestion
}
func (s *stubSuggestions) CreateSuggestion(context.Context, Suggestion) (*Suggestion, error) {
return nil, errors.New("not used")
}
func (s *stubSuggestions) GetSuggestion(_ context.Context, id string) (*Suggestion, error) {
for _, suggestion := range s.ordered {
if suggestion.ID == id {
found := suggestion
return &found, nil
}
}
return nil, ErrSuggestionNotFound
}
func (s *stubSuggestions) ListSuggestions(context.Context, string, string) ([]Suggestion, error) {
out := make([]Suggestion, len(s.ordered))
copy(out, s.ordered)
return out, nil
}
func (s *stubSuggestions) DeleteSuggestion(context.Context, string) error { return nil }
func (s *stubSuggestions) AddVote(context.Context, string, string) error { return nil }
func (s *stubSuggestions) RemoveVote(context.Context, string, string) error {
return nil
}
func newVoteRoomService(t *testing.T, mode RoomSelectionMode, ordered []Suggestion) (*Service, *stubRepo) {
t.Helper()
now := time.Date(2026, 7, 27, 12, 0, 0, 0, time.UTC)
repo := &stubRepo{room: Room{
ID: "room-1",
Code: "ROOM1234",
JoinToken: "TOKEN1234",
HostUserID: 7,
HostProfileID: "host",
Phase: RoomPhaseLobby,
SelectionMode: mode,
GuestControlPolicy: GuestControlPolicyHostOnly,
IsPaused: true,
AnchorUpdatedAt: now,
Generation: 1,
CreatedAt: now,
}}
service := newServiceForTest(
now,
repo,
&stubSessions{},
&stubFiles{},
&stubSelectionResolver{resolved: &ResolvedSelection{ContentID: "movie-winner"}},
)
service.suggestions = &stubSuggestions{ordered: ordered}
t.Cleanup(service.Close)
return service, repo
}
func voteRoomSuggestions() []Suggestion {
return []Suggestion{
{ID: "winner", RoomID: "room-1", ContentID: "movie-winner", Title: "Heat", VoteCount: 3},
{ID: "runner-up", RoomID: "room-1", ContentID: "movie-other", Title: "Alien", VoteCount: 1},
}
}
// The gate on direct selection and the gate on promotion sit on the same code
// path, so a vote room can very easily end up with no way in at all. This is
// the test that catches that: promoting the winner must still start playback.
func TestPromotingTheWinnerStartsAVoteRoom(t *testing.T) {
service, _ := newVoteRoomService(t, RoomSelectionModeVote, voteRoomSuggestions())
snapshot, err := service.PromoteSuggestion(context.Background(), "room-1", "winner", 7, "host")
if err != nil {
t.Fatalf("PromoteSuggestion() error = %v, want the vote winner to start", err)
}
if snapshot.Phase != RoomPhasePlaying {
t.Fatalf("phase = %q, want %q", snapshot.Phase, RoomPhasePlaying)
}
if snapshot.SelectedContentID == nil || *snapshot.SelectedContentID != "movie-winner" {
t.Fatalf("selected content = %v, want movie-winner", snapshot.SelectedContentID)
}
}
func TestPromotingSomethingOtherThanTheWinnerIsRefused(t *testing.T) {
service, _ := newVoteRoomService(t, RoomSelectionModeVote, voteRoomSuggestions())
_, err := service.PromoteSuggestion(context.Background(), "room-1", "runner-up", 7, "host")
if !errors.Is(err, ErrNotVoteWinner) {
t.Fatalf("PromoteSuggestion() error = %v, want ErrNotVoteWinner", err)
}
}
func TestPromotingBeforeAnyoneVotesIsRefused(t *testing.T) {
unvoted := []Suggestion{{ID: "a", RoomID: "room-1", ContentID: "movie-a", VoteCount: 0}}
service, _ := newVoteRoomService(t, RoomSelectionModeVote, unvoted)
_, err := service.PromoteSuggestion(context.Background(), "room-1", "a", 7, "host")
if !errors.Is(err, ErrNoVotesCast) {
t.Fatalf("PromoteSuggestion() error = %v, want ErrNoVotesCast", err)
}
}
// The host bypassing the tally with a direct selection would make the counts on
// everyone else's screen decoration.
func TestDirectSelectionIsRefusedInAVoteRoom(t *testing.T) {
service, _ := newVoteRoomService(t, RoomSelectionModeVote, voteRoomSuggestions())
_, err := service.SelectItem(context.Background(), "room-1", 7, "host", SelectItemInput{
ContentID: "movie-winner",
})
if !errors.Is(err, ErrVoteRoomSelection) {
t.Fatalf("SelectItem() error = %v, want ErrVoteRoomSelection", err)
}
}
// Neither gate applies to a host_pick room: the host picks, votes are not part
// of the mode, and an unvoted suggestion is still promotable.
func TestHostPickRoomIsUntouchedByTheVoteGates(t *testing.T) {
unvoted := []Suggestion{{ID: "a", RoomID: "room-1", ContentID: "movie-winner", VoteCount: 0}}
service, _ := newVoteRoomService(t, RoomSelectionModeHostPick, unvoted)
if _, err := service.SelectItem(context.Background(), "room-1", 7, "host", SelectItemInput{
ContentID: "movie-winner",
}); err != nil {
t.Fatalf("SelectItem() error = %v, want a host_pick room to select directly", err)
}
if _, err := service.PromoteSuggestion(context.Background(), "room-1", "a", 7, "host"); err != nil {
t.Fatalf("PromoteSuggestion() error = %v, want a host_pick room to promote freely", err)
}
}