This commit is contained in:
2026-10-23 03:02:22 +07:00
parent 980ca18b60
commit 0879a8946a
20 changed files with 1827 additions and 960 deletions
+4
View File
@@ -14,6 +14,8 @@ func mapScreenPlanSteps(steps []*proto.ScreenPlanStep) []screen_plan_service.Ste
}
mapped = append(mapped, screen_plan_service.Step{
Mode: int32(step.Mode),
QRURL: step.QrUrl,
TimerType: int32(step.TimerType),
Text: step.Text,
TimerLabel: step.TimerLabel,
TimerDurationSeconds: step.TimerDurationSeconds,
@@ -30,6 +32,8 @@ func mapScreenPlan(plan *screen_plan_service.Plan) *proto.ScreenPlan {
for _, step := range plan.Steps {
steps = append(steps, &proto.ScreenPlanStep{
Mode: proto.ScreenMode(step.Mode),
QrUrl: step.QRURL,
TimerType: proto.ScreenTimerType(step.TimerType),
Text: step.Text,
TimerLabel: step.TimerLabel,
TimerDurationSeconds: step.TimerDurationSeconds,
+40 -3
View File
@@ -222,6 +222,8 @@ func screenState(session *screen_service.ScreenSession) *proto.ScreenState {
TimerRemainingSeconds: timerRemaining,
TimerState: timerState,
Text: session.Text,
QrUrl: session.QRURL,
TimerType: proto.ScreenTimerType(session.TimerType),
}
if time.Since(session.LastSeen) <= 5*time.Second {
state.Status = "connected"
@@ -277,6 +279,15 @@ func (s *server) SetScreenMode(ctx context.Context, req *proto.SetScreenModeReq)
if _, err := s.gameService.GetGame(ctx, int(req.GameID)); err != nil {
return nil, status.Error(codes.NotFound, "игра не найдена")
}
if req.Mode == proto.ScreenMode_SCREEN_MODE_QR {
if !screen_service.ValidQR(req.QrUrl, req.Text) {
return nil, status.Error(codes.InvalidArgument, "Укажите ссылку HTTP/HTTPS до 2048 байт и текст до 10000 байт")
}
if err := s.screenService.SetQR(ctx, int(req.GameID), req.QrUrl, req.Text); err != nil {
return nil, status.Error(codes.Internal, "не удалось изменить QR-код")
}
return &proto.SetScreenModeRsp{}, nil
}
if err := s.screenService.SetMode(ctx, int(req.GameID), int32(req.Mode)); err != nil {
return nil, status.Error(codes.Internal, "не удалось изменить режим экранов")
}
@@ -287,12 +298,12 @@ func (s *server) SetScreenTimer(ctx context.Context, req *proto.SetScreenTimerRe
if !canManageScreens(ctx) {
return nil, status.Error(codes.PermissionDenied, "permission denied")
}
if req == nil || req.GameID <= 0 || req.DurationSeconds <= 0 || req.DurationSeconds > 24*60*60 {
if req == nil || req.GameID <= 0 || req.DurationSeconds <= 0 || req.DurationSeconds > 24*60*60 || !screen_service.ValidTimerType(int32(req.TimerType)) {
return nil, status.Error(codes.InvalidArgument, "некорректные параметры таймера")
}
label := strings.TrimSpace(req.Label)
if label == "" {
label = "Перерыв"
label = screen_service.TimerLabel(int32(req.TimerType))
}
if len(label) > 200 {
return nil, status.Error(codes.InvalidArgument, "слишком длинная надпись таймера")
@@ -300,7 +311,7 @@ func (s *server) SetScreenTimer(ctx context.Context, req *proto.SetScreenTimerRe
if _, err := s.gameService.GetGame(ctx, int(req.GameID)); err != nil {
return nil, status.Error(codes.NotFound, "игра не найдена")
}
if err := s.screenService.SetTimer(ctx, int(req.GameID), req.DurationSeconds, label); err != nil {
if err := s.screenService.SetTimer(ctx, int(req.GameID), req.DurationSeconds, label, int32(req.TimerType)); err != nil {
return nil, status.Error(codes.Internal, "не удалось настроить таймер экранов")
}
return &proto.SetScreenTimerRsp{}, nil
@@ -366,3 +377,29 @@ func (s *server) GetScreenResults(ctx context.Context, req *proto.GetScreenResul
}
return &proto.GetScreenResultsRsp{Questions: mapResultsQuestions(results.Questions), Teams: mapTeamResults(results.Teams)}, nil
}
func (s *server) GetScreenTeams(ctx context.Context, req *proto.GetScreenTeamsReq) (*proto.GetScreenTeamsRsp, error) {
if req == nil || req.ScreenID == "" || req.AccessToken == "" {
return nil, status.Error(codes.InvalidArgument, "некорректные данные экрана")
}
id, err := strconv.Atoi(req.ScreenID)
if err != nil || id <= 0 {
return nil, status.Error(codes.InvalidArgument, "некорректный идентификатор экрана")
}
session, err := s.screenService.Get(ctx, id, hashScreenToken(req.AccessToken))
if errors.Is(err, screen_service.ErrScreenNotFound) {
return nil, status.Error(codes.Unauthenticated, "недействительная сессия экрана")
}
if err != nil || session.GameID == nil {
return nil, status.Error(codes.FailedPrecondition, "экран не привязан к игре")
}
teams, err := s.teamService.GetScreenTeams(ctx, *session.GameID)
if err != nil {
return nil, status.Error(codes.Internal, "не удалось получить команды")
}
result := make([]*proto.ScreenTeam, 0, len(teams))
for _, team := range teams {
result = append(result, &proto.ScreenTeam{ID: team.ID, Name: team.Name})
}
return &proto.GetScreenTeamsRsp{Teams: result}, nil
}
+19
View File
@@ -26,3 +26,22 @@ func TestSetScreenModeRejectsRemovedModes(t *testing.T) {
}
}
}
func TestScreenTeamsRejectsInvalidAccess(t *testing.T) {
tests := []struct {
name string
req *proto.GetScreenTeamsReq
}{
{"missing", nil},
{"missing token", &proto.GetScreenTeamsReq{ScreenID: "1"}},
{"bad ID", &proto.GetScreenTeamsReq{ScreenID: "bad", AccessToken: "token"}},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
_, err := (&server{}).GetScreenTeams(context.Background(), tt.req)
if status.Code(err) != codes.InvalidArgument {
t.Fatalf("unexpected error: %v", err)
}
})
}
}
@@ -14,6 +14,8 @@ var ErrPlanNotFound = errors.New("план экранов не найден")
// Step is a stored screen plan step.
type Step struct {
QRURL string `json:"qrUrl,omitempty"`
TimerType int32 `json:"timerType,omitempty"`
Mode int32 `json:"mode"`
Text string `json:"text,omitempty"`
TimerLabel string `json:"timerLabel,omitempty"`
+62 -1
View File
@@ -8,6 +8,8 @@ import (
"sync"
"testing"
"time"
"evening_detective_server/internal/services/screen_service"
)
func TestReorderPreservesPlaybackAndRejectsChangedMembership(t *testing.T) {
@@ -135,7 +137,7 @@ func TestBindInitializesAudioWithoutChangingTransport(t *testing.T) {
if isNewBinding {
t.Fatal("repeat binding would reset transport")
}
requireNoError(t, f.screens.InitializeScreen(ctx, second, f.gameID, 1, "", "Pause", 600))
requireNoError(t, f.screens.InitializeScreen(ctx, second, f.gameID, 1, "", "Pause", 600, "", 0))
original, err := f.screens.Get(ctx, first, "first")
requireNoError(t, err)
joined, err := f.screens.Get(ctx, second, "second")
@@ -245,3 +247,62 @@ func TestConcurrentBindingsAssignSoundOnce(t *testing.T) {
})
}
}
func TestNewScreenStatePersistsAndCopiesToJoiningScreen(t *testing.T) {
tests := []struct {
name string
mode int32
link, label string
timerType int32
}{
{name: "QR", mode: 9, link: "https://example.org/join"},
{name: "timer", mode: 7, label: "Обсуждение", timerType: 2},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
f := newPlaylistFixture(t)
ctx := context.Background()
first, err := f.screens.Create(ctx, "first-hash")
requireNoError(t, err)
requireNoError(t, f.screens.Bind(ctx, first, f.gameID, "first-hash"))
if tt.mode == 9 {
service := screen_service.NewScreenService(f.screens)
requireNoError(t, service.InitializeScreen(ctx, first, f.gameID, 9, "Присоединяйтесь", "", 0, tt.link, 0))
initialized, err := f.screens.Get(ctx, first, "first-hash")
requireNoError(t, err)
if initialized.TimerDurationSeconds != 600 {
t.Fatal("non-timer plan initialized an invalid timer")
}
requireNoError(t, f.screens.SetQR(ctx, f.gameID, tt.link, "Присоединяйтесь"))
} else {
requireNoError(t, f.screens.SetTimer(ctx, f.gameID, 60, tt.label, tt.timerType))
}
second, err := f.screens.Create(ctx, "second-hash")
requireNoError(t, err)
requireNoError(t, f.screens.Bind(ctx, second, f.gameID, "second-hash"))
requireNoError(t, f.screens.InitializeScreen(ctx, second, f.gameID, 1, "", "Перерыв", 600, "", 0))
copied, err := f.screens.Get(ctx, second, "second-hash")
requireNoError(t, err)
if copied.Mode != tt.mode || copied.QRURL != tt.link || copied.TimerType != tt.timerType {
t.Fatalf("joining screen lost state: %+v", copied)
}
if tt.mode == 9 && copied.Text != "Присоединяйтесь" {
t.Fatal("lost QR caption")
}
if tt.mode == 7 && (copied.TimerLabel != tt.label || copied.TimerDurationSeconds != 60) {
t.Fatal("lost timer settings")
}
all, err := f.screens.ListByGame(ctx, f.gameID)
requireNoError(t, err)
if len(all) != 2 || all[1].QRURL != copied.QRURL || all[1].TimerType != copied.TimerType {
t.Fatal("state list lost new fields")
}
requireNoError(t, f.screens.Unbind(ctx, second))
detached, err := f.screens.Get(ctx, second, "second-hash")
requireNoError(t, err)
if detached.QRURL != "" || detached.TimerType != 0 {
t.Fatal("unbind retained another game's settings")
}
})
}
}
+99 -20
View File
@@ -29,6 +29,8 @@ type ScreenSession struct {
TimerRemainingSeconds int64
TimerState int32
TimerEndsAt *time.Time
QRURL string
TimerType int32
Text string
}
@@ -55,20 +57,41 @@ func (r *ScreenSessionsRepo) Create(ctx context.Context, tokenHash string) (int,
// Get returns a screen session by ID and token hash.
func (r *ScreenSessionsRepo) Get(ctx context.Context, id int, tokenHash string) (*ScreenSession, error) {
session := &ScreenSession{ID: id}
err := r.pool.QueryRow(ctx, `
SELECT game_id, sound_enabled, last_seen_at, mode, state_version, position_ms, command, volume,
timer_label, timer_duration_seconds, timer_remaining_seconds, timer_state, timer_ends_at, text
err := r.pool.QueryRow(
ctx,
`SELECT
game_id,
sound_enabled,
last_seen_at,
mode,
state_version,
position_ms,
command,
volume,
timer_label,
timer_duration_seconds,
timer_remaining_seconds,
timer_state,
timer_ends_at,
text,
qr_url,
timer_type
FROM screen_sessions
WHERE id = $1 AND access_token_hash = $2`, id, tokenHash).
WHERE id = $1 AND access_token_hash = $2`,
id,
tokenHash,
).
Scan(&session.GameID, &session.Sound, &session.LastSeen, &session.Mode, &session.Version, &session.Position, &session.Command, &session.Volume,
&session.TimerLabel, &session.TimerDurationSeconds, &session.TimerRemainingSeconds, &session.TimerState, &session.TimerEndsAt, &session.Text)
&session.TimerLabel, &session.TimerDurationSeconds, &session.TimerRemainingSeconds, &session.TimerState, &session.TimerEndsAt, &session.Text, &session.QRURL, &session.TimerType)
if errors.Is(err, pgx.ErrNoRows) {
return nil, ErrScreenNotFound
}
if err != nil {
return nil, err
}
_, err = r.pool.Exec(ctx, `UPDATE screen_sessions SET last_seen_at = NOW() WHERE id = $1`, id)
_, err = r.pool.Exec(ctx, `UPDATE screen_sessions SET last_seen_at = NOW() WHERE id = $1`,
id,
)
if err != nil {
return nil, err
}
@@ -78,12 +101,31 @@ func (r *ScreenSessionsRepo) Get(ctx context.Context, id int, tokenHash string)
// ListByGame returns screen sessions for a game.
func (r *ScreenSessionsRepo) ListByGame(ctx context.Context, gameID int) ([]*ScreenSession, error) {
rows, err := r.pool.Query(ctx, `
SELECT id, game_id, sound_enabled, last_seen_at, mode, state_version, position_ms, command, volume,
timer_label, timer_duration_seconds, timer_remaining_seconds, timer_state, timer_ends_at, text
rows, err := r.pool.Query(
ctx,
`SELECT
id,
game_id,
sound_enabled,
last_seen_at,
mode,
state_version,
position_ms,
command,
volume,
timer_label,
timer_duration_seconds,
timer_remaining_seconds,
timer_state,
timer_ends_at,
text,
qr_url,
timer_type
FROM screen_sessions
WHERE game_id = $1
ORDER BY id`, gameID)
ORDER BY id`,
gameID,
)
if err != nil {
return nil, err
}
@@ -93,7 +135,7 @@ func (r *ScreenSessionsRepo) ListByGame(ctx context.Context, gameID int) ([]*Scr
for rows.Next() {
session := &ScreenSession{}
if err := rows.Scan(&session.ID, &session.GameID, &session.Sound, &session.LastSeen, &session.Mode, &session.Version, &session.Position, &session.Command, &session.Volume,
&session.TimerLabel, &session.TimerDurationSeconds, &session.TimerRemainingSeconds, &session.TimerState, &session.TimerEndsAt, &session.Text); err != nil {
&session.TimerLabel, &session.TimerDurationSeconds, &session.TimerRemainingSeconds, &session.TimerState, &session.TimerEndsAt, &session.Text, &session.QRURL, &session.TimerType); err != nil {
return nil, err
}
sessions = append(sessions, session)
@@ -148,10 +190,12 @@ func (r *ScreenSessionsRepo) SetCommand(ctx context.Context, gameID int, command
}
// SetTimer updates the display timer for a game.
func (r *ScreenSessionsRepo) SetTimer(ctx context.Context, gameID int, duration int64, label string) error {
_, err := r.pool.Exec(ctx, `
UPDATE screen_sessions
func (r *ScreenSessionsRepo) SetTimer(ctx context.Context, gameID int, duration int64, label string, timerType int32) error {
_, err := r.pool.Exec(
ctx,
`UPDATE screen_sessions
SET mode = 7,
timer_type = $4,
timer_label = $2,
timer_duration_seconds = $3,
timer_remaining_seconds = $3,
@@ -161,7 +205,12 @@ func (r *ScreenSessionsRepo) SetTimer(ctx context.Context, gameID int, duration
state_version = state_version + 1,
text = '',
updated_at = NOW()
WHERE game_id = $1`, gameID, label, duration)
WHERE game_id = $1`,
gameID,
label,
duration,
timerType,
)
return err
}
@@ -280,12 +329,15 @@ func (r *ScreenSessionsRepo) BindWithAudio(ctx context.Context, id, gameID int,
// Unbind detaches a screen session from its game.
func (r *ScreenSessionsRepo) Unbind(ctx context.Context, id int) error {
result, err := r.pool.Exec(ctx, `
UPDATE screen_sessions
result, err := r.pool.Exec(
ctx,
`UPDATE screen_sessions
SET game_id = NULL,
mode = 1,
command = 0,
position_ms = 0,
qr_url = '',
timer_type = 0,
timer_label = 'Перерыв',
timer_duration_seconds = 600,
timer_remaining_seconds = 600,
@@ -294,7 +346,9 @@ func (r *ScreenSessionsRepo) Unbind(ctx context.Context, id int) error {
text = '',
state_version = state_version + 1,
updated_at = NOW()
WHERE id = $1`, id)
WHERE id = $1`,
id,
)
if err != nil {
return err
}
@@ -339,12 +393,14 @@ func (r *ScreenSessionsRepo) SetSound(ctx context.Context, id int, enabled bool)
}
// InitializeScreen copies a live display or uses the plan for the first screen.
func (r *ScreenSessionsRepo) InitializeScreen(ctx context.Context, id, gameID int, mode int32, text, label string, duration int64) error {
func (r *ScreenSessionsRepo) InitializeScreen(ctx context.Context, id, gameID int, mode int32, text, label string, duration int64, qrURL string, timerType int32) error {
_, err := r.pool.Exec(
ctx,
`UPDATE screen_sessions target
SET mode = COALESCE(source.mode, $3),
text = COALESCE(source.text, $4),
qr_url = COALESCE(source.qr_url, $7),
timer_type = COALESCE(source.timer_type, $8),
command = COALESCE(source.command, 0),
position_ms = COALESCE(source.position_ms, 0),
timer_label = COALESCE(source.timer_label, $5),
@@ -365,7 +421,9 @@ func (r *ScreenSessionsRepo) InitializeScreen(ctx context.Context, id, gameID in
timer_duration_seconds,
timer_remaining_seconds,
timer_state,
timer_ends_at
timer_ends_at,
qr_url,
timer_type
FROM screen_sessions
WHERE game_id = $2 AND id <> $1
ORDER BY updated_at DESC, id DESC
@@ -378,6 +436,27 @@ func (r *ScreenSessionsRepo) InitializeScreen(ctx context.Context, id, gameID in
text,
label,
duration,
qrURL,
timerType,
)
return err
}
// SetQR updates QR content and mode together.
func (r *ScreenSessionsRepo) SetQR(ctx context.Context, gameID int, link, text string) error {
_, err := r.pool.Exec(
ctx,
`UPDATE screen_sessions
SET mode = 9,
qr_url = $2,
text = $3,
command = 0,
state_version = state_version + 1,
updated_at = NOW()
WHERE game_id = $1`,
gameID,
link,
text,
)
return err
}
@@ -7,6 +7,8 @@ func mapPlan(plan *game_screen_plans_repo.Plan) *Plan {
for _, step := range plan.Steps {
steps = append(steps, Step{
Mode: step.Mode,
QRURL: step.QRURL,
TimerType: step.TimerType,
Text: step.Text,
TimerLabel: step.TimerLabel,
TimerDurationSeconds: step.TimerDurationSeconds,
@@ -20,6 +22,8 @@ func mapSteps(steps []Step) []game_screen_plans_repo.Step {
for _, step := range steps {
mapped = append(mapped, game_screen_plans_repo.Step{
Mode: step.Mode,
QRURL: step.QRURL,
TimerType: step.TimerType,
Text: step.Text,
TimerLabel: step.TimerLabel,
TimerDurationSeconds: step.TimerDurationSeconds,
@@ -21,6 +21,8 @@ var (
// Step is a screen plan step used by the application layer.
type Step struct {
QRURL string
TimerType int32
Mode int32
Text string
TimerLabel string
@@ -103,13 +105,13 @@ func (s *ScreenPlanService) SetStep(ctx context.Context, gameID, currentStep int
func (s *ScreenPlanService) ApplyCurrent(ctx context.Context, gameID, screenID int) error {
plan, err := s.Get(ctx, gameID)
if errors.Is(err, ErrPlanNotFound) {
return s.screenService.InitializeScreen(ctx, screenID, gameID, 1, "", "Перерыв", 600)
return s.screenService.InitializeScreen(ctx, screenID, gameID, 1, "", "Перерыв", 600, "", 0)
}
if err != nil {
return err
}
step := plan.Steps[plan.CurrentStep]
return s.screenService.InitializeScreen(ctx, screenID, gameID, step.Mode, step.Text, step.TimerLabel, step.TimerDurationSeconds)
return s.screenService.InitializeScreen(ctx, screenID, gameID, step.Mode, step.Text, step.TimerLabel, step.TimerDurationSeconds, step.QRURL, step.TimerType)
}
func normalizePlan(steps []Step, currentStep int) ([]Step, error) {
@@ -126,12 +128,18 @@ func normalizePlan(steps []Step, currentStep int) ([]Step, error) {
if step.Mode == 8 && (step.Text == "" || len(step.Text) > 10000) {
return nil, ErrInvalidPlan
}
if step.Mode == 9 && !screen_service.ValidQR(step.QRURL, step.Text) {
return nil, ErrInvalidPlan
}
if !screen_service.ValidTimerType(step.TimerType) {
return nil, ErrInvalidPlan
}
if step.Mode == 7 {
if step.TimerDurationSeconds <= 0 || step.TimerDurationSeconds > 24*60*60 {
return nil, ErrInvalidPlan
}
if step.TimerLabel == "" {
step.TimerLabel = "Перерыв"
step.TimerLabel = screen_service.TimerLabel(step.TimerType)
}
if len(step.TimerLabel) > 200 {
return nil, ErrInvalidPlan
@@ -144,11 +152,14 @@ func normalizePlan(steps []Step, currentStep int) ([]Step, error) {
func (s *ScreenPlanService) applyStep(ctx context.Context, gameID int, plan *Plan) error {
step := plan.Steps[plan.CurrentStep]
if step.Mode == 9 {
return s.screenService.SetQR(ctx, gameID, step.QRURL, step.Text)
}
if step.Mode == 8 {
return s.screenService.SetText(ctx, gameID, step.Text)
}
if step.Mode == 7 {
return s.screenService.SetTimer(ctx, gameID, step.TimerDurationSeconds, step.TimerLabel)
return s.screenService.SetTimer(ctx, gameID, step.TimerDurationSeconds, step.TimerLabel, step.TimerType)
}
return s.screenService.SetMode(ctx, gameID, step.Mode)
}
@@ -3,6 +3,8 @@ package screen_plan_service
import (
"errors"
"testing"
"evening_detective_server/internal/repos/game_screen_plans_repo"
)
func TestNormalizePlan(t *testing.T) {
@@ -52,3 +54,38 @@ func TestNormalizePlan(t *testing.T) {
})
}
}
func TestNormalizeNewScreens(t *testing.T) {
tests := []struct {
name string
step Step
wantLabel string
wantErr bool
}{
{name: "QR", step: Step{Mode: 9, QRURL: "https://example.org", Text: ""}},
{name: "teams", step: Step{Mode: 10}},
{name: "invalid QR", step: Step{Mode: 9, QRURL: "javascript:alert(1)"}, wantErr: true},
{name: "before game", step: Step{Mode: 7, TimerType: 1, TimerDurationSeconds: 60}, wantLabel: "До начала игры"},
{name: "until end", step: Step{Mode: 7, TimerType: 2, TimerDurationSeconds: 60}, wantLabel: "До конца игры"},
{name: "custom legacy", step: Step{Mode: 7, TimerLabel: "Обсуждение", TimerDurationSeconds: 60}, wantLabel: "Обсуждение"},
{name: "invalid type", step: Step{Mode: 7, TimerType: 3, TimerDurationSeconds: 60}, wantErr: true},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result, err := normalizePlan([]Step{tt.step}, 0)
if (err != nil) != tt.wantErr {
t.Fatalf("normalizePlan() error: %v", err)
}
if tt.wantErr {
return
}
if result[0].TimerLabel != tt.wantLabel {
t.Fatalf("label = %q", result[0].TimerLabel)
}
roundTrip := mapPlan(&game_screen_plans_repo.Plan{Steps: mapSteps(result)})
if roundTrip.Steps[0] != result[0] {
t.Fatal("lost step fields")
}
})
}
}
@@ -23,6 +23,8 @@ type ScreenSession struct {
TimerRemainingSeconds int64
TimerState int32
TimerEndsAt *time.Time
QRURL string
TimerType int32
Text string
}
@@ -43,5 +45,7 @@ func mapSession(session *screen_sessions_repo.ScreenSession) *ScreenSession {
TimerState: session.TimerState,
TimerEndsAt: session.TimerEndsAt,
Text: session.Text,
QRURL: session.QRURL,
TimerType: session.TimerType,
}
}
+46 -5
View File
@@ -3,6 +3,8 @@ package screen_service
import (
"context"
"errors"
"net/url"
"strings"
"evening_detective_server/internal/repos/screen_sessions_repo"
)
@@ -53,7 +55,7 @@ func (s *ScreenService) ListByGame(ctx context.Context, gameID int) ([]*ScreenSe
// ValidMode reports whether mode is a supported display mode.
func ValidMode(mode int32) bool {
switch mode {
case 1, 2, 4, 6, 7, 8:
case 1, 2, 4, 6, 7, 8, 9, 10:
return true
default:
return false
@@ -69,8 +71,11 @@ func (s *ScreenService) SetMode(ctx context.Context, gameID int, mode int32) err
}
// SetTimer updates the display timer for a game.
func (s *ScreenService) SetTimer(ctx context.Context, gameID int, duration int64, label string) error {
return s.repo.SetTimer(ctx, gameID, duration, label)
func (s *ScreenService) SetTimer(ctx context.Context, gameID int, duration int64, label string, timerType int32) error {
if !ValidTimerType(timerType) {
return ErrInvalidMode
}
return s.repo.SetTimer(ctx, gameID, duration, label, timerType)
}
// SetText updates the text screen for a game.
@@ -112,6 +117,42 @@ func mapError(err error) error {
}
// InitializeScreen joins the current display without resetting other screens.
func (s *ScreenService) InitializeScreen(ctx context.Context, id, gameID int, mode int32, text, label string, duration int64) error {
return s.repo.InitializeScreen(ctx, id, gameID, mode, text, label, duration)
func (s *ScreenService) InitializeScreen(ctx context.Context, id, gameID int, mode int32, text, label string, duration int64, qrURL string, timerType int32) error {
// Non-timer steps have no duration, but every state includes a valid timer.
if mode != 7 && duration == 0 {
duration = 600
}
if label == "" {
label = TimerLabel(timerType)
}
return s.repo.InitializeScreen(ctx, id, gameID, mode, text, label, duration, qrURL, timerType)
}
// ValidQR validates a URL and caption without fetching the URL.
func ValidQR(link, text string) bool {
u, err := url.Parse(link)
return err == nil && (u.Scheme == "https" || u.Scheme == "http") && u.Hostname() != "" && u.User == nil && len(link) <= 2048 && len(text) <= 10000 && strings.TrimSpace(link) == link
}
// ValidTimerType reports whether the timer category is supported.
func ValidTimerType(kind int32) bool { return kind >= 0 && kind <= 2 }
// TimerLabel returns the initial caption for a timer category.
func TimerLabel(kind int32) string {
switch kind {
case 1:
return "До начала игры"
case 2:
return "До конца игры"
default:
return "Перерыв"
}
}
// SetQR updates the QR screen for a game.
func (s *ScreenService) SetQR(ctx context.Context, gameID int, link, text string) error {
if !ValidQR(link, text) {
return ErrInvalidMode
}
return s.repo.SetQR(ctx, gameID, link, text)
}
@@ -3,14 +3,38 @@ package screen_service
import (
"context"
"errors"
"strings"
"testing"
)
func TestSetModeRejectsUnsupportedModes(t *testing.T) {
s := NewScreenService(nil)
for _, mode := range []int32{-1, 0, 3, 5, 9} {
for _, mode := range []int32{-1, 0, 3, 5, 11} {
if err := s.SetMode(context.Background(), 1, mode); !errors.Is(err, ErrInvalidMode) {
t.Errorf("SetMode(%d) = %v, want ErrInvalidMode", mode, err)
}
}
}
func TestValidQR(t *testing.T) {
tests := []struct {
name, link, caption string
valid bool
}{
{"link without caption", "https://example.org/join?q=1#join", "", true},
{"caption", "http://localhost:8080", "Команды", true},
{"missing link", "", "Текст", false},
{"script", "javascript:alert(1)", "", false},
{"missing host", "https:///path", "", false},
{"credentials", "https://user:secret@example.org", "", false},
{"long link", "https://example.org/" + strings.Repeat("x", 2048), "", false},
{"long caption", "https://example.org", strings.Repeat("я", 5001), false},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := ValidQR(tt.link, tt.caption); got != tt.valid {
t.Fatalf("ValidQR() = %v", got)
}
})
}
}
@@ -210,3 +210,8 @@ func (s *TeamService) CheckPresence(ctx context.Context, teamID string, password
}
return team.GameID, nil
}
// GetScreenTeams returns active registrations for the public screen.
func (s *TeamService) GetScreenTeams(ctx context.Context, gameID int) ([]*repos.Team, error) {
return s.teamsRepo.GetActiveTeamsByGameID(ctx, gameID)
}