diff --git a/bin/evening_detective_server b/bin/evening_detective_server index e65fd9b..f4932ec 100755 Binary files a/bin/evening_detective_server and b/bin/evening_detective_server differ diff --git a/internal/services/game_service/lifecycle_test.go b/internal/services/game_service/lifecycle_test.go new file mode 100644 index 0000000..864136e --- /dev/null +++ b/internal/services/game_service/lifecycle_test.go @@ -0,0 +1,169 @@ +package game_service_test + +import ( + "context" + "errors" + "os" + "strings" + "testing" + "time" + + "evening_detective_server/internal/modules/cleaner" + "evening_detective_server/internal/modules/password_generator" + "evening_detective_server/internal/modules/storytelling" + "evening_detective_server/internal/modules/team_qr_pdf" + "evening_detective_server/internal/repos/actions_repo" + "evening_detective_server/internal/repos/applications_repo" + "evening_detective_server/internal/repos/games_repo" + "evening_detective_server/internal/repos/scenarios_repo" + "evening_detective_server/internal/repos/team_answers_repo" + "evening_detective_server/internal/repos/teams_repo" + "evening_detective_server/internal/services/answer_service" + "evening_detective_server/internal/services/game_service" + "evening_detective_server/internal/services/gameplay_service" + "evening_detective_server/internal/services/participation_service" + "evening_detective_server/internal/services/scenarios_service" + "evening_detective_server/internal/services/team_service" + "github.com/jackc/pgx/v5/pgxpool" +) + +func TestServicesLifecyclePostgres(t *testing.T) { + dsn := os.Getenv("TEST_DATABASE_URL") + if dsn == "" { + t.Skip("set TEST_DATABASE_URL to run PostgreSQL integration check") + } + ctx := context.Background() + check := func(err error) { + t.Helper() + if err != nil { + t.Fatal(err) + } + } + config, err := pgxpool.ParseConfig(dsn) + check(err) + config.MaxConns = 1 // Temporary tables stay on the same connection. + pool, err := pgxpool.NewWithConfig(ctx, config) + check(err) + defer pool.Close() + _, err = pool.Exec(ctx, ` + CREATE TEMP TABLE users(id INT, username TEXT); + CREATE TEMP TABLE scenarios(id INT PRIMARY KEY, name TEXT, description TEXT, image TEXT, scenario TEXT, + author_id INT, status TEXT DEFAULT 'draft', updated_at TIMESTAMP DEFAULT NOW(), created_at TIMESTAMP DEFAULT NOW(), published_at TIMESTAMP, is_deleted BOOLEAN DEFAULT FALSE); + CREATE TEMP TABLE games(id SERIAL PRIMARY KEY, name TEXT, description TEXT, start_at TIMESTAMP, scenario_id INT, + format TEXT, status TEXT DEFAULT 'draft', started_at TIMESTAMP, ended_at TIMESTAMP, updated_at TIMESTAMP DEFAULT NOW(), created_at TIMESTAMP DEFAULT NOW()); + CREATE TEMP TABLE teams(id SERIAL PRIMARY KEY, name TEXT, creator_id INT, game_id INT, password TEXT, status TEXT DEFAULT 'register', created_at TIMESTAMP DEFAULT NOW()); + CREATE TEMP TABLE actions(id SERIAL PRIMARY KEY, team_id INT, code TEXT, via_mcp BOOLEAN, created_at TIMESTAMP DEFAULT NOW()); + CREATE TEMP TABLE applications(id SERIAL PRIMARY KEY, team_id INT, name TEXT, image TEXT, file_type TEXT, status TEXT DEFAULT 'need', created_at TIMESTAMP DEFAULT NOW()); + CREATE TEMP TABLE team_answers(team_id INT, question_code TEXT, answer TEXT, score INT, updated_at TIMESTAMP DEFAULT NOW(), UNIQUE(team_id, question_code)); + INSERT INTO scenarios(id,name,scenario) VALUES(1,'Case','{"introduction":{"text":"Intro"},"places":[{"code":"p","name":"Place","applications":[{"name":"Clue","image":"clue.pdf"}]}],"questions":[{"code":"q","text":"Who?","answer":"Butler"}]}');`) + check(err) + for _, name := range []string{"20260913130000_add_team_registration.sql", "20260915120000_add_team_participation.sql"} { + data, err := os.ReadFile("../../../migrations/" + name) + check(err) + _, err = pool.Exec(ctx, strings.Split(string(data), "-- +goose Down")[0]) + check(err) + } + games, teams := games_repo.NewGamesRepo(pool), teams_repo.NewTeamsRepo(pool) + scenarios := scenarios_repo.NewScenariosRepo(pool) + actions, applications := actions_repo.NewActionsRepo(pool), applications_repo.NewApplicationsRepo(pool) + textCleaner := cleaner.NewCleaner() + scenarioService := scenarios_service.NewScenarioService(scenarios, textCleaner, "https://files.test/", nil, nil) + game := game_service.NewGameService(games, teams, scenarios, scenarioService, "https://files.test/", actions, applications) + team := team_service.NewTeamService(games, teams, password_generator.NewGenerator(12), team_qr_pdf.NewGenerator(), "https://app.test") + play := gameplay_service.NewGameplayService(games, teams, scenarioService, storytelling.NewStory(textCleaner), actions, applications, textCleaner, "https://files.test/") + answers := answer_service.NewAnswerService(games, teams, scenarioService, team_answers_repo.NewTeamAnswersRepo(pool), actions) + participation := participation_service.NewParticipationService(teams, "https://app.test") + start := time.Now().Add(48 * time.Hour) + id, err := game.AddGame(ctx, "Game", "", start, 1, "online") + check(err) + check(game.UpdateGame(ctx, id, "Game", "Updated", start, 1, "")) + registration := team_service.Registration{Name: "Team", Email: "captain@example.org", ParticipantsCount: 3} + teamID, err := team.RegisterTeam(ctx, id, "delivery", registration) + check(err) + retryID, err := team.RegisterTeam(ctx, id, "delivery", registration) + check(err) + if retryID != teamID { + t.Fatal("duplicate registration") + } + registered, err := teams.GetTeamByID(ctx, teamID) + check(err) + if registered.ParticipationStatus != "pending" { + t.Fatal("early registration must be pending") + } + token := strings.Repeat("a", 64) + check(teams.SetParticipationToken(ctx, teamID, token)) + p, err := participation.GetParticipation(ctx, token) + check(err) + if p.Status != "pending" || p.TeamName != "Team" { + t.Fatalf("participation: %+v", p) + } + media, err := game.GetGameMedia(ctx, id) + check(err) + if media.Scenario.Story.Introduction.Text != "Intro" { + t.Fatal("game media missing") + } + pdf, _, err := team.DownloadTeamQR(ctx, id) + check(err) + if !strings.HasPrefix(string(pdf), "%PDF-") { + t.Fatal("invalid QR PDF") + } + check(game.StartGame(ctx, id)) + check(play.AddTeamAction(ctx, teamID, registered.Password, "p", true)) + registered, err = teams.GetTeamByID(ctx, teamID) + check(err) + if registered.ParticipationStatus != "confirmed" || registered.ParticipationSource != "first_action" { + t.Fatal("first action did not confirm participation") + } + story, _, moves, err := play.GetTeamActions(ctx, teamID, registered.Password) + check(err) + if len(moves) != 1 || !moves[0].ViaMCP || len(story.Places) != 1 || story.Questions[0].Answer != "" { + t.Fatal("incorrect team story") + } + full, err := game.GetFullGame(ctx, id) + check(err) + if len(full.Teams) != 1 || full.Teams[0].ActionsCount != 1 || len(full.Teams[0].Applications) != 1 || full.Teams[0].Applications[0].Image != "https://files.test/clue.pdf" { + t.Fatalf("incorrect game details: %+v", full) + } + check(play.GiveTeamApplications(ctx, teamID, "Clue")) + full, err = game.GetFullGame(ctx, id) + check(err) + if len(full.Teams[0].Applications) != 0 { + t.Fatal("given clue still pending") + } + check(play.DeleteLastTeamAction(ctx, teamID)) + full, err = game.GetFullGame(ctx, id) + check(err) + if full.Teams[0].ActionsCount != 0 { + t.Fatal("move not rolled back") + } + check(play.AddTeamAction(ctx, teamID, registered.Password, "p", false)) + if err := answers.SubmitTeamAnswers(ctx, teamID, registered.Password, []answer_service.Answer{{QuestionCode: "missing"}}); !errors.Is(err, answer_service.ErrQuestionNotFound) { + t.Fatalf("unknown question: %v", err) + } + check(answers.SubmitTeamAnswers(ctx, teamID, registered.Password, []answer_service.Answer{{QuestionCode: "q", Text: "Butler"}})) + check(game.PauseGame(ctx, id)) + check(answers.SubmitTeamAnswers(ctx, teamID, registered.Password, []answer_service.Answer{{QuestionCode: "q", Text: "Butler"}})) + check(game.PlayGame(ctx, id)) + check(game.FinishGame(ctx, id)) + if err := answers.SubmitTeamAnswers(ctx, teamID, registered.Password, nil); !errors.Is(err, answer_service.ErrGameFinished) { + t.Fatalf("finished answers: %v", err) + } + score := 5 + check(answers.SetTeamAnswerScore(ctx, teamID, "q", &score)) + results, err := answers.GetGameResults(ctx, id) + check(err) + if len(results.Teams) != 1 || results.Teams[0].TotalScore != 5 || results.Teams[0].Place != 1 || results.Teams[0].Team.ActionsCount != 1 { + t.Fatalf("incorrect results: %+v", results) + } + check(game.ResetGame(ctx, id)) + full, err = game.GetFullGame(ctx, id) + check(err) + if full.Status != "draft" || full.StartedAt != nil || full.EndedAt != nil || full.Teams[0].ActionsCount != 1 { + t.Fatal("reset changed history or retained timestamps") + } + grading, err := answers.GetGameAnswers(ctx, id) + check(err) + if len(grading.Teams) != 1 || grading.Teams[0].Answers[0].Score == nil || *grading.Teams[0].Answers[0].Score != 5 { + t.Fatal("reset removed answers or scores") + } +}