Files
silo-server/internal/watchtogether/service_test.go
T

591 lines
16 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
}
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) 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.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 dispatchedCommand struct {
sessionID string
name playback.CommandName
}
type stubDispatcher struct {
commands []dispatchedCommand
}
func (s *stubDispatcher) DispatchToSession(
command playback.CommandEnvelope,
_ time.Duration,
_ func(),
) playback.CommandDispatchResult {
s.commands = append(s.commands, dispatchedCommand{
sessionID: command.SessionID,
name: command.Name,
})
return playback.CommandDispatchResult{}
}
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, dispatcher *stubDispatcher, resolver WatchTogetherSelectionResolver) *Service {
service := NewService(repo, sessions, files, dispatcher, resolver, 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 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
dispatcher := &stubDispatcher{}
conn := &recordingConn{}
service := newServiceForTest(
now,
repo,
&stubSessions{},
&stubFiles{file: &models.MediaFile{ID: 42, ContentID: "movie-1"}},
dispatcher,
nil,
)
service.rooms[repo.room.ID].members[buildMemberKey(8, "guest")] = &memberState{
userID: 8,
profileID: "guest",
sessionID: "session-1",
connection: conn,
}
position := 120.0
_, err := service.HandleTransportRequest(context.Background(), repo.room.ID, 8, "guest", TransportRequest{
Action: TransportActionSeek,
PositionSeconds: &position,
IsPaused: false,
})
if !errors.Is(err, ErrTransportNotAllowed) {
t.Fatalf("HandleTransportRequest(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)}
dispatcher := &stubDispatcher{}
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"}},
dispatcher,
nil,
)
service.rooms[repo.room.ID].members[buildMemberKey(8, "guest")] = &memberState{
userID: 8,
profileID: "guest",
sessionID: "session-1",
connection: conn,
}
_, err := service.HandleStateReport(context.Background(), repo.room.ID, 8, "guest", StateReport{
SessionID: "session-1",
PositionSeconds: 2,
IsPaused: false,
})
if err != nil {
t.Fatalf("HandleStateReport() 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
dispatcher := &stubDispatcher{}
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"}},
dispatcher,
nil,
)
service.rooms[repo.room.ID].members[buildMemberKey(7, "host")] = &memberState{
userID: 7,
profileID: "host",
connection: conn,
}
snapshot, err := service.AttachSession(context.Background(), repo.room.ID, 7, "host", "session-1")
if err != nil {
t.Fatalf("AttachSession() 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
dispatcher := &stubDispatcher{}
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"}},
dispatcher,
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{},
}
snapshot, err := service.AttachSession(context.Background(), repo.room.ID, 7, "host", "host-session")
if err != nil {
t.Fatalf("AttachSession() 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
dispatcher := &stubDispatcher{}
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",
}},
dispatcher,
nil,
)
service.rooms[repo.room.ID].members[buildMemberKey(7, "host")] = &memberState{
userID: 7,
profileID: "host",
connection: stubConn{},
}
snapshot, err := service.AttachSession(context.Background(), repo.room.ID, 7, "host", "host-session")
if err != nil {
t.Fatalf("AttachSession() 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{}, &stubDispatcher{}, 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{},
&stubDispatcher{},
&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 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{},
&stubDispatcher{},
&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{},
&stubDispatcher{},
&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)
dispatcher := &stubDispatcher{}
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"}},
dispatcher,
nil,
)
service.rooms[repo.room.ID].members[buildMemberKey(7, "host")] = &memberState{
userID: 7,
profileID: "host",
connection: stubConn{},
}
_, err := service.AttachSession(context.Background(), repo.room.ID, 7, "host", "host-session")
if !errors.Is(err, ErrSessionMismatch) {
t.Fatalf("AttachSession() error = %v, want ErrSessionMismatch", err)
}
}
func intPtr(value int) *int {
return &value
}