generated from VLADIMIR/template
up
This commit is contained in:
@@ -6,6 +6,7 @@ import (
|
||||
|
||||
// Game describes a game and its scenario and teams.
|
||||
type Game struct {
|
||||
Export *GameExport
|
||||
Format string
|
||||
ID int
|
||||
Name string
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
package repos
|
||||
|
||||
// GameExport contains public status, never the snapshot or storage key.
|
||||
type GameExport struct {
|
||||
ID string `json:"id"`
|
||||
State string `json:"state"`
|
||||
PackageID string `json:"package_id"`
|
||||
Size int64 `json:"size"`
|
||||
SHA256 string `json:"sha256"`
|
||||
Failure string `json:"failure"`
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
package game_exports_repo
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"evening_detective_server/internal/repos/games_repo"
|
||||
"evening_detective_server/internal/repos/scenarios_repo"
|
||||
"evening_detective_server/internal/repos/teams_repo"
|
||||
"evening_detective_server/internal/repos/user_agreements_repo"
|
||||
"evening_detective_server/internal/repos/users_repo"
|
||||
"github.com/jackc/pgx/v5"
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
)
|
||||
|
||||
type exportFixture struct {
|
||||
pool *pgxpool.Pool
|
||||
exports *GameExportsRepo
|
||||
games *games_repo.GamesRepo
|
||||
teams *teams_repo.TeamsRepo
|
||||
scenarios *scenarios_repo.ScenariosRepo
|
||||
users *users_repo.UsersRepo
|
||||
userID int
|
||||
scenarioID int
|
||||
gameID int
|
||||
teamID int
|
||||
}
|
||||
|
||||
// newExportFixture uses real migrations in an isolated test schema; all game data uses repositories.
|
||||
func newExportFixture(t *testing.T) exportFixture {
|
||||
t.Helper()
|
||||
dsn := os.Getenv("TEST_DATABASE_URL")
|
||||
if dsn == "" {
|
||||
t.Skip("set TEST_DATABASE_URL for PostgreSQL export checks")
|
||||
}
|
||||
ctx := context.Background()
|
||||
admin, err := pgxpool.New(ctx, dsn)
|
||||
requireNoError(t, err)
|
||||
schema := fmt.Sprintf("exports_test_%d", time.Now().UnixNano())
|
||||
quoted := pgx.Identifier{schema}.Sanitize()
|
||||
_, err = admin.Exec(ctx, "CREATE SCHEMA "+quoted)
|
||||
requireNoError(t, err)
|
||||
t.Cleanup(func() {
|
||||
_, err := admin.Exec(ctx, "DROP SCHEMA "+quoted+" CASCADE")
|
||||
if err != nil {
|
||||
t.Error(err)
|
||||
}
|
||||
admin.Close()
|
||||
})
|
||||
config, err := pgxpool.ParseConfig(dsn)
|
||||
requireNoError(t, err)
|
||||
config.ConnConfig.RuntimeParams["search_path"] = schema
|
||||
config.ConnConfig.RuntimeParams["timezone"] = "UTC"
|
||||
pool, err := pgxpool.NewWithConfig(ctx, config)
|
||||
requireNoError(t, err)
|
||||
t.Cleanup(pool.Close)
|
||||
files, err := filepath.Glob("../../../migrations/*.sql")
|
||||
requireNoError(t, err)
|
||||
for _, name := range files {
|
||||
raw, err := os.ReadFile(name)
|
||||
requireNoError(t, err)
|
||||
_, err = pool.Exec(ctx, strings.Split(string(raw), "-- +goose Down")[0])
|
||||
if err != nil {
|
||||
t.Fatalf("migration %s: %v", name, err)
|
||||
}
|
||||
}
|
||||
f := exportFixture{
|
||||
pool: pool,
|
||||
exports: NewGameExportsRepo(pool),
|
||||
games: games_repo.NewGamesRepo(pool),
|
||||
teams: teams_repo.NewTeamsRepo(pool),
|
||||
scenarios: scenarios_repo.NewScenariosRepo(pool),
|
||||
users: users_repo.NewUserRepo(pool),
|
||||
}
|
||||
f.userID, err = f.users.AddUserWithAgreements(ctx, "organizer", "export@test.invalid", "hash", []string{"organizer"},
|
||||
[]user_agreements_repo.Agreement{{AgreementType: "terms", Version: "test"}})
|
||||
requireNoError(t, err)
|
||||
f.scenarioID, err = f.scenarios.AddScenario(ctx, "Original story", f.userID)
|
||||
requireNoError(t, err)
|
||||
requireNoError(t, f.scenarios.UpdateStoryByScenarioID(ctx, f.scenarioID,
|
||||
`{"introduction":{"text":"Intro"},"questions":[{"code":"q","text":"Who?","answer":"Butler"}]}`))
|
||||
f.gameID, err = f.games.AddGame(ctx, "Test export", "Original description", nil, time.Now().Add(12*time.Hour), int32(f.scenarioID), "offline")
|
||||
requireNoError(t, err)
|
||||
f.teamID, err = f.teams.AddTeamWithParticipation(ctx, f.userID, f.gameID, "Team", "original-qr", "captain@test.invalid", 3, false)
|
||||
requireNoError(t, err)
|
||||
return f
|
||||
}
|
||||
|
||||
func requireNoError(t *testing.T, err error) {
|
||||
t.Helper()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func (f exportFixture) start(t *testing.T, id string) *Attempt {
|
||||
t.Helper()
|
||||
attempt, err := f.exports.Start(context.Background(), f.gameID, f.userID, id, "https://game.example")
|
||||
requireNoError(t, err)
|
||||
return attempt
|
||||
}
|
||||
@@ -0,0 +1,366 @@
|
||||
// Package game_exports_repo persists export attempts and their immutable snapshots.
|
||||
package game_exports_repo
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"time"
|
||||
|
||||
"evening_detective_server/internal/repos"
|
||||
"github.com/jackc/pgx/v5"
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
)
|
||||
|
||||
var (
|
||||
ErrGameNotFound = errors.New("игра не найдена")
|
||||
ErrNotExportable = errors.New("экспорт доступен только для игры в черновике без дат проведения и прогресса")
|
||||
ErrExportNotFound = errors.New("экспорт игры ещё не запущен")
|
||||
ErrOwnershipLost = errors.New("владение экспортом потеряно")
|
||||
)
|
||||
|
||||
const Lease = 2 * time.Minute
|
||||
const PrepareTimeout = 30 * time.Minute
|
||||
|
||||
// Attempt includes private worker data; only GameExport may be exposed to clients.
|
||||
type Attempt struct {
|
||||
repos.GameExport
|
||||
GameID int
|
||||
Snapshot []byte
|
||||
SourceServer string
|
||||
ObjectKey string
|
||||
CreatedAt time.Time
|
||||
}
|
||||
|
||||
type GameExportsRepo struct {
|
||||
pool *pgxpool.Pool
|
||||
lease time.Duration
|
||||
prepareTimeout time.Duration
|
||||
}
|
||||
|
||||
func NewGameExportsRepo(pool *pgxpool.Pool) *GameExportsRepo {
|
||||
return &GameExportsRepo{
|
||||
pool: pool,
|
||||
lease: Lease,
|
||||
prepareTimeout: PrepareTimeout,
|
||||
}
|
||||
}
|
||||
|
||||
// Start locks the game before inspecting progress, then saves all package data in one SQL snapshot.
|
||||
func (r *GameExportsRepo) Start(ctx context.Context, gameID, userID int, id, source string) (*Attempt, error) {
|
||||
tx, err := r.pool.Begin(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer func() { _ = tx.Rollback(ctx) }()
|
||||
var status string
|
||||
var started, ended *time.Time
|
||||
err = tx.QueryRow(
|
||||
ctx,
|
||||
`SELECT
|
||||
status,
|
||||
started_at,
|
||||
ended_at
|
||||
FROM games
|
||||
WHERE id = $1
|
||||
FOR UPDATE`,
|
||||
gameID,
|
||||
).Scan(&status, &started, &ended)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return nil, ErrGameNotFound
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
existing, err := readAttempt(ctx, tx, gameID, true, true)
|
||||
if err == nil {
|
||||
return existing, tx.Commit(ctx)
|
||||
}
|
||||
if !errors.Is(err, ErrExportNotFound) {
|
||||
return nil, err
|
||||
}
|
||||
if status != "draft" || started != nil || ended != nil {
|
||||
return nil, ErrNotExportable
|
||||
}
|
||||
var progress bool
|
||||
err = tx.QueryRow(
|
||||
ctx,
|
||||
`SELECT
|
||||
EXISTS (
|
||||
SELECT
|
||||
1
|
||||
FROM teams t
|
||||
WHERE t.game_id = $1 AND (
|
||||
EXISTS (
|
||||
SELECT
|
||||
1
|
||||
FROM actions a
|
||||
WHERE a.team_id = t.id
|
||||
)
|
||||
OR EXISTS (
|
||||
SELECT
|
||||
1
|
||||
FROM applications a
|
||||
WHERE a.team_id = t.id
|
||||
)
|
||||
OR EXISTS (
|
||||
SELECT
|
||||
1
|
||||
FROM team_answers a
|
||||
WHERE a.team_id = t.id
|
||||
)
|
||||
)
|
||||
)`,
|
||||
gameID,
|
||||
).Scan(&progress)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if progress {
|
||||
return nil, ErrNotExportable
|
||||
}
|
||||
snapshot, err := captureSnapshot(ctx, tx, gameID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
_, err = tx.Exec(
|
||||
ctx,
|
||||
`INSERT INTO game_exports (id, game_id, initiator_id, snapshot, source_server, object_key)
|
||||
VALUES ($1, $2, $3, $4, $5, $6)`,
|
||||
id,
|
||||
gameID,
|
||||
userID,
|
||||
snapshot,
|
||||
source,
|
||||
id+".zip",
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
result, err := readAttempt(ctx, tx, gameID, true, true)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return result, tx.Commit(ctx)
|
||||
}
|
||||
|
||||
func (r *GameExportsRepo) Get(ctx context.Context, gameID int) (*Attempt, error) {
|
||||
return readAttempt(ctx, r.pool, gameID, false, false)
|
||||
}
|
||||
|
||||
// IRowReader is implemented by both the pool and a transaction.
|
||||
type IRowReader interface {
|
||||
QueryRow(context.Context, string, ...any) pgx.Row
|
||||
}
|
||||
|
||||
func readAttempt(ctx context.Context, reader IRowReader, gameID int, active, withSnapshot bool) (*Attempt, error) {
|
||||
attempt := &Attempt{}
|
||||
err := reader.QueryRow(
|
||||
ctx,
|
||||
`SELECT
|
||||
id,
|
||||
COALESCE(game_id, 0),
|
||||
state,
|
||||
package_id,
|
||||
size,
|
||||
sha256,
|
||||
failure,
|
||||
CASE WHEN $3 THEN snapshot ELSE NULL END,
|
||||
source_server,
|
||||
object_key,
|
||||
created_at
|
||||
FROM game_exports
|
||||
WHERE game_id = $1 AND (NOT $2 OR state IN ('preparing', 'ready'))
|
||||
ORDER BY created_at DESC, id
|
||||
LIMIT 1`,
|
||||
gameID,
|
||||
active,
|
||||
withSnapshot,
|
||||
).Scan(&attempt.ID, &attempt.GameID, &attempt.State, &attempt.PackageID, &attempt.Size,
|
||||
&attempt.SHA256, &attempt.Failure, &attempt.Snapshot, &attempt.SourceServer, &attempt.ObjectKey, &attempt.CreatedAt)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return nil, ErrExportNotFound
|
||||
}
|
||||
return attempt, err
|
||||
}
|
||||
|
||||
// Claim only assigns unowned live attempts. Expired ownership is failed, never reassigned.
|
||||
func (r *GameExportsRepo) Claim(ctx context.Context, owner string) (*Attempt, error) {
|
||||
attempt := &Attempt{}
|
||||
err := r.pool.QueryRow(
|
||||
ctx,
|
||||
`WITH candidate AS (
|
||||
SELECT
|
||||
id
|
||||
FROM game_exports
|
||||
WHERE state = 'preparing' AND owner_id IS NULL
|
||||
AND created_at + $3::interval > clock_timestamp()
|
||||
ORDER BY created_at
|
||||
FOR UPDATE SKIP LOCKED
|
||||
LIMIT 1
|
||||
)
|
||||
UPDATE game_exports e
|
||||
SET owner_id = $1, lease_until = clock_timestamp() + $2::interval
|
||||
FROM candidate c
|
||||
WHERE e.id = c.id
|
||||
RETURNING
|
||||
e.id,
|
||||
COALESCE(e.game_id, 0),
|
||||
e.state,
|
||||
e.package_id,
|
||||
e.size,
|
||||
e.sha256,
|
||||
e.failure,
|
||||
e.snapshot,
|
||||
e.source_server,
|
||||
e.object_key,
|
||||
e.created_at`,
|
||||
owner,
|
||||
r.lease.String(),
|
||||
r.prepareTimeout.String(),
|
||||
).Scan(&attempt.ID, &attempt.GameID, &attempt.State, &attempt.PackageID, &attempt.Size,
|
||||
&attempt.SHA256, &attempt.Failure, &attempt.Snapshot, &attempt.SourceServer, &attempt.ObjectKey, &attempt.CreatedAt)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return attempt, nil
|
||||
}
|
||||
|
||||
func (r *GameExportsRepo) Heartbeat(ctx context.Context, id, owner string) error {
|
||||
tag, err := r.pool.Exec(
|
||||
ctx,
|
||||
`UPDATE game_exports
|
||||
SET lease_until = clock_timestamp() + $3::interval
|
||||
WHERE id = $1 AND owner_id = $2 AND state = 'preparing'
|
||||
AND lease_until > clock_timestamp()
|
||||
AND created_at + $4::interval > clock_timestamp()`,
|
||||
id,
|
||||
owner,
|
||||
r.lease.String(),
|
||||
r.prepareTimeout.String(),
|
||||
)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if tag.RowsAffected() == 0 {
|
||||
return ErrOwnershipLost
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Complete fences publication by attempt, owner, lease and total preparation deadline.
|
||||
func (r *GameExportsRepo) Complete(ctx context.Context, id, owner, packageID, digest string, size int64) error {
|
||||
tag, err := r.pool.Exec(
|
||||
ctx,
|
||||
`UPDATE game_exports
|
||||
SET state = 'ready', package_id = $3, sha256 = $4, size = $5,
|
||||
owner_id = NULL, lease_until = NULL
|
||||
WHERE id = $1 AND owner_id = $2 AND state = 'preparing'
|
||||
AND lease_until > clock_timestamp()
|
||||
AND created_at + $6::interval > clock_timestamp()`,
|
||||
id,
|
||||
owner,
|
||||
packageID,
|
||||
digest,
|
||||
size,
|
||||
r.prepareTimeout.String(),
|
||||
)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if tag.RowsAffected() == 0 {
|
||||
return ErrOwnershipLost
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Fail refuses stale workers without changing game data.
|
||||
func (r *GameExportsRepo) Fail(ctx context.Context, id, owner, reason string) error {
|
||||
tag, err := r.pool.Exec(
|
||||
ctx,
|
||||
`UPDATE game_exports
|
||||
SET state = 'failed', failure = $3, owner_id = NULL, lease_until = NULL
|
||||
WHERE id = $1 AND owner_id = $2
|
||||
AND state = 'preparing' AND lease_until > clock_timestamp()`,
|
||||
id,
|
||||
owner,
|
||||
reason,
|
||||
)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if tag.RowsAffected() == 0 {
|
||||
return ErrOwnershipLost
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *GameExportsRepo) Expire(ctx context.Context) error {
|
||||
_, err := r.pool.Exec(
|
||||
ctx,
|
||||
`UPDATE game_exports
|
||||
SET state = 'failed', failure = 'Подготовка прервана. Запустите экспорт повторно.',
|
||||
owner_id = NULL, lease_until = NULL
|
||||
WHERE state = 'preparing' AND (
|
||||
lease_until <= clock_timestamp() OR created_at + $1::interval <= clock_timestamp()
|
||||
)`,
|
||||
r.prepareTimeout.String(),
|
||||
)
|
||||
return err
|
||||
}
|
||||
|
||||
// CleanupKeys retries deletions until all old workers' preparation deadlines have elapsed.
|
||||
func (r *GameExportsRepo) CleanupKeys(ctx context.Context) ([]string, error) {
|
||||
rows, err := r.pool.Query(
|
||||
ctx,
|
||||
`SELECT
|
||||
object_key
|
||||
FROM game_exports
|
||||
WHERE state = 'failed' AND (cleaned_at IS NULL OR (
|
||||
cleaned_at < created_at + $1::interval AND cleaned_at < clock_timestamp() - INTERVAL '2 minutes'
|
||||
))
|
||||
ORDER BY created_at
|
||||
LIMIT 100`,
|
||||
(r.prepareTimeout + r.lease).String(),
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
keys := []string{}
|
||||
for rows.Next() {
|
||||
var key string
|
||||
if err := rows.Scan(&key); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
keys = append(keys, key)
|
||||
}
|
||||
return keys, rows.Err()
|
||||
}
|
||||
|
||||
func (r *GameExportsRepo) MarkCleaned(ctx context.Context, key string) error {
|
||||
_, err := r.pool.Exec(
|
||||
ctx,
|
||||
`UPDATE game_exports
|
||||
SET cleaned_at = clock_timestamp()
|
||||
WHERE object_key = $1 AND state = 'failed'`,
|
||||
key,
|
||||
)
|
||||
return err
|
||||
}
|
||||
|
||||
// IsInactive only identifies known terminal attempts, including leftovers after successful publication.
|
||||
func (r *GameExportsRepo) IsInactive(ctx context.Context, id string) (bool, error) {
|
||||
var inactive bool
|
||||
err := r.pool.QueryRow(
|
||||
ctx,
|
||||
`SELECT
|
||||
EXISTS (
|
||||
SELECT
|
||||
1
|
||||
FROM game_exports
|
||||
WHERE id = $1 AND state IN ('ready', 'failed')
|
||||
)`,
|
||||
id,
|
||||
).Scan(&inactive)
|
||||
return inactive, err
|
||||
}
|
||||
@@ -0,0 +1,340 @@
|
||||
package game_exports_repo
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"evening_detective_server/internal/repos"
|
||||
"evening_detective_server/internal/repos/actions_repo"
|
||||
"evening_detective_server/internal/repos/applications_repo"
|
||||
"evening_detective_server/internal/repos/game_map_repo"
|
||||
"evening_detective_server/internal/repos/game_reviews_repo"
|
||||
"evening_detective_server/internal/repos/game_screen_plans_repo"
|
||||
"evening_detective_server/internal/repos/playlists_repo"
|
||||
"evening_detective_server/internal/repos/screen_sessions_repo"
|
||||
"evening_detective_server/internal/repos/team_answers_repo"
|
||||
"evening_detective_server/internal/repos/tickets_repo"
|
||||
"github.com/jackc/pgx/v5"
|
||||
)
|
||||
|
||||
func TestExportSnapshotAndEditableGame(t *testing.T) {
|
||||
for _, state := range []string{"preparing", "ready"} {
|
||||
t.Run(state, func(t *testing.T) {
|
||||
f := newExportFixture(t)
|
||||
ctx := context.Background()
|
||||
music := playlists_repo.New(f.pool)
|
||||
base, err := music.Create(ctx, "Scenario default")
|
||||
requireNoError(t, err)
|
||||
assigned, err := music.Create(ctx, "Game assigned")
|
||||
requireNoError(t, err)
|
||||
selected, err := music.Create(ctx, "Selected by screen")
|
||||
requireNoError(t, err)
|
||||
requireNoError(t, music.SetScenario(ctx, f.scenarioID, base.ID))
|
||||
requireNoError(t, music.SetGame(ctx, f.gameID, assigned.ID))
|
||||
_, err = music.SetMusic(ctx, f.gameID, selected.ID, 0, 0, 100, false, 1)
|
||||
requireNoError(t, err)
|
||||
ticketRepo := tickets_repo.New(f.pool)
|
||||
activeHash := sha256.Sum256([]byte("active-ticket"))
|
||||
usedHash := sha256.Sum256([]byte("used-ticket"))
|
||||
_, err = ticketRepo.CreateBatch(ctx, f.gameID, [][]byte{activeHash[:], usedHash[:]})
|
||||
requireNoError(t, err)
|
||||
_, err = ticketRepo.Accept(ctx, usedHash[:], f.userID)
|
||||
requireNoError(t, err)
|
||||
token := "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
|
||||
requireNoError(t, f.teams.SetParticipationToken(ctx, f.teamID, token))
|
||||
mapRepo := game_map_repo.NewGameMapRepo(f.pool)
|
||||
requireNoError(t, mapRepo.Ensure(ctx, f.gameID, "team", f.teamID))
|
||||
plans := game_screen_plans_repo.NewGameScreenPlansRepo(f.pool)
|
||||
_, err = plans.Save(ctx, f.gameID, []game_screen_plans_repo.Step{{Mode: 1}}, 0)
|
||||
requireNoError(t, err)
|
||||
screens := screen_sessions_repo.NewScreenSessionsRepo(f.pool)
|
||||
screenID, err := screens.Create(ctx, "screen-token-hash")
|
||||
requireNoError(t, err)
|
||||
requireNoError(t, screens.Bind(ctx, screenID, f.gameID, "screen-token-hash"))
|
||||
reviews := game_reviews_repo.NewGameReviewsRepo(f.pool)
|
||||
review, err := reviews.Create(ctx, f.gameID, f.teamID, 5, 2, "Review")
|
||||
requireNoError(t, err)
|
||||
attempt := f.start(t, "attempt-original")
|
||||
if state == "ready" {
|
||||
_, err := f.exports.Claim(ctx, "worker")
|
||||
requireNoError(t, err)
|
||||
requireNoError(t, f.exports.Complete(ctx, attempt.ID, "worker", "package-id", "digest", 123))
|
||||
}
|
||||
var snapshot struct {
|
||||
Game struct {
|
||||
PlaylistID int `json:"playlist_id"`
|
||||
}
|
||||
Scenario struct {
|
||||
Name string `json:"name"`
|
||||
}
|
||||
Teams []struct {
|
||||
Password string `json:"password"`
|
||||
}
|
||||
Tickets []struct {
|
||||
TokenHash []byte `json:"token_hash"`
|
||||
Status string `json:"status"`
|
||||
}
|
||||
Playlists []struct {
|
||||
ID int `json:"id"`
|
||||
}
|
||||
}
|
||||
requireNoError(t, json.Unmarshal(attempt.Snapshot, &snapshot))
|
||||
if snapshot.Game.PlaylistID != selected.ID || len(snapshot.Playlists) != 2 || len(snapshot.Tickets) != 2 ||
|
||||
!bytes.Equal(snapshot.Tickets[0].TokenHash, activeHash[:]) || snapshot.Teams[0].Password != "original-qr" {
|
||||
t.Fatalf("snapshot lost effective music, ticket or QR: %+v", snapshot)
|
||||
}
|
||||
requireNoError(t, f.teams.UpdateTeam(ctx, f.teamID, "Online edit"))
|
||||
_, err = f.teams.RegisterTeam(ctx, f.gameID, "delivery", "New", "new@test.invalid", 2, "password")
|
||||
requireNoError(t, err)
|
||||
_, err = f.teams.AnswerParticipation(ctx, token, "confirmed")
|
||||
requireNoError(t, err)
|
||||
_, err = actions_repo.NewActionsRepo(f.pool).AddAction(ctx, f.teamID, "code", false)
|
||||
requireNoError(t, err)
|
||||
_, err = ticketRepo.Accept(ctx, activeHash[:], f.userID)
|
||||
requireNoError(t, err)
|
||||
_, err = music.SetMusic(ctx, f.gameID, base.ID, 0, 0, 100, false, 1)
|
||||
requireNoError(t, err)
|
||||
_, err = plans.Save(ctx, f.gameID, []game_screen_plans_repo.Step{{Mode: 2}}, 0)
|
||||
requireNoError(t, err)
|
||||
requireNoError(t, screens.SetMode(ctx, f.gameID, 2))
|
||||
requireNoError(t, screens.Unbind(ctx, screenID))
|
||||
requireNoError(t, mapRepo.Update(ctx, game_map_repo.Item{GameID: f.gameID, Kind: "team", ItemID: f.teamID, X: 10}))
|
||||
_, err = reviews.Moderate(ctx, review.ID, "approved")
|
||||
requireNoError(t, err)
|
||||
same, err := f.exports.Start(ctx, f.gameID, f.userID, "not-a-new-attempt", "test")
|
||||
requireNoError(t, err)
|
||||
if same.ID != attempt.ID || !bytes.Equal(same.Snapshot, attempt.Snapshot) {
|
||||
t.Fatal("online edits changed an existing export snapshot")
|
||||
}
|
||||
requireNoError(t, f.scenarios.UpdateScenarioByID(ctx, f.scenarioID, "Edited shared story", "", ""))
|
||||
_, err = music.Update(ctx, selected.ID, "Edited shared playlist")
|
||||
requireNoError(t, err)
|
||||
requireNoError(t, f.users.DeleteUser(ctx, f.userID))
|
||||
team, err := f.teams.GetTeamByID(ctx, f.teamID)
|
||||
requireNoError(t, err)
|
||||
if team.Password != "original-qr" {
|
||||
t.Fatal("account deletion changed exported team")
|
||||
}
|
||||
ticket, err := ticketRepo.GetByHash(ctx, usedHash[:])
|
||||
requireNoError(t, err)
|
||||
if ticket.UsedBy != nil || ticket.Status != "used" {
|
||||
t.Fatal("account deletion changed exported ticket")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestExportConcurrentStartAndTeamWrites(t *testing.T) {
|
||||
for _, tc := range []struct{ name string }{{"one active attempt and consistent team snapshot"}} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
f := newExportFixture(t)
|
||||
ctx := context.Background()
|
||||
var wg sync.WaitGroup
|
||||
attempts := make(chan *Attempt, 8)
|
||||
errs := make(chan error, 9)
|
||||
for n := range 8 {
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
attempt, err := f.exports.Start(ctx, f.gameID, f.userID, fmt.Sprintf("attempt-%d", n), "https://game.example")
|
||||
if err != nil {
|
||||
errs <- err
|
||||
} else {
|
||||
attempts <- attempt
|
||||
}
|
||||
}()
|
||||
}
|
||||
wg.Add(1)
|
||||
var changeErr error
|
||||
go func() { defer wg.Done(); changeErr = f.teams.UpdateTeam(ctx, f.teamID, "Concurrent name") }()
|
||||
wg.Wait()
|
||||
close(errs)
|
||||
close(attempts)
|
||||
for err := range errs {
|
||||
t.Error(err)
|
||||
}
|
||||
if changeErr != nil {
|
||||
t.Fatal(changeErr)
|
||||
}
|
||||
var id string
|
||||
for attempt := range attempts {
|
||||
if id != "" && id != attempt.ID {
|
||||
t.Fatal("more than one active export")
|
||||
}
|
||||
id = attempt.ID
|
||||
var snapshot struct {
|
||||
Teams []struct {
|
||||
Name string `json:"name"`
|
||||
}
|
||||
}
|
||||
requireNoError(t, json.Unmarshal(attempt.Snapshot, &snapshot))
|
||||
if snapshot.Teams[0].Name != "Concurrent name" && snapshot.Teams[0].Name != "Team" {
|
||||
t.Fatalf("unexpected team snapshot: %s", snapshot.Teams[0].Name)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestExportRejectsProgressAndRunningGames(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
mutate func(exportFixture) error
|
||||
}{
|
||||
{"started", func(f exportFixture) error { return f.games.StartGame(context.Background(), f.gameID) }},
|
||||
{"actions", func(f exportFixture) error {
|
||||
_, e := actions_repo.NewActionsRepo(f.pool).AddAction(context.Background(), f.teamID, "p", false)
|
||||
return e
|
||||
}},
|
||||
{"applications", func(f exportFixture) error {
|
||||
_, e := applications_repo.NewApplicationsRepo(f.pool).AddApplication(context.Background(), f.teamID, "Clue", "clue.pdf", "pdf")
|
||||
return e
|
||||
}},
|
||||
{"answers", func(f exportFixture) error {
|
||||
return team_answers_repo.NewTeamAnswersRepo(f.pool).UpsertAnswers(context.Background(), f.teamID, []repos.TeamAnswer{{QuestionCode: "q"}})
|
||||
}},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
f := newExportFixture(t)
|
||||
requireNoError(t, tc.mutate(f))
|
||||
_, err := f.exports.Start(context.Background(), f.gameID, f.userID, "attempt", "https://game.example")
|
||||
if !errors.Is(err, ErrNotExportable) {
|
||||
t.Fatalf("export allowed: %v", err)
|
||||
}
|
||||
requireNoError(t, f.teams.UpdateTeam(context.Background(), f.teamID, "Still writable"))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestExpiredExportCannotPublishAndRetryTakesNewSnapshot(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
lease, timeout time.Duration
|
||||
}{
|
||||
{"lease", 100 * time.Millisecond, time.Minute},
|
||||
{"total deadline", time.Minute, 100 * time.Millisecond},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
f := newExportFixture(t)
|
||||
f.exports.lease, f.exports.prepareTimeout = tc.lease, tc.timeout
|
||||
ctx := context.Background()
|
||||
attempt := f.start(t, "old-attempt")
|
||||
claimed, err := f.exports.Claim(ctx, "old-worker")
|
||||
requireNoError(t, err)
|
||||
if claimed.ID != attempt.ID {
|
||||
t.Fatal("wrong claimed attempt")
|
||||
}
|
||||
_, err = NewGameExportsRepo(f.pool).Claim(ctx, "other-worker")
|
||||
if !errors.Is(err, pgx.ErrNoRows) {
|
||||
t.Fatalf("ownership stolen: %v", err)
|
||||
}
|
||||
if err := f.exports.Complete(ctx, attempt.ID, "other-worker", "id", "digest", 10); !errors.Is(err, ErrOwnershipLost) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
time.Sleep(150 * time.Millisecond)
|
||||
if err := f.exports.Heartbeat(ctx, attempt.ID, "old-worker"); !errors.Is(err, ErrOwnershipLost) {
|
||||
t.Fatalf("expired heartbeat accepted: %v", err)
|
||||
}
|
||||
requireNoError(t, f.exports.Expire(ctx))
|
||||
failed, err := f.exports.Get(ctx, f.gameID)
|
||||
requireNoError(t, err)
|
||||
if failed.State != "failed" {
|
||||
t.Fatalf("not recovered: %+v", failed)
|
||||
}
|
||||
requireNoError(t, f.teams.UpdateTeam(ctx, f.teamID, "New snapshot name"))
|
||||
f.exports.lease, f.exports.prepareTimeout = Lease, PrepareTimeout
|
||||
retry := f.start(t, "new-attempt")
|
||||
if retry.ID == attempt.ID || bytes.Equal(retry.Snapshot, attempt.Snapshot) {
|
||||
t.Fatal("retry reused old snapshot")
|
||||
}
|
||||
if err := f.exports.Fail(ctx, attempt.ID, "old-worker", "stale failure"); !errors.Is(err, ErrOwnershipLost) {
|
||||
t.Fatalf("stale failure accepted: %v", err)
|
||||
}
|
||||
current, err := f.exports.Get(ctx, f.gameID)
|
||||
requireNoError(t, err)
|
||||
if current.ID != retry.ID || current.State != "preparing" {
|
||||
t.Fatal("stale failure changed retry")
|
||||
}
|
||||
|
||||
if err := f.exports.Complete(ctx, attempt.ID, "old-worker", "id", "digest", 10); !errors.Is(err, ErrOwnershipLost) {
|
||||
t.Fatalf("stale publication accepted: %v", err)
|
||||
}
|
||||
_, err = NewGameExportsRepo(f.pool).Claim(ctx, "new-worker")
|
||||
requireNoError(t, err)
|
||||
requireNoError(t, f.exports.Heartbeat(ctx, retry.ID, "new-worker"))
|
||||
requireNoError(t, f.exports.Complete(ctx, retry.ID, "new-worker", "package-id", "digest", 10))
|
||||
ready, err := NewGameExportsRepo(f.pool).Get(ctx, f.gameID)
|
||||
requireNoError(t, err)
|
||||
if ready.State != "ready" {
|
||||
t.Fatal("ready package lost after repository restart")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestFailedExportCleanupSurvivesGameDeletion(t *testing.T) {
|
||||
for _, tc := range []struct{ name string }{{"cleanup remains durable after game removal"}} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
f := newExportFixture(t)
|
||||
ctx := context.Background()
|
||||
attempt := f.start(t, "failed-attempt")
|
||||
_, err := f.exports.Claim(ctx, "worker")
|
||||
requireNoError(t, err)
|
||||
requireNoError(t, f.exports.Fail(ctx, attempt.ID, "worker", "Safe failure"))
|
||||
requireNoError(t, f.teams.DeleteTeam(ctx, f.teamID))
|
||||
requireNoError(t, f.games.DeleteGameByID(ctx, f.gameID))
|
||||
keys, err := f.exports.CleanupKeys(ctx)
|
||||
requireNoError(t, err)
|
||||
if len(keys) != 1 || keys[0] != attempt.ObjectKey {
|
||||
t.Fatal("deleted game lost pending object cleanup")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestPreparingExportSurvivesGameDeletion(t *testing.T) {
|
||||
for _, result := range []string{"ready", "failed", "expired"} {
|
||||
t.Run(result, func(t *testing.T) {
|
||||
f := newExportFixture(t)
|
||||
ctx := context.Background()
|
||||
attempt := f.start(t, "deleted-game-attempt")
|
||||
requireNoError(t, f.teams.DeleteTeam(ctx, f.teamID))
|
||||
requireNoError(t, f.games.DeleteGameByID(ctx, f.gameID))
|
||||
claimed, err := f.exports.Claim(ctx, "worker")
|
||||
requireNoError(t, err)
|
||||
if claimed.ID != attempt.ID || claimed.GameID != 0 || !bytes.Equal(claimed.Snapshot, attempt.Snapshot) {
|
||||
t.Fatal("deleting the game damaged queued snapshot")
|
||||
}
|
||||
switch result {
|
||||
case "ready":
|
||||
requireNoError(t, f.exports.Complete(ctx, attempt.ID, "worker", "package-id", "digest", 123))
|
||||
case "failed":
|
||||
requireNoError(t, f.exports.Fail(ctx, attempt.ID, "worker", "Safe failure"))
|
||||
case "expired":
|
||||
f.exports.prepareTimeout = 0
|
||||
requireNoError(t, f.exports.Expire(ctx))
|
||||
}
|
||||
inactive, err := f.exports.IsInactive(ctx, attempt.ID)
|
||||
requireNoError(t, err)
|
||||
if !inactive {
|
||||
t.Fatal("deleted game left an unfinishable export")
|
||||
}
|
||||
keys, err := f.exports.CleanupKeys(ctx)
|
||||
requireNoError(t, err)
|
||||
expected := 1
|
||||
if result == "ready" {
|
||||
expected = 0
|
||||
}
|
||||
if len(keys) != expected {
|
||||
t.Fatalf("wrong object cleanup state: %v", keys)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
package game_exports_repo
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/jackc/pgx/v5"
|
||||
)
|
||||
|
||||
// captureSnapshot uses one statement so editable shared scenarios and playlists are consistent.
|
||||
func captureSnapshot(ctx context.Context, tx pgx.Tx, gameID int) ([]byte, error) {
|
||||
var raw []byte
|
||||
err := tx.QueryRow(
|
||||
ctx,
|
||||
`SELECT jsonb_build_object(
|
||||
'game', jsonb_build_object(
|
||||
'id', g.id, 'name', g.name, 'description', COALESCE(g.description, ''),
|
||||
'location', g.location, 'start_at', g.start_at AT TIME ZONE 'UTC',
|
||||
'status', g.status, 'format', g.format, 'scenario_id', g.scenario_id,
|
||||
'playlist_id', COALESCE(m.playlist_id, g.playlist_id),
|
||||
'started_at', g.started_at AT TIME ZONE 'UTC', 'ended_at', g.ended_at AT TIME ZONE 'UTC'
|
||||
),
|
||||
'scenario', jsonb_build_object(
|
||||
'id', s.id, 'name', s.name, 'description', s.description,
|
||||
'image', COALESCE(s.image, ''), 'story', s.scenario, 'author_id', s.author_id,
|
||||
'status', s.status, 'created_at', s.created_at AT TIME ZONE 'UTC',
|
||||
'updated_at', s.updated_at AT TIME ZONE 'UTC',
|
||||
'published_at', s.published_at AT TIME ZONE 'UTC', 'playlist_id', s.playlist_id
|
||||
),
|
||||
'teams', COALESCE((
|
||||
SELECT jsonb_agg(jsonb_build_object(
|
||||
'id', t.id, 'game_id', t.game_id, 'name', t.name, 'status', t.status,
|
||||
'password', t.password, 'creator_id', t.creator_id, 'captain_email', t.captain_email,
|
||||
'participants_count', t.participants_count, 'participation_status', t.participation_status,
|
||||
'participation_source', t.participation_source, 'participation_answered_at', t.participation_answered_at,
|
||||
'registration_delivery_id', t.registration_delivery_id, 'created_at', t.created_at AT TIME ZONE 'UTC'
|
||||
) ORDER BY t.id)
|
||||
FROM teams t
|
||||
WHERE t.game_id = g.id
|
||||
), '[]'::jsonb),
|
||||
'tickets', COALESCE((
|
||||
SELECT jsonb_agg(jsonb_build_object(
|
||||
'id', t.id, 'game_id', t.game_id, 'token_hash', encode(t.token_hash, 'base64'),
|
||||
'status', t.status, 'used_at', t.used_at, 'used_by', t.used_by, 'created_at', t.created_at
|
||||
) ORDER BY t.id)
|
||||
FROM tickets t
|
||||
WHERE t.game_id = g.id
|
||||
), '[]'::jsonb),
|
||||
'playlists', COALESCE((
|
||||
SELECT jsonb_agg(jsonb_build_object(
|
||||
'id', p.id, 'name', p.name, 'tracks', COALESCE((
|
||||
SELECT jsonb_agg(jsonb_build_object(
|
||||
'id', tr.id, 'name', tr.name, 'filename', tr.filename, 'position', tr.position
|
||||
) ORDER BY tr.position, tr.id)
|
||||
FROM playlist_tracks tr
|
||||
WHERE tr.playlist_id = p.id
|
||||
), '[]'::jsonb)
|
||||
) ORDER BY p.id)
|
||||
FROM playlists p
|
||||
WHERE p.id IN (COALESCE(m.playlist_id, g.playlist_id), s.playlist_id)
|
||||
), '[]'::jsonb),
|
||||
'screen_plan', (
|
||||
SELECT jsonb_build_object('game_id', sp.game_id, 'steps', sp.steps, 'current_step', sp.current_step)
|
||||
FROM game_screen_plans sp
|
||||
WHERE sp.game_id = g.id
|
||||
),
|
||||
'map', COALESCE((
|
||||
SELECT jsonb_agg(jsonb_build_object(
|
||||
'game_id', mi.game_id, 'kind', mi.kind, 'item_id', mi.item_id,
|
||||
'x', mi.x, 'y', mi.y, 'hidden', mi.hidden
|
||||
) ORDER BY mi.kind, mi.item_id)
|
||||
FROM game_map_items mi
|
||||
WHERE mi.game_id = g.id
|
||||
), '[]'::jsonb)
|
||||
)
|
||||
FROM games g
|
||||
JOIN scenarios s ON s.id = g.scenario_id
|
||||
LEFT JOIN game_music_states m ON m.game_id = g.id
|
||||
WHERE g.id = $1`,
|
||||
gameID,
|
||||
).Scan(&raw)
|
||||
return raw, err
|
||||
}
|
||||
@@ -52,7 +52,7 @@ func (r *Repo) Create(ctx context.Context, gameID, teamID int, rating, difficult
|
||||
FROM teams t
|
||||
WHERE t.id = $2 AND t.game_id = $1
|
||||
ON CONFLICT (game_id, team_id) DO NOTHING
|
||||
RETURNING id, game_id, team_id, rating, text, status, created_at`,
|
||||
RETURNING id, game_id, team_id, rating, difficulty, text, status, created_at`,
|
||||
gameID,
|
||||
teamID,
|
||||
rating,
|
||||
|
||||
@@ -76,7 +76,16 @@ func (r *GamesRepo) GetGameByID(
|
||||
games.status,
|
||||
games.scenario_id,
|
||||
games.started_at,
|
||||
games.ended_at
|
||||
games.ended_at,
|
||||
(SELECT
|
||||
jsonb_build_object(
|
||||
'id', e.id, 'state', e.state, 'package_id', e.package_id,
|
||||
'size', e.size, 'sha256', e.sha256, 'failure', e.failure
|
||||
)
|
||||
FROM game_exports e
|
||||
WHERE e.game_id = games.id
|
||||
ORDER BY e.created_at DESC, e.id
|
||||
LIMIT 1)
|
||||
FROM games
|
||||
WHERE id = $1`,
|
||||
id,
|
||||
@@ -92,6 +101,7 @@ func (r *GamesRepo) GetGameByID(
|
||||
&game.ScenarioID,
|
||||
&game.StartedAt,
|
||||
&game.EndedAt,
|
||||
&game.Export,
|
||||
)
|
||||
if err != nil {
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
@@ -136,7 +146,16 @@ func (r *GamesRepo) GetGames(
|
||||
games.location,
|
||||
games.start_at,
|
||||
games.format,
|
||||
games.status
|
||||
games.status,
|
||||
(SELECT
|
||||
jsonb_build_object(
|
||||
'id', e.id, 'state', e.state, 'package_id', e.package_id,
|
||||
'size', e.size, 'sha256', e.sha256, 'failure', e.failure
|
||||
)
|
||||
FROM game_exports e
|
||||
WHERE e.game_id = games.id
|
||||
ORDER BY e.created_at DESC, e.id
|
||||
LIMIT 1)
|
||||
FROM games
|
||||
ORDER BY created_at DESC`,
|
||||
)
|
||||
@@ -156,6 +175,7 @@ func (r *GamesRepo) GetGames(
|
||||
&game.StartAt,
|
||||
&game.Format,
|
||||
&game.Status,
|
||||
&game.Export,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
|
||||
Reference in New Issue
Block a user