This commit is contained in:
2026-09-17 01:55:28 +07:00
parent 92e4fdd139
commit 49fe193ed7
5 changed files with 58 additions and 39 deletions
@@ -1,4 +1,4 @@
package game_service
package participation_service
import (
"context"
@@ -12,6 +12,7 @@ import (
"evening_detective_server/internal/modules/email_sender"
"evening_detective_server/internal/repos/teams_repo"
"evening_detective_server/internal/services/game_service"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgxpool"
)
@@ -88,7 +89,7 @@ func TestParticipationPostgres(t *testing.T) {
exec(strings.Split(string(data), "-- +goose Down")[0])
}
repo := teams_repo.NewTeamsRepo(pool)
service := &GameService{teamsRepo: repo, appBaseURL: "https://example.org"}
service := NewParticipationService(repo, "https://example.org")
state := func(id int, want string) {
t.Helper()
team, err := repo.GetTeamByID(ctx, id)
@@ -98,9 +99,10 @@ func TestParticipationPostgres(t *testing.T) {
}
state(1, "pending")
state(2, "pending") // Existing teams are not changed by the migration.
exec(`UPDATE teams SET captain_email='captain@example.org',participants_count=5;
// Model registration output explicitly: direct SQL inserts do not auto-confirm teams.
exec(`UPDATE teams SET captain_email='captain@example.org',participants_count=5 WHERE id=1;
INSERT INTO teams(game_id,created_at,captain_email) SELECT 1,start_at-INTERVAL '24 hours','boundary@example.org' FROM games WHERE id=1;
INSERT INTO teams(game_id,created_at,captain_email) SELECT 1,start_at-INTERVAL '24 hours'+INTERVAL '1 microsecond','late@example.org' FROM games WHERE id=1;
INSERT INTO teams(game_id,created_at,captain_email,participation_status,participation_source,participation_answered_at) SELECT 1,start_at-INTERVAL '24 hours'+INTERVAL '1 microsecond','late@example.org','confirmed','automatic',NOW() FROM games WHERE id=1;
INSERT INTO teams(game_id,captain_email) VALUES(2,'future@example.org');
INSERT INTO teams(game_id,created_at) SELECT 1,start_at-INTERVAL '25 hours' FROM games WHERE id=1;`)
state(3, "pending")
@@ -156,7 +158,7 @@ func TestParticipationPostgres(t *testing.T) {
}
sender.err = nil
// Expired lease is recovered after a worker restart.
exec(`UPDATE teams SET participation_next_attempt_at=NOW()-INTERVAL '1 minute' WHERE id=3`)
exec(`UPDATE teams SET participation_next_attempt_at=NOW()-INTERVAL '1 minute' WHERE id IN (1,3)`)
if err := service.sendParticipationEmails(ctx, sender, "secret", time.UTC); err != nil {
t.Fatal(err)
}
@@ -171,19 +173,19 @@ func TestParticipationPostgres(t *testing.T) {
t.Fatal("email retry exceeded the eight-attempt limit")
}
token := participationToken("secret", 1)
if _, err := repo.GetParticipation(ctx, "bad"); !errors.Is(err, teams_repo.ErrParticipationLink) {
if _, err := service.GetParticipation(ctx, "bad"); !errors.Is(err, teams_repo.ErrParticipationLink) {
t.Fatalf("invalid token: %v", err)
}
if _, err := repo.GetParticipation(ctx, strings.Repeat("0", 64)); !errors.Is(err, teams_repo.ErrParticipationLink) {
if _, err := service.GetParticipation(ctx, strings.Repeat("0", 64)); !errors.Is(err, teams_repo.ErrParticipationLink) {
t.Fatalf("unknown token: %v", err)
}
if _, err := repo.GetParticipation(ctx, token); err != nil {
if _, err := service.GetParticipation(ctx, token); err != nil {
t.Fatal(err)
}
state(1, "pending") // Viewing a link never answers.
answers := make(chan error, 2)
for _, answer := range []string{"confirmed", "cancelled"} {
go func() { _, err := repo.AnswerParticipation(ctx, token, answer); answers <- err }()
go func() { _, err := service.AnswerParticipation(ctx, token, answer); answers <- err }()
}
success, conflict := 0, 0
for range 2 {
@@ -199,16 +201,16 @@ func TestParticipationPostgres(t *testing.T) {
if success != 1 || conflict != 1 {
t.Fatalf("concurrent answers: success=%d conflict=%d", success, conflict)
}
p, err := repo.GetParticipation(ctx, token)
p, err := service.GetParticipation(ctx, token)
if err != nil {
t.Fatal(err)
}
if _, err := repo.AnswerParticipation(ctx, token, p.Status); err != nil {
if _, err := service.AnswerParticipation(ctx, token, p.Status); err != nil {
t.Fatalf("same answer not idempotent: %v", err)
}
// Cancellation excludes teams from shared active listings and password-based gameplay.
token3 := participationToken("secret", 3)
if _, err := repo.AnswerParticipation(ctx, token3, "cancelled"); err != nil {
if _, err := service.AnswerParticipation(ctx, token3, "cancelled"); err != nil {
t.Fatal(err)
}
active, err := repo.GetActiveTeamsByGameID(ctx, 1)
@@ -220,13 +222,14 @@ func TestParticipationPostgres(t *testing.T) {
t.Fatal("cancelled team in active list")
}
}
if _, _, _, err := service.GetTeamActions(ctx, 3, "secret"); !errors.Is(err, teams_repo.ErrTeamNotFound) {
gameService := game_service.NewGameService(nil, repo, nil, nil, "", nil, nil, nil, nil, nil, nil, nil, "")
if _, _, _, err := gameService.GetTeamActions(ctx, 3, "secret"); !errors.Is(err, teams_repo.ErrTeamNotFound) {
t.Fatalf("cancelled team got story: %v", err)
}
if err := service.AddTeamAction(ctx, 3, "secret", "anything", false); !errors.Is(err, teams_repo.ErrTeamNotFound) {
if err := gameService.AddTeamAction(ctx, 3, "secret", "anything", false); !errors.Is(err, teams_repo.ErrTeamNotFound) {
t.Fatalf("cancelled team added action: %v", err)
}
if _, err := service.GetTeamAnswers(ctx, 3, "secret"); !errors.Is(err, teams_repo.ErrTeamNotFound) {
if _, err := gameService.GetTeamAnswers(ctx, 3, "secret"); !errors.Is(err, teams_repo.ErrTeamNotFound) {
t.Fatalf("cancelled team got answers: %v", err)
}
token6 := participationToken("secret", 6)
@@ -234,11 +237,11 @@ func TestParticipationPostgres(t *testing.T) {
t.Fatal(err)
}
exec(`UPDATE games SET status='run' WHERE id=1`)
if _, err := repo.AnswerParticipation(ctx, token6, "confirmed"); !errors.Is(err, teams_repo.ErrParticipationClosed) {
if _, err := service.AnswerParticipation(ctx, token6, "confirmed"); !errors.Is(err, teams_repo.ErrParticipationClosed) {
t.Fatalf("running game accepted answer: %v", err)
}
exec(`UPDATE games SET status='draft',start_at=(NOW() AT TIME ZONE 'UTC')-INTERVAL '1 second' WHERE id=1`)
if _, err := repo.AnswerParticipation(ctx, token6, "confirmed"); !errors.Is(err, teams_repo.ErrParticipationClosed) {
if _, err := service.AnswerParticipation(ctx, token6, "confirmed"); !errors.Is(err, teams_repo.ErrParticipationClosed) {
t.Fatalf("past scheduled start accepted answer: %v", err)
}
state(6, "pending")
@@ -1,4 +1,4 @@
package game_service
package participation_service
import (
"context"
@@ -19,10 +19,19 @@ import (
"github.com/jackc/pgx/v5"
)
func (s *GameService) GetParticipation(ctx context.Context, token string) (*teams_repo.Participation, error) {
type ParticipationService struct {
teamsRepo *teams_repo.TeamsRepo
appBaseURL string
}
func NewParticipationService(teamsRepo *teams_repo.TeamsRepo, appBaseURL string) *ParticipationService {
return &ParticipationService{teamsRepo: teamsRepo, appBaseURL: appBaseURL}
}
func (s *ParticipationService) GetParticipation(ctx context.Context, token string) (*teams_repo.Participation, error) {
return s.teamsRepo.GetParticipation(ctx, token)
}
func (s *GameService) AnswerParticipation(ctx context.Context, token, answer string) (*teams_repo.Participation, error) {
func (s *ParticipationService) AnswerParticipation(ctx context.Context, token, answer string) (*teams_repo.Participation, error) {
return s.teamsRepo.AnswerParticipation(ctx, token, answer)
}
@@ -46,7 +55,7 @@ func participationEmail(p *teams_repo.Participation, baseURL, token string, loca
return email_sender.Message{To: p.Email, Subject: "Подтвердите участие в «Вечернем детективе»", Body: body, HTML: markup}
}
func (s *GameService) RunParticipationEmails(ctx context.Context, sender email_sender.IEmailSender, secret, timezone string) {
func (s *ParticipationService) RunParticipationEmails(ctx context.Context, sender email_sender.IEmailSender, secret, timezone string) {
base, err := url.Parse(s.appBaseURL)
if err != nil || base.Host == "" || (base.Scheme != "https" && base.Scheme != "http") || base.User != nil || base.RawQuery != "" || base.Fragment != "" || secret == "" {
log.Print("participation email worker disabled: configure APP_BASE_URL and PARTICIPATION_SECRET")
@@ -74,7 +83,7 @@ func (s *GameService) RunParticipationEmails(ctx context.Context, sender email_s
}
}
func (s *GameService) sendParticipationEmails(ctx context.Context, sender email_sender.IEmailSender, secret string, location *time.Location) error {
func (s *ParticipationService) sendParticipationEmails(ctx context.Context, sender email_sender.IEmailSender, secret string, location *time.Location) error {
for ctx.Err() == nil {
jobCtx, cancel := context.WithTimeout(ctx, time.Minute)
p, err := s.teamsRepo.ClaimParticipationEmail(jobCtx)