From 74a45da402691c8a29107af344cb017869716f16 Mon Sep 17 00:00:00 2001 From: Fedorov Vladimir Date: Sun, 11 Oct 2026 13:09:34 +0700 Subject: [PATCH] up --- docs/GAME_PACKAGE.md | 96 +++- internal/repos/game_results_repo/repo.go | 59 +- internal/repos/game_returns_repo/repo.go | 260 +++++++++ internal/repos/game_returns_repo/repo_test.go | 85 +++ internal/repos/game_returns_repo/write.go | 304 +++++++++++ internal/repos/games_repo/repo.go | 16 +- internal/repos/teams_repo/participation.go | 64 ++- .../services/answer_service/service_test.go | 1 + .../game_export_service/return_test.go | 508 ++++++++++++++++++ .../game_results_package_service/apply.go | 309 +++++++++++ .../apply_mapper.go | 105 ++++ .../apply_test.go | 249 +++++++++ .../apply_validation.go | 73 +++ .../flow_apply_test.go | 143 +++++ .../game_results_package_service/snapshot.go | 84 +-- .../services/game_service/lifecycle_test.go | 1 + .../gameplay_service/finished_story_test.go | 1 + .../20261012120000_create_game_returns.sql | 18 + 18 files changed, 2294 insertions(+), 82 deletions(-) create mode 100644 internal/repos/game_returns_repo/repo.go create mode 100644 internal/repos/game_returns_repo/repo_test.go create mode 100644 internal/repos/game_returns_repo/write.go create mode 100644 internal/services/game_export_service/return_test.go create mode 100644 internal/services/game_results_package_service/apply.go create mode 100644 internal/services/game_results_package_service/apply_mapper.go create mode 100644 internal/services/game_results_package_service/apply_test.go create mode 100644 internal/services/game_results_package_service/apply_validation.go create mode 100644 internal/services/game_results_package_service/flow_apply_test.go create mode 100644 migrations/20261012120000_create_game_returns.sql diff --git a/docs/GAME_PACKAGE.md b/docs/GAME_PACKAGE.md index 383d225..a4bde10 100644 --- a/docs/GAME_PACKAGE.md +++ b/docs/GAME_PACKAGE.md @@ -350,7 +350,7 @@ RustFS и создать `.continue` в течение минуты. Buc ## Пакет результатов, версия 1 (ED-029, этап 9) Это отдельный контракт: ZIP содержит ровно `manifest.json` и `results.json`, -без сценария и файлов. Он ещё не применяется онлайн и не доступен через API/UI. +без сценария и файлов. Серверное применение реализовано этапом 10; API/UI остаются этапом 11. `game_results_package_service.NewGameResultsPackageService(repo, mode).Prepare(ctx, gameID, output)` доступен только в `venue`, для существующей игры из готового @@ -396,9 +396,10 @@ SHA-256 защищает от повреждения; это не подпись | `answers` | `team_id`, `question_code`, `answer`, nullable `score`, `created_at`, `updated_at` | `source_id` совпадает с `id` для исходной записи и равен `0` для новой локальной. -Все ссылки прогресса используют итоговый локальный `team_id`. Локальные ID новых -команд, билетов, ходов и улик не являются свободными ID онлайн-базы: следующий -этап должен выделять ID и сохранять соответствия. +Все ссылки прогресса используют итоговый локальный `team_id`. Этап 10 уточняет +выделение ID: новые команды сохраняют venue-ID и пароль для напечатанных QR; +занятый чужой командой ID отклоняет весь возврат. Новым билетам, ходам и уликам +выделяются онлайн-ID, соответствия сохраняются в закрытом реестре. `creator_id` и `registration_delivery_id` итоговой команды берутся только из исходного снимка; для новой команды они отсутствуют. Текущий локальный создатель @@ -433,6 +434,87 @@ workspace. Стенд проверяет оба формата, свежую о сохранность прежнего ZIP, повторную подготовку после перезапуска и отсутствие изменений исходных игр. Пакеты и тестовые ресурсы удаляются после прогона. -Применение онлайн, разрешение конфликтов и идемпотентность возврата — этап 10; -подготовка/скачивание и импорт через интерфейс — этап 11. До завершения возврата -используются только тестовые игры. +Подготовка/скачивание и возврат через интерфейс — этап 11. До завершения +интерфейса и контрольного прогона используются только тестовые игры. + + +## Применение результатов онлайн (ED-029, этап 10) + +`game_results_package_service.NewGameResultsApplyService(repo, privateStorage, +mode, sourceServer).Apply(ctx, input io.ReaderAt, size, initiatorID)` доступен +только при `mode="prod"`. Это серверный сервис без нового API/UI и развёртывания. +Возвращается закрытая запись реестра; будущий transport должен выдавать только +публичный результат операции. + +Входные байты один раз сохраняются в приватный временный файл и проверяются +кодеком этапа 9; SHA-256 всего ZIP связывает повтор с этими же байтами. +Сервис проверяет сервер-источник, игру, ID и SHA-256 исходного экспорта, читает +его ZIP из закрытого хранилища, проверяет размер, контрольную сумму и контракт. +`source` результатов сверяется с данными этого ZIP: команды, билеты, сценарий, +материалы и вопросы нельзя подменить в загруженном пакете. Формат неизменяем. +Все временные файлы/каталоги имеют права 0600/0700 и удаляются при успешном +применении, отказе и отмене. + +Пакет результатов считается источником истины для возвращаемых данных: +онлайн-правки состава команд/билетов, прогресса, ответов, баллов, формата, +статуса и фактических дат перезаписываются без сравнения с прежним состоянием. +Игровой сценарий восстанавливается из сохранённого экспортного снимка с +исходными онлайн-ссылками материалов. Если текущий сценарий отличается, +создаётся отдельная копия для возвращаемой игры; сценарии других игр не меняются. +Копия сохраняет оформление и playlist текущего сценария; неизменённый сценарий +повторно не копируется. Название, описание, место, плановая дата, музыка, карта +и экраны игры сохраняются. Отсутствующая игра не восстанавливается. + +Полные итоговые снимки заменяют команды/участие и билеты с учётом удалений, +ходы/MCP, материалы и выдачу, ответы и nullable баллы, статус `finish` и +фактические даты. Баллы не пересчитываются. Удаление команды очищает её связи +карты. Исходные команды/билеты сохраняют ID; новые команды сохраняют также +venue-пароль. Новые билеты получают онлайн-ID и прежний хеш токена. Коллизия +ID или хеша с чужой записью отклоняет весь пакет. Ходы и улики получают свежие +онлайн-ID в порядке пакета, включая исправленный снимок. Реестр хранит +соответствия каждого возврата; соответствия новых билетов сохраняются между +исправлениями. Последовательности продвигаются монотонным `nextval`, без `setval` +и глобальных блокировок обычных операций. + +Материалы переводятся из portable-троек исходного ZIP в исходные онлайн-ссылки +через сохранённый экспортный снимок. Новых загрузок файлов и скачивания внешних +URL нет. Существующие онлайн-ссылки создателей и исполнителей и технические +почтовые поля сохраняются; их изменение не считается изменением возвращаемых +данных. У новых команд `creator_id=NULL`, у новых локально использованных +билетов `used_by=NULL`; отсутствующий онлайн-аккаунт не восстанавливается. +Локальные исполнители остаются только в закрытом реестре. Новые почтовые +задания и токены не создаются; возвращённые игры исключены из очереди новых +напоминаний об отзыве. + +`game_returns` хранит ID/SHA-256 ZIP результатов, исходный экспорт, исходный ID +игры, время подготовки/применения, соответствия ID (включая сценарий), +локальных исполнителей и инициатора. Записи остаются после +удаления игры. Тот же ID с теми же байтами возвращает первоначальную запись без +изменений, даже после более нового пакета или удаления игры. Тот же ID с другими +байтами — конфликт. Новый пакет требует `created_at` строго позже последнего +применённого; время используется без поправок часов. UTC-время подготовки +сохраняется строкой с фиксированными девятью знаками долей секунды, чтобы +PostgreSQL не терял наносекунды при сравнении и повторе. + +Чтение текущих ссылок пользователей, запись данных и реестра выполняются +одной `SERIALIZABLE` +транзакцией с блокировками игры, экспорта, сценария и затронутых записей. +Serialization failure/deadlock повторяются не более трёх раз, затем возвращается +безопасная повторяемая ошибка. Проверки ZIP/хранилища проходят до транзакции. +`game.export` исчезает в чтении игры и списка после успешного возврата именно +соответствующего экспорта; исходный экспорт остаётся `ready`, ZIP сохраняется +для следующих проверок. Более старый экспорт вместо скрытого не показывается. + +Проверки: `go test ./...` с отдельным `TEST_DATABASE_URL`, релевантный `go test +-race`, `python3 tests/venue/flow.py --apply`. Стенд проверяет оба формата, +настоящие venue-ZIP, prod PostgreSQL/private RustFS, перезапись онлайн-редактирования, +исправление баллов, старый повтор после нового и перезапуска prod, прежние и +новые ссылки, сохранение оформления и исчезновение офлайн-отметки. + +Ограничения: ZIP не имеет криптографического подтверждения подлинности. +Sequence-значения PostgreSQL не откатываются: большие разрывы venue-ID расходуют +их даже при отказе. Продвижение ограничено пятью секундами, затем данные +откатываются и возвращается повторяемая ошибка; INT32-потолок отклоняется. +Перед поддержкой площадок с такими разрывами требуется расширить типы ID. +ZIP на 1 ГиБ, 100 клиентов и Wi-Fi этим этапом не проверяются. До завершения +этапа 11 и контрольного прогона используются только тестовые игры. diff --git a/internal/repos/game_results_repo/repo.go b/internal/repos/game_results_repo/repo.go index 5b455b5..1a0b585 100644 --- a/internal/repos/game_results_repo/repo.go +++ b/internal/repos/game_results_repo/repo.go @@ -49,7 +49,44 @@ func (r *GameResultsRepo) Capture(ctx context.Context, gameID int) (*Snapshot, e s.scenario, g.scenario_id, (i.mappings->>'scenario_id')::integer, - jsonb_build_object( + `+resultsDataSQL+` + FROM games g + JOIN scenarios s ON s.id = g.scenario_id + JOIN game_imports i ON i.source_game_id = g.id AND i.state = 'ready' + WHERE g.id = $1`, + gameID, + ).Scan(&snapshot.ImportID, &snapshot.PackageID, &snapshot.SourceServer, + &snapshot.SHA256, &snapshot.OriginalData, &snapshot.Manifest, &snapshot.Story, &snapshot.ScenarioID, &snapshot.ImportedScenarioID, &snapshot.Data) + if errors.Is(err, pgx.ErrNoRows) { + return nil, ErrNotImported + } + if err != nil { + return nil, err + } + if err := tx.Commit(ctx); err != nil { + return nil, err + } + return snapshot, nil +} + +// CaptureOnline reads returned fields inside the caller's locked transaction. +func CaptureOnline(ctx context.Context, tx pgx.Tx, gameID int) (*Snapshot, error) { + snapshot := &Snapshot{} + err := tx.QueryRow( + ctx, + `SELECT + g.scenario_id, + s.scenario, + `+resultsDataSQL+` + FROM games g + JOIN scenarios s ON s.id = g.scenario_id + WHERE g.id = $1`, + gameID, + ).Scan(&snapshot.ScenarioID, &snapshot.Story, &snapshot.Data) + return snapshot, err +} + +const resultsDataSQL = `jsonb_build_object( 'game', jsonb_build_object( 'id', g.id, 'status', g.status, 'format', g.format, 'started_at', g.started_at AT TIME ZONE 'UTC', @@ -131,22 +168,4 @@ func (r *GameResultsRepo) Capture(ctx context.Context, gameID int) (*Snapshot, e JOIN teams t ON t.id = a.team_id WHERE t.game_id = g.id ), '[]'::jsonb) - ) - FROM games g - JOIN scenarios s ON s.id = g.scenario_id - JOIN game_imports i ON i.source_game_id = g.id AND i.state = 'ready' - WHERE g.id = $1`, - gameID, - ).Scan(&snapshot.ImportID, &snapshot.PackageID, &snapshot.SourceServer, - &snapshot.SHA256, &snapshot.OriginalData, &snapshot.Manifest, &snapshot.Story, &snapshot.ScenarioID, &snapshot.ImportedScenarioID, &snapshot.Data) - if errors.Is(err, pgx.ErrNoRows) { - return nil, ErrNotImported - } - if err != nil { - return nil, err - } - if err := tx.Commit(ctx); err != nil { - return nil, err - } - return snapshot, nil -} + )` diff --git a/internal/repos/game_returns_repo/repo.go b/internal/repos/game_returns_repo/repo.go new file mode 100644 index 0000000..7f8dc1d --- /dev/null +++ b/internal/repos/game_returns_repo/repo.go @@ -0,0 +1,260 @@ +// Package game_returns_repo persists atomic returns and their private audit records. +package game_returns_repo + +import ( + "context" + "errors" + "time" + + "evening_detective_server/internal/repos/game_exports_repo" + "evening_detective_server/internal/repos/game_results_repo" + "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/pgconn" + "github.com/jackc/pgx/v5/pgxpool" +) + +var ( + ErrConflict = errors.New("пакет устарел или идентификатор уже занят") + ErrSource = errors.New("исходный экспорт не найден") + ErrRetry = errors.New("возврат временно недоступен; повторите попытку") +) + +// Return is retained even after the source game is deleted. +type Return struct { + PackageID string + SHA256 string + ExportID string + GameID int + PreparedAt time.Time + AppliedAt time.Time + Mappings []byte + LocalActors []byte + InitiatorID int +} + +type GameReturnsRepo struct{ pool *pgxpool.Pool } + +func NewGameReturnsRepo(pool *pgxpool.Pool) *GameReturnsRepo { return &GameReturnsRepo{pool: pool} } + +// Transaction retries only PostgreSQL serialization failures and deadlocks. +func (r *GameReturnsRepo) Transaction(ctx context.Context, apply func(pgx.Tx) (*Return, error)) (*Return, error) { + for attempt := 0; attempt < 3; attempt++ { + tx, err := r.pool.BeginTx(ctx, pgx.TxOptions{IsoLevel: pgx.Serializable}) + if err != nil { + return nil, err + } + result, err := apply(tx) + if err == nil { + err = tx.Commit(ctx) + } + _ = tx.Rollback(ctx) + if err == nil { + return result, nil + } + if ctx.Err() != nil { + return nil, ctx.Err() + } + var pgErr *pgconn.PgError + if errors.As(err, &pgErr) { + switch pgErr.Code { + case "40001", "40P01": + continue + case "23505", "23503", "23514", "22001", "22003", "2200H": + return nil, ErrConflict + } + } + return nil, err + } + return nil, ErrRetry +} + +func ReadReturn(ctx context.Context, tx pgx.Tx, packageID string) (*Return, error) { + return readReturn(ctx, tx, `WHERE package_id = $1`, packageID) +} +func ReadLatest(ctx context.Context, tx pgx.Tx, exportID string) (*Return, error) { + return readReturn(ctx, tx, `WHERE export_id = $1 + ORDER BY prepared_at DESC + LIMIT 1`, exportID) +} + +type IRowReader interface { + QueryRow(context.Context, string, ...any) pgx.Row +} + +func (r *GameReturnsRepo) FindReturn(ctx context.Context, packageID string) (*Return, error) { + return readReturn(ctx, r.pool, `WHERE package_id = $1`, packageID) +} + +func readReturn(ctx context.Context, tx IRowReader, filter, id string) (*Return, error) { + result := &Return{} + var prepared string + err := tx.QueryRow( + ctx, + `SELECT + package_id, + sha256, + export_id, + source_game_id, + prepared_at, + applied_at, + mappings, + local_actors, + COALESCE(initiator_id, 0) + FROM game_returns + `+filter, + id, + ).Scan(&result.PackageID, &result.SHA256, &result.ExportID, &result.GameID, &prepared, &result.AppliedAt, &result.Mappings, &result.LocalActors, &result.InitiatorID) + if err != nil { + return nil, err + } + result.PreparedAt, err = time.Parse(time.RFC3339Nano, prepared) + if err != nil { + return nil, err + } + return result, nil +} + +func (r *GameReturnsRepo) ReadExport(ctx context.Context, packageID string) (*game_exports_repo.Attempt, error) { + result := &game_exports_repo.Attempt{} + err := r.pool.QueryRow( + ctx, + `SELECT + id, + COALESCE(game_id, 0), + package_id, + sha256, + size, + source_server, + object_key, + snapshot + FROM game_exports + WHERE package_id = $1 AND state = 'ready'`, + packageID, + ).Scan(&result.ID, &result.GameID, &result.PackageID, &result.SHA256, &result.Size, &result.SourceServer, &result.ObjectKey, &result.Snapshot) + if errors.Is(err, pgx.ErrNoRows) { + return nil, ErrSource + } + if err != nil { + return nil, err + } + return result, nil +} + +// Lock blocks concurrent returns, edits and new FK-linked gameplay rows for this game. +func Lock(ctx context.Context, tx pgx.Tx, gameID int, exportID string) error { + var id int + err := tx.QueryRow( + ctx, + `SELECT + g.id + FROM games g + JOIN game_exports e ON e.game_id = g.id + WHERE g.id = $1 AND e.id = $2 AND e.state = 'ready' + FOR UPDATE OF g, e`, + gameID, + exportID, + ).Scan(&id) + if errors.Is(err, pgx.ErrNoRows) { + return ErrSource + } + if err != nil { + return err + } + for _, query := range []string{ + `SELECT + s.id + FROM scenarios s + JOIN games g ON g.scenario_id = s.id + WHERE g.id = $1 + FOR UPDATE OF s`, + `SELECT + id + FROM teams + WHERE game_id = $1 + ORDER BY id + FOR UPDATE`, + `SELECT + id + FROM tickets + WHERE game_id = $1 + ORDER BY id + FOR UPDATE`, + `SELECT + a.id + FROM actions a + JOIN teams t ON t.id = a.team_id + WHERE t.game_id = $1 + ORDER BY a.id + FOR UPDATE OF a`, + `SELECT + a.id + FROM applications a + JOIN teams t ON t.id = a.team_id + WHERE t.game_id = $1 + ORDER BY a.id + FOR UPDATE OF a`, + `SELECT + a.id + FROM team_answers a + JOIN teams t ON t.id = a.team_id + WHERE t.game_id = $1 + ORDER BY a.id + FOR UPDATE OF a`, + } { + rows, err := tx.Query( + ctx, + query, + gameID, + ) + if err != nil { + return err + } + for rows.Next() { + } + err = rows.Err() + rows.Close() + if err != nil { + return err + } + } + return nil +} + +func Capture(ctx context.Context, tx pgx.Tx, gameID int) (*game_results_repo.Snapshot, error) { + return game_results_repo.CaptureOnline(ctx, tx, gameID) +} + +// Allocate reserves online identities; rolled-back sequence gaps are intentional. +func Allocate(ctx context.Context, tx pgx.Tx, kind string) (int, error) { + sequences := map[string]string{"tickets": "tickets_id_seq", "actions": "actions_id_seq", "applications": "applications_id_seq"} + sequence, ok := sequences[kind] + if !ok { + return 0, ErrConflict + } + var id int + err := tx.QueryRow( + ctx, + `SELECT nextval($1::regclass)`, + sequence, + ).Scan(&id) + return id, err +} + +func Save(ctx context.Context, tx pgx.Tx, result *Return) error { + return tx.QueryRow( + ctx, + `INSERT INTO game_returns ( + package_id, sha256, export_id, source_game_id, prepared_at, mappings, local_actors, initiator_id + ) + VALUES ($1,$2,$3,$4,$5,$6,$7,NULLIF($8,0)) + RETURNING applied_at`, + result.PackageID, + result.SHA256, + result.ExportID, + result.GameID, + result.PreparedAt.UTC().Format("2006-01-02T15:04:05.000000000Z"), + result.Mappings, + result.LocalActors, + result.InitiatorID, + ).Scan(&result.AppliedAt) +} diff --git a/internal/repos/game_returns_repo/repo_test.go b/internal/repos/game_returns_repo/repo_test.go new file mode 100644 index 0000000..7327a23 --- /dev/null +++ b/internal/repos/game_returns_repo/repo_test.go @@ -0,0 +1,85 @@ +package game_returns_repo + +import ( + "context" + "errors" + "os" + "testing" + + "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/pgconn" + "github.com/jackc/pgx/v5/pgxpool" +) + +func TestTransactionRetriesOnlyTransientFailures(t *testing.T) { + for _, tc := range []struct { + name, code string + failures, attempts int + expected error + }{ + {"serialization then success", "40001", 2, 3, nil}, + {"deadlock then success", "40P01", 1, 2, nil}, + {"serialization exhausted", "40001", 3, 3, ErrRetry}, + {"deadlock exhausted", "40P01", 3, 3, ErrRetry}, + {"unique collision", "23505", 1, 1, ErrConflict}, + {"foreign key collision", "23503", 1, 1, ErrConflict}, + {"sequence exhausted", "2200H", 1, 1, ErrConflict}, + } { + t.Run(tc.name, func(t *testing.T) { + dsn := os.Getenv("TEST_DATABASE_URL") + if dsn == "" { + t.Skip("set TEST_DATABASE_URL for transaction retry checks") + } + ctx := context.Background() + pool, err := pgxpool.New(ctx, dsn) + if err != nil { + t.Fatal(err) + } + defer pool.Close() + repo := NewGameReturnsRepo(pool) + attempts := 0 + result, err := repo.Transaction(ctx, func(pgx.Tx) (*Return, error) { + attempts++ + if attempts <= tc.failures { + return nil, &pgconn.PgError{Code: tc.code} + } + return &Return{PackageID: "saved"}, nil + }) + if attempts != tc.attempts || !errors.Is(err, tc.expected) { + t.Fatalf("attempts %d error %v", attempts, err) + } + if err == nil && result.PackageID != "saved" { + t.Fatal("result lost") + } + }) + } +} + +func TestSequenceErrorPreservesCancellationAndCause(t *testing.T) { + for _, tc := range []struct { + name string + cancelParent, cancelAdvance bool + expected error + }{ + {"parent cancelled", true, true, context.Canceled}, + {"advance cancelled", false, true, ErrRetry}, + {"driver failure", false, false, ErrConflict}, + } { + t.Run(tc.name, func(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + advanceCtx, advanceCancel := context.WithCancel(ctx) + defer advanceCancel() + if tc.cancelParent { + cancel() + } + if tc.cancelAdvance { + advanceCancel() + } + err := sequenceError(ctx, advanceCtx, ErrConflict) + if !errors.Is(err, tc.expected) { + t.Fatal(err) + } + }) + } +} diff --git a/internal/repos/game_returns_repo/write.go b/internal/repos/game_returns_repo/write.go new file mode 100644 index 0000000..1f023e2 --- /dev/null +++ b/internal/repos/game_returns_repo/write.go @@ -0,0 +1,304 @@ +package game_returns_repo + +import ( + "context" + "time" + + "github.com/jackc/pgx/v5" +) + +// Write replaces gameplay only; technical mail fields and existing online actors survive updates. +func Write(ctx context.Context, tx pgx.Tx, gameID int, data []byte) error { + var collision bool + err := tx.QueryRow( + ctx, + `SELECT EXISTS ( + SELECT + 1 + FROM teams t + JOIN jsonb_to_recordset($2::jsonb->'teams') AS n(id integer) ON n.id = t.id + WHERE t.game_id <> $1 + ) OR EXISTS ( + SELECT + 1 + FROM tickets t + JOIN jsonb_to_recordset($2::jsonb->'tickets') AS n(id integer) ON n.id = t.id + WHERE t.game_id <> $1 + )`, + gameID, + data, + ).Scan(&collision) + if err != nil { + return err + } + if collision { + return ErrConflict + } + for _, query := range []string{ + `DELETE FROM actions a + USING teams t + WHERE a.team_id = t.id AND t.game_id = $1 AND $2::jsonb IS NOT NULL`, + `DELETE FROM applications a + USING teams t + WHERE a.team_id = t.id AND t.game_id = $1 AND $2::jsonb IS NOT NULL`, + `DELETE FROM team_answers a + USING teams t + WHERE a.team_id = t.id AND t.game_id = $1 AND $2::jsonb IS NOT NULL`, + `DELETE FROM game_map_items m + WHERE m.game_id = $1 AND m.kind = 'team' + AND NOT EXISTS ( + SELECT + 1 + FROM jsonb_to_recordset($2::jsonb->'teams') AS n(id integer) + WHERE n.id = m.item_id + )`, + `DELETE FROM teams t + WHERE t.game_id = $1 + AND NOT EXISTS ( + SELECT + 1 + FROM jsonb_to_recordset($2::jsonb->'teams') AS n(id integer) + WHERE n.id = t.id + )`, + `DELETE FROM tickets t + WHERE t.game_id = $1 + AND NOT EXISTS ( + SELECT + 1 + FROM jsonb_to_recordset($2::jsonb->'tickets') AS n(id integer) + WHERE n.id = t.id + )`, + `INSERT INTO teams (id, game_id, name, status, password, creator_id, captain_email, participants_count, participation_status, participation_source, participation_answered_at, registration_delivery_id, created_at) + SELECT + n.id, + n.game_id, + n.name, + n.status, + n.password, + CASE WHEN EXISTS ( + SELECT + 1 + FROM users u + WHERE u.id = n.creator_id + ) THEN n.creator_id ELSE NULL END, + n.captain_email, + n.participants_count, + n.participation_status, + n.participation_source, + n.participation_answered_at, + n.registration_delivery_id, + n.created_at + FROM jsonb_to_recordset($2::jsonb->'teams') AS n( + id integer, + game_id integer, + name text, + status text, + password text, + creator_id integer, + captain_email text, + participants_count integer, + participation_status text, + participation_source text, + participation_answered_at timestamptz, + registration_delivery_id text, + created_at timestamp + ) + ON CONFLICT (id) DO UPDATE + SET name = EXCLUDED.name, + status = EXCLUDED.status, + password = EXCLUDED.password, + captain_email = EXCLUDED.captain_email, + participants_count = EXCLUDED.participants_count, + participation_status = EXCLUDED.participation_status, + participation_source = EXCLUDED.participation_source, + participation_answered_at = EXCLUDED.participation_answered_at, + created_at = EXCLUDED.created_at + WHERE teams.game_id = $1`, + `INSERT INTO tickets (id, game_id, token_hash, status, used_at, used_by, created_at) + SELECT + n.id, + n.game_id, + decode(n.token_hash, 'base64'), + n.status, + n.used_at, + CASE WHEN EXISTS ( + SELECT + 1 + FROM users u + WHERE u.id = n.used_by + ) THEN n.used_by ELSE NULL END, + n.created_at + FROM jsonb_to_recordset($2::jsonb->'tickets') AS n( + id integer, + game_id integer, + token_hash text, + status text, + used_at timestamptz, + used_by integer, + created_at timestamptz + ) + ON CONFLICT (id) DO UPDATE + SET token_hash = EXCLUDED.token_hash, + status = EXCLUDED.status, + used_at = EXCLUDED.used_at, + used_by = EXCLUDED.used_by, + created_at = EXCLUDED.created_at + WHERE tickets.game_id = $1`, + `INSERT INTO actions (id, team_id, code, via_mcp, created_at) + SELECT + n.id, + n.team_id, + n.code, + n.via_mcp, + n.created_at + FROM jsonb_to_recordset($2::jsonb->'actions') AS n( + id integer, + team_id integer, + code text, + via_mcp boolean, + created_at timestamp + ) + WHERE EXISTS ( + SELECT + 1 + FROM teams t + WHERE t.id = n.team_id AND t.game_id = $1 + )`, + `INSERT INTO applications (id, team_id, name, image, file_type, status, created_at) + SELECT + n.id, + n.team_id, + n.name, + n.image, + n.file_type, + n.status, + n.created_at + FROM jsonb_to_recordset($2::jsonb->'applications') AS n( + id integer, + team_id integer, + name text, + image text, + file_type text, + status text, + created_at timestamp + ) + WHERE EXISTS ( + SELECT + 1 + FROM teams t + WHERE t.id = n.team_id AND t.game_id = $1 + )`, + `INSERT INTO team_answers (team_id, question_code, answer, score, created_at, updated_at) + SELECT + n.team_id, + n.question_code, + n.answer, + n.score, + n.created_at, + n.updated_at + FROM jsonb_to_recordset($2::jsonb->'answers') AS n( + team_id integer, + question_code text, + answer text, + score integer, + created_at timestamp, + updated_at timestamp + ) + WHERE EXISTS ( + SELECT + 1 + FROM teams t + WHERE t.id = n.team_id AND t.game_id = $1 + )`, + `UPDATE games + SET format = $2::jsonb->'game'->>'format', + status = $2::jsonb->'game'->>'status', + started_at = ($2::jsonb->'game'->>'started_at')::timestamp, + ended_at = ($2::jsonb->'game'->>'ended_at')::timestamp + WHERE id = $1`, + } { + if _, err := tx.Exec( + ctx, + query, + gameID, + data, + ); err != nil { + return err + } + } + // nextval is monotonic under concurrent inserts; setval can move a live sequence backwards. + // shortcut: huge venue ID gaps consume sequence values even on refusal; widen IDs before supporting such venues. + var maximum int + if err := tx.QueryRow( + ctx, + `SELECT COALESCE(max(id),0) + FROM teams`, + ).Scan(&maximum); err != nil { + return err + } + if maximum >= 2147483647 { + return ErrConflict + } + advanceCtx, cancel := context.WithTimeout(ctx, 5*time.Second) + defer cancel() + for { + var id int + if err := tx.QueryRow( + advanceCtx, + `SELECT nextval('teams_id_seq')`, + ).Scan(&id); err != nil { + return sequenceError(ctx, advanceCtx, err) + } + if id >= maximum { + break + } + if err := tx.QueryRow( + advanceCtx, + `SELECT max(nextval('teams_id_seq')) + FROM generate_series(1,$1::integer)`, + min(maximum-id, 4096), + ).Scan(&id); err != nil { + return sequenceError(ctx, advanceCtx, err) + } + if id >= maximum { + break + } + } + return nil +} + +func sequenceError(ctx, advanceCtx context.Context, err error) error { + if ctx.Err() != nil { + return ctx.Err() + } + if advanceCtx.Err() != nil { + return ErrRetry + } + return err +} + +// RestoreStory isolates returned questions and materials from other games sharing the scenario. +func RestoreStory(ctx context.Context, tx pgx.Tx, gameID int, story []byte) (int, error) { + var scenarioID int + err := tx.QueryRow( + ctx, + `INSERT INTO scenarios (name, description, image, scenario, author_id, status, playlist_id) + SELECT s.name, s.description, s.image, $2::jsonb, s.author_id, 'draft', s.playlist_id + FROM scenarios s + JOIN games g ON g.scenario_id = s.id + WHERE g.id = $1 + RETURNING id`, + gameID, + story, + ).Scan(&scenarioID) + if err != nil { + return 0, err + } + _, err = tx.Exec( + ctx, + `UPDATE games SET scenario_id = $2 WHERE id = $1`, + gameID, + scenarioID, + ) + return scenarioID, err +} diff --git a/internal/repos/games_repo/repo.go b/internal/repos/games_repo/repo.go index 62c4f6b..f79b779 100644 --- a/internal/repos/games_repo/repo.go +++ b/internal/repos/games_repo/repo.go @@ -78,10 +78,14 @@ func (r *GamesRepo) GetGameByID( games.started_at, games.ended_at, (SELECT - jsonb_build_object( + CASE WHEN EXISTS ( + SELECT 1 + FROM game_returns r + WHERE r.export_id = e.id + ) THEN NULL ELSE jsonb_build_object( 'id', e.id, 'state', e.state, 'package_id', e.package_id, 'size', e.size, 'sha256', e.sha256, 'failure', e.failure - ) + ) END FROM game_exports e WHERE e.game_id = games.id ORDER BY e.created_at DESC, e.id @@ -148,10 +152,14 @@ func (r *GamesRepo) GetGames( games.format, games.status, (SELECT - jsonb_build_object( + CASE WHEN EXISTS ( + SELECT 1 + FROM game_returns r + WHERE r.export_id = e.id + ) THEN NULL ELSE jsonb_build_object( 'id', e.id, 'state', e.state, 'package_id', e.package_id, 'size', e.size, 'sha256', e.sha256, 'failure', e.failure - ) + ) END FROM game_exports e WHERE e.game_id = games.id ORDER BY e.created_at DESC, e.id diff --git a/internal/repos/teams_repo/participation.go b/internal/repos/teams_repo/participation.go index d73fc66..4e091e9 100644 --- a/internal/repos/teams_repo/participation.go +++ b/internal/repos/teams_repo/participation.go @@ -162,20 +162,56 @@ func (r *TeamsRepo) FinishParticipationEmail(ctx context.Context, id, attempt in // ClaimReviewReminder claims the next review reminder job. func (r *TeamsRepo) ClaimReviewReminder(ctx context.Context) (*ReviewReminder, error) { job := &ReviewReminder{} - err := r.pool.QueryRow(ctx, `WITH candidate AS ( - SELECT t.id FROM teams t JOIN games g ON g.id=t.game_id - WHERE t.captain_email<>'' AND t.review_reminder_sent_at IS NULL - AND t.review_reminder_attempts < $1 - AND (t.review_reminder_next_attempt_at IS NULL OR t.review_reminder_next_attempt_at <= NOW()) - AND g.status='finish' AND g.started_at <= NOW() - INTERVAL '16 hours' - AND NOT EXISTS (SELECT 1 FROM game_reviews r WHERE r.game_id=t.game_id AND r.team_id=t.id) - ORDER BY g.started_at, t.id - FOR UPDATE OF t SKIP LOCKED LIMIT 1 - ) UPDATE teams t SET review_reminder_next_attempt_at=NOW()+INTERVAL '5 minutes', - review_reminder_attempts=t.review_reminder_attempts+1 - FROM candidate c, games g WHERE t.id=c.id AND g.id=t.game_id - RETURNING t.id,t.name,t.game_id,g.name,t.captain_email,t.password,t.review_reminder_attempts`, maxReviewReminderAttempts).Scan( - &job.TeamID, &job.TeamName, &job.GameID, &job.GameName, &job.Email, &job.Password, &job.Attempts) + err := r.pool.QueryRow( + ctx, + `WITH candidate AS ( + SELECT + t.id + FROM teams t + JOIN games g ON g.id = t.game_id + WHERE t.captain_email <> '' AND t.review_reminder_sent_at IS NULL + AND t.review_reminder_attempts < $1 + AND (t.review_reminder_next_attempt_at IS NULL OR t.review_reminder_next_attempt_at <= NOW()) + AND g.status = 'finish' AND g.started_at <= NOW() - INTERVAL '16 hours' + AND NOT EXISTS ( + SELECT + 1 + FROM game_reviews r + WHERE r.game_id = t.game_id AND r.team_id = t.id + ) + AND NOT EXISTS ( + SELECT + 1 + FROM game_returns r + WHERE r.source_game_id = g.id + ) + ORDER BY g.started_at, t.id + FOR UPDATE OF t SKIP LOCKED + LIMIT 1 + ) + UPDATE teams t + SET review_reminder_next_attempt_at = NOW() + INTERVAL '5 minutes', + review_reminder_attempts = t.review_reminder_attempts + 1 + FROM candidate c, games g + WHERE t.id = c.id AND g.id = t.game_id + RETURNING + t.id, + t.name, + t.game_id, + g.name, + t.captain_email, + t.password, + t.review_reminder_attempts`, + maxReviewReminderAttempts, + ).Scan( + &job.TeamID, + &job.TeamName, + &job.GameID, + &job.GameName, + &job.Email, + &job.Password, + &job.Attempts, + ) return job, err } diff --git a/internal/services/answer_service/service_test.go b/internal/services/answer_service/service_test.go index 928a446..de110e8 100644 --- a/internal/services/answer_service/service_test.go +++ b/internal/services/answer_service/service_test.go @@ -28,6 +28,7 @@ func TestAnswerAccessPostgres(t *testing.T) { } defer pool.Close() _, err = pool.Exec(ctx, ` + CREATE TEMP TABLE game_returns(export_id TEXT); CREATE TEMP TABLE game_exports(id TEXT, game_id INT, state TEXT, package_id TEXT, size BIGINT, sha256 TEXT, failure TEXT, created_at TIMESTAMPTZ); CREATE TEMP TABLE games (id INT, name TEXT, description TEXT, location TEXT, start_at TIMESTAMP, format TEXT, status TEXT, scenario_id INT, started_at TIMESTAMP, ended_at TIMESTAMP); diff --git a/internal/services/game_export_service/return_test.go b/internal/services/game_export_service/return_test.go new file mode 100644 index 0000000..44ae23e --- /dev/null +++ b/internal/services/game_export_service/return_test.go @@ -0,0 +1,508 @@ +package game_export_service_test + +import ( + "archive/zip" + "bytes" + "context" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "github.com/jackc/pgx/v5" + "os" + "reflect" + "sync" + "testing" + "time" + + "evening_detective_server/internal/repos" + "evening_detective_server/internal/repos/game_map_repo" + "evening_detective_server/internal/repos/game_returns_repo" + "evening_detective_server/internal/repos/team_answers_repo" + "evening_detective_server/internal/repos/tickets_repo" + results "evening_detective_server/internal/services/game_results_package_service" +) + +type returnFixture struct { + serviceFixture + storage *memoryStorage + data results.Data + manifest results.Manifest +} + +func newReturnFixture(t *testing.T, format string) returnFixture { + t.Helper() + ctx := context.Background() + f := newServiceFixture(t) + check(t, f.games.UpdateGame(ctx, f.gameID, "Test game", "Description", nil, time.Now().Add(time.Hour), int32(f.scenarioID), format)) + check(t, f.scenarios.UpdateStoryByScenarioID(ctx, f.scenarioID, `{"places":[{"code":"p","name":"Place","text":"Text","applications":[{"name":"Clue","image":"original.png"}]}],"questions":[{"code":"q","text":"Who?","answer":"Butler"},{"code":"q2","text":"Why?","answer":"Money"}]}`)) + _, err := f.teams.AddTeam(ctx, f.userID, f.gameID, "Removed", "removed-qr") + check(t, err) + hash := sha256.Sum256([]byte("removed ticket")) + _, err = tickets_repo.New(f.pool).CreateBatch(ctx, f.gameID, [][]byte{hash[:]}) + check(t, err) + storage := &memoryStorage{objects: map[string][]byte{}} + exporter := f.service(storage) + _, err = exporter.Start(ctx, f.gameID, f.userID) + check(t, err) + _, err = exporter.ProcessNext(ctx) + check(t, err) + info, raw := download(t, exporter, f.gameID) + original, err := f.codec.Unpack(ctx, bytes.NewReader(raw), int64(len(raw)), t.TempDir()) + check(t, err) + defer func() { _ = os.RemoveAll(original.Dir) }() + story, _ := json.Marshal(original.Data.Scenario.Story) + storyHash := sha256.Sum256(story) + now := time.Now().UTC().Truncate(time.Microsecond) + end := now.Add(time.Hour) + sourceTeam := original.Data.Teams[0] + team := results.Team{Team: sourceTeam, SourceID: sourceTeam.ID} + team.ParticipationStatus = "confirmed" + local := f.userID + newTeam := team + newTeam.ID = original.Data.Teams[len(original.Data.Teams)-1].ID + 10 + newTeam.SourceID = 0 + newTeam.CreatorID = nil + newTeam.LocalCreatorID = &local + newTeam.Name = "Venue team" + newTeam.Password = "venue-printed-qr" + sourceTicket := original.Data.Tickets[0] + ticket := results.Ticket{Ticket: sourceTicket, SourceID: sourceTicket.ID, LocalUsedBy: &local} + ticket.Status = "used" + ticket.UsedAt = &now + newTicket := ticket + newTicket.ID = original.Data.Tickets[len(original.Data.Tickets)-1].ID + 10 + newTicket.SourceID = 0 + newHash := sha256.Sum256([]byte("venue ticket")) + newTicket.TokenHash = newHash[:] + material := results.Material{Name: "Clue", Image: original.Data.Scenario.Story.Places[0].Applications[0].Image, FileType: "image"} + score := 5 + return returnFixture{ + serviceFixture: f, + storage: storage, + manifest: results.Manifest{SourceServer: "https://game.example", GameID: f.gameID, SourcePackageID: info.PackageID, SourceSHA256: info.SHA256}, + data: results.Data{ + Source: results.Source{ScenarioID: f.scenarioID, ScenarioSHA256: hex.EncodeToString(storyHash[:]), Teams: original.Data.Teams, Tickets: original.Data.Tickets, Materials: []results.Material{material}, QuestionCodes: []string{"q", "q2"}}, + Game: results.Game{ID: f.gameID, Status: "finish", Format: format, StartedAt: &now, EndedAt: &end}, + Teams: []results.Team{team, newTeam}, Tickets: []results.Ticket{ticket, newTicket}, + DeletedTeamIDs: []int{original.Data.Teams[1].ID}, DeletedTicketIDs: []int{original.Data.Tickets[1].ID}, + Actions: []results.Action{{ID: 70, TeamID: team.ID, Code: "p", CreatedAt: now}, {ID: 71, TeamID: team.ID, Code: "p", ViaMCP: true, CreatedAt: now}}, + Applications: []results.Application{{ID: 80, TeamID: team.ID, Material: material, Status: "done", CreatedAt: now}}, + Answers: []results.Answer{{TeamID: team.ID, QuestionCode: "q", Text: "Butler", Score: &score, CreatedAt: now, UpdatedAt: now}, {TeamID: team.ID, QuestionCode: "q2", Text: "Money", CreatedAt: now, UpdatedAt: now}}, + }, + } +} +func (f returnFixture) pack(t *testing.T) []byte { + t.Helper() + var output bytes.Buffer + check(t, results.Pack(context.Background(), f.manifest, &f.data, &output)) + return output.Bytes() +} +func (f returnFixture) apply(raw []byte) (*game_returns_repo.Return, error) { + service := results.NewGameResultsApplyService(game_returns_repo.NewGameReturnsRepo(f.pool), f.storage, "prod", "https://game.example") + r, err := service.Apply(context.Background(), bytes.NewReader(raw), int64(len(raw)), f.userID) + if err != nil { + return nil, fmt.Errorf("%w (cause: %v)", err, errors.Unwrap(err)) + } + return r, nil +} + +func TestReturnAtomicLifecycle(t *testing.T) { + for _, format := range []string{"online", "offline"} { + t.Run(format, func(t *testing.T) { + ctx := context.Background() + f := newReturnFixture(t, format) + mapRepo := game_map_repo.NewGameMapRepo(f.pool) + check(t, mapRepo.Ensure(ctx, f.gameID, "team", f.data.DeletedTeamIDs[0])) + check(t, mapRepo.Ensure(ctx, f.gameID, "team", f.teamID)) + check(t, f.games.UpdateGame(ctx, f.gameID, "Online title", "Online description", nil, time.Now().Add(72*time.Hour), int32(f.scenarioID), format)) + token := hex.EncodeToString(bytes.Repeat([]byte{9}, 32)) + check(t, f.teams.SetParticipationToken(ctx, f.teamID, token)) + raw := f.pack(t) + first, err := f.apply(raw) + check(t, err) + participation, err := f.teams.GetParticipation(ctx, token) + check(t, err) + if participation.TeamID != f.teamID { + t.Fatal("mail token lost") + } + game, err := f.games.GetGameByID(ctx, f.gameID) + check(t, err) + if game.Export != nil || game.Status != "finish" || game.Name != "Online title" || game.Description != "Online description" { + t.Fatal("online presentation/status not preserved") + } + games, err := f.games.GetGames(ctx) + check(t, err) + if games[0].Export != nil { + t.Fatal("list export visible") + } + team, err := f.teams.GetTeamByID(ctx, f.data.Teams[1].ID) + check(t, err) + if team.Password != "venue-printed-qr" { + t.Fatal("venue QR changed") + } + _, err = f.teams.GetTeamByID(ctx, f.data.DeletedTeamIDs[0]) + if err == nil { + t.Fatal("deleted team remains") + } + items, err := mapRepo.List(ctx, f.gameID) + check(t, err) + if len(items) != 1 || items[0].ItemID != f.teamID { + t.Fatal("map relation orphaned") + } + ticket, err := tickets_repo.New(f.pool).GetByHash(ctx, f.data.Tickets[0].TokenHash) + check(t, err) + if ticket.ID != f.data.Tickets[0].ID || ticket.Status != "used" || ticket.UsedBy != nil { + t.Fatal("ticket identity or local actor wrong") + } + newTicket, err := tickets_repo.New(f.pool).GetByHash(ctx, f.data.Tickets[1].TokenHash) + check(t, err) + if newTicket.ID == f.data.Tickets[1].ID { + t.Fatal("venue ticket ID reused") + } + added, err := f.teams.AddTeam(ctx, f.userID, f.gameID, "Sequence check", "qr") + check(t, err) + if added <= f.data.Teams[1].ID { + t.Fatal("sequence not advanced") + } + check(t, f.teams.DeleteTeam(ctx, added)) + again, err := f.apply(raw) + check(t, err) + if !reflect.DeepEqual(first, again) { + t.Fatal("repeat changed result") + } + zero := 0 + f.data.Answers[0].Score = &zero + corrected := f.pack(t) + second, err := f.apply(corrected) + check(t, err) + if !second.PreparedAt.After(first.PreparedAt) { + t.Fatal("package order") + } + answers := team_answers_repo.NewTeamAnswersRepo(f.pool) + stored, err := answers.GetAnswersByTeamID(ctx, f.teamID) + check(t, err) + if len(stored) != 2 || stored[0].Score == nil || *stored[0].Score != 0 || stored[1].Score != nil { + t.Fatal("scores recalculated or null lost") + } + old, err := f.apply(raw) + check(t, err) + if !old.AppliedAt.Equal(first.AppliedAt) { + t.Fatal("old repeat changed") + } + later := f.pack(t) + check(t, answers.SetAnswerScore(ctx, f.teamID, "q", nil)) + _, err = f.apply(later) + check(t, err) + stored, err = answers.GetAnswersByTeamID(ctx, f.teamID) + check(t, err) + if stored[0].Score == nil || *stored[0].Score != 0 { + t.Fatal("online grading was not overwritten") + } + // The same ID with changed archive bytes conflicts before any source access. + modified := append(bytes.Clone(raw), []byte("extra")...) + _, err = f.apply(modified) + if !errors.Is(err, results.ErrReturnConflict) { + t.Fatalf("changed bytes: %v", err) + } + // Registry survives game deletion and supports old repeats. + teams, err := f.teams.GetTeamsByGameID(ctx, f.gameID) + check(t, err) + for _, team := range teams { + check(t, f.teams.DeleteTeam(ctx, team.ID)) + } + check(t, f.games.DeleteGameByID(ctx, f.gameID)) + _, err = f.apply(raw) + check(t, err) + _, err = f.apply(f.pack(t)) + if !errors.Is(err, results.ErrSourceMismatch) { + t.Fatal(err) + } + }) + } +} + +func TestReturnRollbackOnCollision(t *testing.T) { + for _, kind := range []string{"team ID", "ticket hash", "source teams", "source story", "original ZIP", "source materials", "source questions", "source tickets", "format", "team overflow"} { + t.Run(kind, func(t *testing.T) { + ctx := context.Background() + f := newReturnFixture(t, "online") + switch kind { + case "team ID": + foreign, err := f.games.AddGame(ctx, "Other", "", nil, time.Now(), int32(f.scenarioID), "online") + check(t, err) + id, err := f.teams.AddTeam(ctx, f.userID, foreign, "Other team", "qr") + check(t, err) + f.data.Teams[1].ID = id + case "ticket hash": + foreign, err := f.games.AddGame(ctx, "Other", "", nil, time.Now(), int32(f.scenarioID), "online") + check(t, err) + _, err = tickets_repo.New(f.pool).CreateBatch(ctx, foreign, [][]byte{f.data.Tickets[1].TokenHash}) + check(t, err) + case "source teams": + f.data.Source.Teams[0].Name = "Forged source" + f.data.Teams[0].Name = "Forged source" + case "source story": + f.data.Source.ScenarioSHA256 = hex.EncodeToString(make([]byte, 32)) + case "format": + f.data.Game.Format = "offline" + case "team overflow": + f.data.Teams[1].ID = 2147483647 + case "source materials": + f.data.Source.Materials[0].Name = "Forged clue" + f.data.Applications[0].Name = "Forged clue" + case "source questions": + f.data.Source.QuestionCodes[1] = "forged" + f.data.Answers[1].QuestionCode = "forged" + case "source tickets": + f.data.Source.Tickets[0].TokenHash = bytes.Repeat([]byte{11}, 32) + f.data.Tickets[0].TokenHash = f.data.Source.Tickets[0].TokenHash + case "original ZIP": + for key := range f.storage.objects { + f.storage.objects[key] = []byte("corrupt") + } + } + raw := f.pack(t) + _, err := f.apply(raw) + expected := results.ErrReturnConflict + if kind == "source teams" || kind == "source story" || kind == "original ZIP" || kind == "source materials" || kind == "source questions" || kind == "source tickets" || kind == "format" { + expected = results.ErrSourceMismatch + } + if !errors.Is(err, expected) { + t.Fatalf("expected %v got %v", expected, err) + } + game, err := f.games.GetGameByID(ctx, f.gameID) + check(t, err) + if game.Status != "draft" || game.Export == nil { + t.Fatal("partial application") + } + originalHash := sha256.Sum256([]byte("ticket")) + ticket, err := tickets_repo.New(f.pool).GetByHash(ctx, originalHash[:]) + check(t, err) + if ticket.Status != "active" { + t.Fatal("ticket partially applied") + } + _, err = game_returns_repo.NewGameReturnsRepo(f.pool).FindReturn(ctx, resultID(t, raw)) + if err == nil { + t.Fatal("failed return recorded") + } + }) + } +} + +func resultID(t *testing.T, raw []byte) string { + t.Helper() + b, err := results.Unpack(context.Background(), bytes.NewReader(raw), int64(len(raw))) + check(t, err) + return b.Manifest.PackageID +} + +func TestReturnConcurrentRepeat(t *testing.T) { + for _, name := range []string{"concurrent same package"} { + t.Run(name, func(t *testing.T) { + f := newReturnFixture(t, "online") + raw := f.pack(t) + var workers sync.WaitGroup + failures := make(chan error, 2) + times := make(chan time.Time, 2) + for i := 0; i < 2; i++ { + workers.Add(1) + go func() { + defer workers.Done() + r, err := f.apply(raw) + if err != nil { + failures <- err + } else { + times <- r.AppliedAt + } + }() + } + workers.Wait() + close(failures) + close(times) + for err := range failures { + t.Fatal(err) + } + var first time.Time + for timestamp := range times { + if first.IsZero() { + first = timestamp + } else if !first.Equal(timestamp) { + t.Fatal("repeat applied twice") + } + } + }) + } +} + +// rewriteManifest keeps valid result bytes while testing ordering and identity checks. +func rewriteManifest(t *testing.T, raw []byte, change func(*results.Manifest)) []byte { + t.Helper() + reader, err := zip.NewReader(bytes.NewReader(raw), int64(len(raw))) + check(t, err) + var output bytes.Buffer + writer := zip.NewWriter(&output) + for _, entry := range reader.File { + stream, err := entry.Open() + check(t, err) + var content bytes.Buffer + _, err = content.ReadFrom(stream) + check(t, err) + check(t, stream.Close()) + data := content.Bytes() + if entry.Name == "manifest.json" { + var manifest results.Manifest + check(t, json.Unmarshal(data, &manifest)) + change(&manifest) + data, err = json.Marshal(manifest) + check(t, err) + } + file, err := writer.Create(entry.Name) + check(t, err) + _, err = file.Write(data) + check(t, err) + } + check(t, writer.Close()) + return output.Bytes() +} + +func TestReturnPackageOrdering(t *testing.T) { + for _, kind := range []string{"equal", "older", "other server", "wrong export hash", "wrong export ID", "one ns later"} { + t.Run(kind, func(t *testing.T) { + f := newReturnFixture(t, "online") + raw := f.pack(t) + first, err := f.apply(raw) + check(t, err) + newer := f.pack(t) + modified := rewriteManifest(t, newer, func(m *results.Manifest) { + switch kind { + case "one ns later": + m.CreatedAt = first.PreparedAt.Add(time.Nanosecond) + case "equal": + m.CreatedAt = first.PreparedAt + case "older": + m.CreatedAt = first.PreparedAt.Add(-time.Hour) + case "other server": + m.SourceServer = "https://other.example" + case "wrong export hash": + m.SourceSHA256 = hex.EncodeToString(make([]byte, 32)) + case "wrong export ID": + m.SourcePackageID = hex.EncodeToString(make([]byte, 16)) + } + }) + _, err = f.apply(modified) + expected := results.ErrSourceMismatch + if kind == "one ns later" { + expected = nil + } + if kind == "equal" || kind == "older" { + expected = results.ErrReturnConflict + } + if !errors.Is(err, expected) { + t.Fatal(err) + } + }) + } +} + +func TestReturnCreatesNoMailJobs(t *testing.T) { + for _, name := range []string{"returned game has no reminder or participation jobs"} { + t.Run(name, func(t *testing.T) { + ctx := context.Background() + f := newReturnFixture(t, "online") + start := time.Now().Add(-24 * time.Hour).UTC() + end := start.Add(time.Hour) + f.data.Game.StartedAt = &start + f.data.Game.EndedAt = &end + for i := range f.data.Teams { + f.data.Teams[i].CaptainEmail = "captain@test.invalid" + } + _, err := f.apply(f.pack(t)) + check(t, err) + _, err = f.teams.ClaimReviewReminder(ctx) + if !errors.Is(err, pgx.ErrNoRows) { + t.Fatalf("review mail created: %v", err) + } + _, err = f.teams.ClaimParticipationEmail(ctx) + if !errors.Is(err, pgx.ErrNoRows) { + t.Fatalf("participation mail created: %v", err) + } + }) + } +} + +func TestReturnOverwritesOnlineEditsAndIsolatesScenario(t *testing.T) { + for _, kind := range []string{"shared scenario edited", "different scenario selected"} { + t.Run(kind, func(t *testing.T) { + ctx := context.Background() + f := newReturnFixture(t, "online") + selected := f.scenarioID + if kind == "different scenario selected" { + var err error + selected, err = f.scenarios.AddScenario(ctx, "Online scenario", f.userID) + check(t, err) + } + changedStory := `{"places":[],"questions":[]}` + check(t, f.scenarios.UpdateStoryByScenarioID(ctx, selected, changedStory)) + other, err := f.games.AddGame(ctx, "Other game", "", nil, time.Now(), int32(selected), "offline") + check(t, err) + check(t, f.games.UpdateGame(ctx, f.gameID, "Online title", "Online description", nil, time.Now(), int32(selected), "offline")) + check(t, f.teams.UpdateTeam(ctx, f.teamID, "Online team name")) + extraTeam, err := f.teams.AddTeam(ctx, f.userID, f.gameID, "Online extra team", "online-qr") + check(t, err) + err = team_answers_repo.NewTeamAnswersRepo(f.pool).UpsertAnswers(ctx, f.teamID, []repos.TeamAnswer{{QuestionCode: "q", Text: "Online answer"}}) + check(t, err) + raw := f.pack(t) + first, err := f.apply(raw) + check(t, err) + game, err := f.games.GetGameByID(ctx, f.gameID) + check(t, err) + if game.ScenarioID == selected || game.Format != "online" || game.Name != "Online title" || game.Description != "Online description" { + t.Fatal("scenario/format/presentation not restored") + } + story, err := f.scenarios.GetStoryByScenarioID(ctx, int(game.ScenarioID)) + check(t, err) + if !bytes.Contains([]byte(story), []byte("Butler")) || !bytes.Contains([]byte(story), []byte("original.png")) || bytes.Contains([]byte(story), []byte("package://")) { + t.Fatal("original questions/material links not restored") + } + otherGame, err := f.games.GetGameByID(ctx, other) + check(t, err) + otherStory, err := f.scenarios.GetStoryByScenarioID(ctx, int(otherGame.ScenarioID)) + check(t, err) + var expected, actual any + check(t, json.Unmarshal([]byte(changedStory), &expected)) + check(t, json.Unmarshal([]byte(otherStory), &actual)) + if !reflect.DeepEqual(expected, actual) { + t.Fatal("shared scenario changed") + } + team, err := f.teams.GetTeamByID(ctx, f.teamID) + check(t, err) + if team.Name != f.data.Teams[0].Name { + t.Fatal("online team edit remains") + } + if _, err := f.teams.GetTeamByID(ctx, extraTeam); err == nil { + t.Fatal("extra online team remains") + } + stored, err := team_answers_repo.NewTeamAnswersRepo(f.pool).GetAnswersByTeamID(ctx, f.teamID) + check(t, err) + if len(stored) != 2 || stored[0].Text != "Butler" { + t.Fatal("online answers not overwritten") + } + _, err = f.apply(f.pack(t)) + check(t, err) + updated, err := f.games.GetGameByID(ctx, f.gameID) + check(t, err) + if updated.ScenarioID != game.ScenarioID { + t.Fatal("unchanged scenario cloned again") + } + var mappings struct { + Scenario int `json:"scenario"` + } + check(t, json.Unmarshal(first.Mappings, &mappings)) + if mappings.Scenario != int(game.ScenarioID) { + t.Fatal("scenario mapping missing") + } + }) + } +} diff --git a/internal/services/game_results_package_service/apply.go b/internal/services/game_results_package_service/apply.go new file mode 100644 index 0000000..65ff8c8 --- /dev/null +++ b/internal/services/game_results_package_service/apply.go @@ -0,0 +1,309 @@ +package game_results_package_service + +import ( + "context" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "errors" + "io" + "os" + + "evening_detective_server/internal/modules/file_storage" + "evening_detective_server/internal/repos/game_returns_repo" + "evening_detective_server/internal/services/game_package_service" + "github.com/jackc/pgx/v5" +) + +var ( + ErrReturnNotAvailable = errors.New("возврат результатов доступен только на онлайн-сервере") + ErrSourceMismatch = errors.New("пакет не соответствует исходному экспорту") + ErrReturnConflict = game_returns_repo.ErrConflict + ErrReturnRetry = game_returns_repo.ErrRetry + ErrReturnFailed = errors.New("не удалось применить результаты") +) + +// GameResultsApplyService applies private result archives; transport is added separately. +type GameResultsApplyService struct { + repo *game_returns_repo.GameReturnsRepo + storage file_storage.IStreamStorage + mode string + source string +} + +func NewGameResultsApplyService(repo *game_returns_repo.GameReturnsRepo, storage file_storage.IStreamStorage, mode, source string) *GameResultsApplyService { + return &GameResultsApplyService{ + repo: repo, + storage: storage, + mode: mode, + source: source, + } +} + +// Apply preserves recorded repeats, including older packages and deleted games. +func (s *GameResultsApplyService) Apply(ctx context.Context, input io.ReaderAt, size int64, initiatorID int) (*game_returns_repo.Return, error) { + if err := ctx.Err(); err != nil { + return nil, err + } + if s.mode != "prod" || s.repo == nil || s.storage == nil { + return nil, ErrReturnNotAvailable + } + bundle, digest, err := readResultInput(ctx, input, size) + if err != nil { + return nil, safeReturnError(ctx, err) + } + result, err := s.apply(ctx, bundle, digest, initiatorID) + if err == nil || errors.Is(err, ErrSourceMismatch) || errors.Is(err, ErrReturnConflict) || errors.Is(err, ErrReturnRetry) || errors.Is(err, ErrInvalidPackage) { + return result, err + } + if ctx.Err() != nil { + return nil, ctx.Err() + } + return nil, &returnFailure{cause: err} +} + +type returnFailure struct{ cause error } + +func (e *returnFailure) Error() string { return ErrReturnFailed.Error() } +func (e *returnFailure) Unwrap() error { return e.cause } +func (e *returnFailure) Is(target error) bool { return target == ErrReturnFailed } + +func (s *GameResultsApplyService) apply(ctx context.Context, bundle *Bundle, digest string, initiatorID int) (*game_returns_repo.Return, error) { + saved, err := s.repo.FindReturn(ctx, bundle.Manifest.PackageID) + if err == nil { + return repeatReturn(saved, digest) + } + if !errors.Is(err, pgx.ErrNoRows) { + return nil, err + } + export, err := s.repo.ReadExport(ctx, bundle.Manifest.SourcePackageID) + if errors.Is(err, game_returns_repo.ErrSource) { + return nil, ErrSourceMismatch + } + if err != nil { + return nil, err + } + m := bundle.Manifest + if m.SourceServer != s.source || m.SourceServer != export.SourceServer || m.SourceSHA256 != export.SHA256 || m.GameID != export.GameID { + return nil, ErrSourceMismatch + } + var snapshot game_package_service.Data + if err := json.Unmarshal(export.Snapshot, &snapshot); err != nil { + return nil, err + } + original, err := s.readOriginal(ctx, export.ObjectKey, export.Size, export.SHA256) + if err != nil { + return nil, err + } + defer func() { _ = os.RemoveAll(original.Dir) }() + if original.Manifest.PackageID != m.SourcePackageID || original.Manifest.SourceServer != m.SourceServer || original.Manifest.GameID != m.GameID { + return nil, ErrSourceMismatch + } + source, err := sourceFromOriginal(&original.Data) + if err != nil { + return nil, ErrSourceMismatch + } + if original.Data.Game.Format != bundle.Data.Game.Format || !sameJSON(source, bundle.Data.Source) { + return nil, ErrSourceMismatch + } + materials, err := originalMaterials(&original.Data, &snapshot) + if err != nil { + return nil, err + } + return s.repo.Transaction(ctx, func(tx pgx.Tx) (*game_returns_repo.Return, error) { + saved, err := game_returns_repo.ReadReturn(ctx, tx, m.PackageID) + if err == nil { + return repeatReturn(saved, digest) + } + if !errors.Is(err, pgx.ErrNoRows) { + return nil, err + } + if err := game_returns_repo.Lock(ctx, tx, m.GameID, export.ID); err != nil { + if errors.Is(err, game_returns_repo.ErrSource) { + return nil, ErrSourceMismatch + } + return nil, err + } + latest, err := game_returns_repo.ReadLatest(ctx, tx, export.ID) + if err != nil && !errors.Is(err, pgx.ErrNoRows) { + return nil, err + } + if err == nil && !m.CreatedAt.After(latest.PreparedAt) { + return nil, ErrReturnConflict + } + current, err := game_returns_repo.Capture(ctx, tx, m.GameID) + if err != nil { + return nil, err + } + mappings := newMappings() + if latest != nil { + if err := json.Unmarshal(latest.Mappings, &mappings); err != nil { + return nil, err + } + } + if err := allocateReturnIDs(ctx, tx, &bundle.Data, &mappings); err != nil { + return nil, err + } + data, err := mapReturnData(&bundle.Data, current, materials, &mappings) + if err != nil { + return nil, err + } + raw, err := json.Marshal(data) + if err != nil { + return nil, err + } + if err := game_returns_repo.Write(ctx, tx, m.GameID, raw); err != nil { + return nil, err + } + story, err := json.Marshal(snapshot.Scenario.Story) + if err != nil { + return nil, err + } + mappings.Scenario = current.ScenarioID + if !sameRawJSON(current.Story, story) { + mappings.Scenario, err = game_returns_repo.RestoreStory(ctx, tx, m.GameID, story) + if err != nil { + return nil, err + } + } + ids, err := json.Marshal(mappings) + if err != nil { + return nil, err + } + actors, err := json.Marshal(localActors(&bundle.Data)) + if err != nil { + return nil, err + } + result := &game_returns_repo.Return{ + PackageID: m.PackageID, + SHA256: digest, + ExportID: export.ID, + GameID: m.GameID, + PreparedAt: m.CreatedAt.UTC(), + Mappings: ids, + LocalActors: actors, + InitiatorID: initiatorID, + } + if err := game_returns_repo.Save(ctx, tx, result); err != nil { + return nil, err + } + return game_returns_repo.ReadReturn(ctx, tx, m.PackageID) + }) +} + +func repeatReturn(saved *game_returns_repo.Return, digest string) (*game_returns_repo.Return, error) { + if saved.SHA256 != digest { + return nil, ErrReturnConflict + } + return saved, nil +} + +func (s *GameResultsApplyService) readOriginal(ctx context.Context, key string, size int64, digest string) (*game_package_service.Bundle, error) { + if size <= 0 || size > game_package_service.MaxArchiveSize { + return nil, ErrSourceMismatch + } + body, err := s.storage.Open(ctx, key) + if err != nil { + return nil, err + } + defer func() { _ = body.Close() }() + file, err := os.CreateTemp("", "game-return-*.zip") + if err != nil { + return nil, err + } + defer func() { _ = file.Close(); _ = os.Remove(file.Name()) }() + hash := sha256.New() + n, err := io.Copy(io.MultiWriter(file, hash), io.LimitReader(&contextReader{ctx: ctx, reader: body}, size+1)) + if err != nil { + return nil, err + } + if n != size || hex.EncodeToString(hash.Sum(nil)) != digest { + return nil, ErrSourceMismatch + } + original, err := game_package_service.NewGamePackageService("").Unpack(ctx, file, size, "") + if err != nil { + if ctx.Err() != nil { + return nil, ctx.Err() + } + return nil, ErrSourceMismatch + } + return original, nil +} + +func reserveID(ctx context.Context, tx pgx.Tx, kind string, localID int, ids map[int]int) (int, error) { + if id := ids[localID]; id != 0 { + return id, nil + } + id, err := game_returns_repo.Allocate(ctx, tx, kind) + if err != nil { + return 0, err + } + ids[localID] = id + return id, nil +} + +func allocateReturnIDs(ctx context.Context, tx pgx.Tx, data *Data, ids *idMappings) error { + for _, ticket := range data.Tickets { + if ticket.SourceID != 0 { + ids.Tickets[ticket.ID] = ticket.SourceID + continue + } + if _, err := reserveID(ctx, tx, "tickets", ticket.ID, ids.Tickets); err != nil { + return err + } + } + // Fresh progress IDs preserve the package order even when earlier rows reappear. + ids.Actions = map[int]int{} + ids.Applications = map[int]int{} + for _, action := range data.Actions { + if _, err := reserveID(ctx, tx, "actions", action.ID, ids.Actions); err != nil { + return err + } + } + for _, application := range data.Applications { + if _, err := reserveID(ctx, tx, "applications", application.ID, ids.Applications); err != nil { + return err + } + } + return nil +} + +func safeReturnError(ctx context.Context, err error) error { + if ctx.Err() != nil { + return ctx.Err() + } + for _, sentinel := range []error{ErrInvalidPackage, ErrUnsupportedVersion, ErrLimitExceeded} { + if errors.Is(err, sentinel) { + return sentinel + } + } + return &returnFailure{cause: err} +} + +// Stage once so the validated payload and idempotency digest always describe the same bytes. +func readResultInput(ctx context.Context, input io.ReaderAt, size int64) (*Bundle, string, error) { + if input == nil || size <= 0 { + return nil, "", ErrInvalidPackage + } + if size > game_package_service.MaxArchiveSize { + return nil, "", ErrLimitExceeded + } + file, err := os.CreateTemp("", "game-return-results-*.zip") + if err != nil { + return nil, "", err + } + defer func() { _ = file.Close(); _ = os.Remove(file.Name()) }() + hash := sha256.New() + n, err := io.Copy(io.MultiWriter(file, hash), &contextReader{ctx: ctx, reader: io.NewSectionReader(input, 0, size)}) + if err != nil { + return nil, "", err + } + if n != size { + return nil, "", ErrInvalidPackage + } + bundle, err := Unpack(ctx, file, size) + if err != nil { + return nil, "", err + } + return bundle, hex.EncodeToString(hash.Sum(nil)), nil +} diff --git a/internal/services/game_results_package_service/apply_mapper.go b/internal/services/game_results_package_service/apply_mapper.go new file mode 100644 index 0000000..d3715c8 --- /dev/null +++ b/internal/services/game_results_package_service/apply_mapper.go @@ -0,0 +1,105 @@ +package game_results_package_service + +import ( + "encoding/json" + + "evening_detective_server/internal/repos/game_results_repo" +) + +type idMappings struct { + Scenario int `json:"scenario"` + Teams map[int]int `json:"teams"` + Tickets map[int]int `json:"tickets"` + Actions map[int]int `json:"actions"` + Applications map[int]int `json:"applications"` +} + +func newMappings() idMappings { + return idMappings{ + Teams: map[int]int{}, + Tickets: map[int]int{}, + Actions: map[int]int{}, + Applications: map[int]int{}, + } +} + +type actors struct { + Creators map[int]int `json:"creators"` + TicketUsers map[int]int `json:"ticket_users"` +} + +func localActors(data *Data) actors { + result := actors{ + Creators: map[int]int{}, + TicketUsers: map[int]int{}, + } + for _, team := range data.Teams { + if team.LocalCreatorID != nil { + result.Creators[team.ID] = *team.LocalCreatorID + } + } + for _, ticket := range data.Tickets { + if ticket.LocalUsedBy != nil { + result.TicketUsers[ticket.ID] = *ticket.LocalUsedBy + } + } + return result +} + +// mapReturnData keeps credentials and online identities; local actors remain only in the registry. +func mapReturnData(input *Data, current *game_results_repo.Snapshot, materials map[Material]Material, ids *idMappings) (*Data, error) { + raw, err := json.Marshal(input) + if err != nil { + return nil, err + } + var data, online Data + if err := json.Unmarshal(raw, &data); err != nil { + return nil, err + } + if err := json.Unmarshal(current.Data, &online); err != nil { + return nil, err + } + for i := range data.Teams { + team := &data.Teams[i] + ids.Teams[team.ID] = team.ID + team.LocalCreatorID = nil + for _, existing := range online.Teams { + if existing.ID == team.ID { + team.CreatorID = existing.CreatorID + team.RegistrationDeliveryID = existing.RegistrationDeliveryID + break + } + } + } + for i := range data.Tickets { + ticket := &data.Tickets[i] + localID := ticket.ID + if ticket.SourceID != 0 { + ticket.ID = ticket.SourceID + } else { + ticket.ID = ids.Tickets[localID] + } + ids.Tickets[localID] = ticket.ID + ticket.LocalUsedBy = nil + for _, existing := range online.Tickets { + if existing.ID == ticket.ID && ticket.Status == "used" { + ticket.UsedBy = existing.UsedBy + break + } + } + } + for i := range data.Actions { + action := &data.Actions[i] + action.ID = ids.Actions[action.ID] + } + for i := range data.Applications { + application := &data.Applications[i] + material, ok := materials[application.Material] + if !ok { + return nil, ErrSourceMismatch + } + application.Material = material + application.ID = ids.Applications[application.ID] + } + return &data, nil +} diff --git a/internal/services/game_results_package_service/apply_test.go b/internal/services/game_results_package_service/apply_test.go new file mode 100644 index 0000000..f71eadb --- /dev/null +++ b/internal/services/game_results_package_service/apply_test.go @@ -0,0 +1,249 @@ +package game_results_package_service + +import ( + "bytes" + "context" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "errors" + "io" + "os" + "strings" + "testing" + + "evening_detective_server/internal/repos/game_results_repo" + "evening_detective_server/internal/repos/game_returns_repo" + "evening_detective_server/internal/services/game_package_service" +) + +func marshalTest(t *testing.T, value any) []byte { + t.Helper() + raw, err := json.Marshal(value) + if err != nil { + t.Fatal(err) + } + return raw +} + +func TestReturnMappingPreservesOnlineActorsAndMaterial(t *testing.T) { + for _, name := range []string{"actors and nullable scores"} { + t.Run(name, func(t *testing.T) { + input := sampleData("online") + source := input.Source.Materials[0] + onlineMaterial := source + onlineMaterial.Image = "original.pdf" + online := *input + online.Teams = append([]Team(nil), input.Teams...) + owner := 44 + online.Teams[0].CreatorID = &owner + online.Tickets = append([]Ticket(nil), input.Tickets...) + online.Tickets[0].UsedBy = &owner + current := &game_results_repo.Snapshot{Data: marshalTest(t, online)} + ids := newMappings() + ids.Tickets[31] = 300 + ids.Actions[1] = 100 + ids.Actions[2] = 101 + ids.Applications[1] = 200 + // Assign every application regardless of the sample's local ID. + for _, a := range input.Applications { + ids.Applications[a.ID] = 200 + } + mapped, err := mapReturnData(input, current, map[Material]Material{source: onlineMaterial}, &ids) + if err != nil { + t.Fatal(err) + } + if *mapped.Teams[0].CreatorID != owner || mapped.Teams[1].CreatorID != nil || mapped.Teams[1].LocalCreatorID != nil || mapped.Teams[1].ID != input.Teams[1].ID || mapped.Teams[1].Password != input.Teams[1].Password { + t.Fatal("team identity lost") + } + if *mapped.Tickets[0].UsedBy != owner || mapped.Tickets[1].UsedBy != nil || mapped.Tickets[1].LocalUsedBy != nil || mapped.Tickets[1].ID != 300 { + t.Fatal("ticket actors mixed") + } + if mapped.Applications[0].Image != "original.pdf" || mapped.Answers[0].Score == nil || *mapped.Answers[0].Score != 0 || mapped.Answers[1].Score != nil { + t.Fatal("material or nullable score lost") + } + if input.Applications[0].Image != source.Image { + t.Fatal("input mutated") + } + actorData := localActors(input) + if len(actorData.Creators) != 1 || len(actorData.TicketUsers) != 2 { + t.Fatal("local actors missing from registry") + } + }) + } +} + +func TestRepeatReturnRejectsChangedBytes(t *testing.T) { + for _, tc := range []struct { + name, digest string + conflict bool + }{{"same bytes", "same", false}, {"different bytes", "different", true}} { + t.Run(tc.name, func(t *testing.T) { + saved := &game_returns_repo.Return{SHA256: "same"} + result, err := repeatReturn(saved, tc.digest) + if errors.Is(err, ErrReturnConflict) != tc.conflict || (!tc.conflict && result != saved) { + t.Fatal(err) + } + }) + } +} + +func TestApplyModeAndSafeErrors(t *testing.T) { + for _, mode := range []string{"venue", "", "unknown"} { + t.Run(mode, func(t *testing.T) { + _, err := NewGameResultsApplyService(nil, nil, mode, "").Apply(context.Background(), nil, 0, 0) + if !errors.Is(err, ErrReturnNotAvailable) { + t.Fatal(err) + } + }) + } + for _, name := range []string{"internal cause hidden"} { + t.Run(name, func(t *testing.T) { + cause := errors.New("SQL private password token") + err := &returnFailure{cause: cause} + if !errors.Is(err, ErrReturnFailed) || !errors.Is(err, cause) || strings.Contains(err.Error(), "SQL") { + t.Fatal(err) + } + }) + } +} + +func TestSafeReturnErrorHidesInternalDetails(t *testing.T) { + for _, tc := range []struct { + name string + cause error + expected error + }{ + {"invalid reader", errors.Join(ErrInvalidPackage, errors.New("private path and password")), ErrInvalidPackage}, + {"unknown reader", errors.New("private path and password"), ErrReturnFailed}, + {"unsupported", ErrUnsupportedVersion, ErrUnsupportedVersion}, + {"limit", ErrLimitExceeded, ErrLimitExceeded}, + } { + t.Run(tc.name, func(t *testing.T) { + err := safeReturnError(context.Background(), tc.cause) + if !errors.Is(err, tc.expected) || strings.Contains(err.Error(), "private") { + t.Fatal(err) + } + }) + } + for _, name := range []string{"cancelled"} { + t.Run(name, func(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + cancel() + if err := safeReturnError(ctx, errors.New("private")); !errors.Is(err, context.Canceled) { + t.Fatal(err) + } + }) + } +} + +func TestOriginalMaterialsRestoresSavedURLs(t *testing.T) { + for _, link := range []string{"saved.pdf", "https://example.invalid/clue.pdf"} { + t.Run(link, func(t *testing.T) { + saved := storedSample(t) + var portable, online game_package_service.Data + if err := json.Unmarshal(saved.OriginalData, &portable); err != nil { + t.Fatal(err) + } + if err := json.Unmarshal(saved.OriginalData, &online); err != nil { + t.Fatal(err) + } + online.Scenario.Story.Places[0].Applications[0].Image = link + materials, err := originalMaterials(&portable, &online) + if err != nil { + t.Fatal(err) + } + source, err := sourceFromOriginal(&portable) + if err != nil { + t.Fatal(err) + } + restored := materials[source.Materials[0]] + if restored.Image != link || restored.FileType != "pdf" { + t.Fatal("saved URL/type lost") + } + }) + } +} + +type inspectedResultReader struct { + reader io.ReaderAt + inspect func() + fail error +} + +func (r *inspectedResultReader) ReadAt(raw []byte, offset int64) (int, error) { + if r.inspect != nil { + r.inspect() + r.inspect = nil + } + if r.fail != nil { + return 0, r.fail + } + return r.reader.ReadAt(raw, offset) +} + +func TestResultStagingIsPrivateAndAlwaysRemoved(t *testing.T) { + readerFailure := errors.New("private reader failure") + for _, tc := range []struct { + name string + malformed, cancelled bool + failure, expected error + }{ + {"success", false, false, nil, nil}, + {"invalid ZIP", true, false, nil, ErrInvalidPackage}, + {"reader failure", false, false, readerFailure, readerFailure}, + {"cancelled", false, true, nil, context.Canceled}, + } { + t.Run(tc.name, func(t *testing.T) { + directory := t.TempDir() + t.Setenv("TMPDIR", directory) + var output bytes.Buffer + if err := Pack(context.Background(), sampleManifest(), sampleData("online"), &output); err != nil { + t.Fatal(err) + } + raw := output.Bytes() + if tc.malformed { + raw = []byte("invalid ZIP") + } + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + if tc.cancelled { + cancel() + } + reader := &inspectedResultReader{ + reader: bytes.NewReader(raw), + fail: tc.failure, + inspect: func() { + files, err := os.ReadDir(directory) + if err != nil { + t.Fatal(err) + } + if len(files) != 1 { + t.Fatal("staging file missing") + } + info, err := files[0].Info() + if err != nil { + t.Fatal(err) + } + if info.Mode().Perm() != 0600 { + t.Fatal("staging permissions too broad") + } + }, + } + bundle, digest, err := readResultInput(ctx, reader, int64(len(raw))) + if !errors.Is(err, tc.expected) { + t.Fatal(err) + } + files, readErr := os.ReadDir(directory) + if readErr != nil || len(files) != 0 { + t.Fatal("staging file leaked") + } + if err == nil { + hash := sha256.Sum256(raw) + if bundle.Data.Game.ID != 1 || digest != hex.EncodeToString(hash[:]) { + t.Fatal("staged payload/digest mismatch") + } + } + }) + } +} diff --git a/internal/services/game_results_package_service/apply_validation.go b/internal/services/game_results_package_service/apply_validation.go new file mode 100644 index 0000000..ae8d5e5 --- /dev/null +++ b/internal/services/game_results_package_service/apply_validation.go @@ -0,0 +1,73 @@ +package game_results_package_service + +import ( + "encoding/json" + "reflect" + + "evening_detective_server/internal/modules/cleaner" + "evening_detective_server/internal/modules/file_storage" + "evening_detective_server/internal/services/game_package_service" +) + +func sameJSON(a, b any) bool { + left, err := json.Marshal(a) + if err != nil { + return false + } + right, err := json.Marshal(b) + if err != nil { + return false + } + var x, y any + if json.Unmarshal(left, &x) != nil || json.Unmarshal(right, &y) != nil { + return false + } + return reflect.DeepEqual(x, y) +} + +func sameRawJSON(a, b []byte) bool { + var x, y any + if json.Unmarshal(a, &x) != nil || json.Unmarshal(b, &y) != nil { + return false + } + return reflect.DeepEqual(x, y) +} + +// originalMaterials pairs portable evidence with the immutable online export snapshot. +func originalMaterials(portable, online *game_package_service.Data) (map[Material]Material, error) { + result := map[Material]Material{} + if portable.Scenario.Story == nil || online.Scenario.Story == nil || len(portable.Scenario.Story.Places) != len(online.Scenario.Story.Places) { + return nil, ErrSourceMismatch + } + clean := cleaner.NewCleaner() + for i, place := range portable.Scenario.Story.Places { + other := online.Scenario.Story.Places[i] + if place == nil || other == nil || len(place.Applications) != len(other.Applications) { + return nil, ErrSourceMismatch + } + for j, application := range place.Applications { + original := other.Applications[j] + if application == nil || original == nil { + return nil, ErrSourceMismatch + } + from := Material{ + Name: clean.ClearText(application.Name), + Image: application.Image, + FileType: application.FileType, + } + if from.FileType == "" { + from.FileType = file_storage.FileType(from.Image) + } + to := Material{ + Name: clean.ClearText(original.Name), + Image: original.Image, + FileType: from.FileType, + } + if previous, ok := result[from]; ok && previous != to { + return nil, ErrSourceMismatch + } + result[from] = to + } + } + return result, nil +} diff --git a/internal/services/game_results_package_service/flow_apply_test.go b/internal/services/game_results_package_service/flow_apply_test.go new file mode 100644 index 0000000..ad296b1 --- /dev/null +++ b/internal/services/game_results_package_service/flow_apply_test.go @@ -0,0 +1,143 @@ +package game_results_package_service + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "os" + "path/filepath" + "strconv" + "testing" + "time" + + "evening_detective_server/internal/modules/file_storage" + "evening_detective_server/internal/repos/game_returns_repo" + "evening_detective_server/internal/repos/games_repo" + "evening_detective_server/internal/repos/team_answers_repo" + "evening_detective_server/internal/services/game_package_service" + "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/pgxpool" +) + +// TestProdFlowReturns applies the actual venue ZIP to the isolated prod DB and private RustFS. +func TestProdFlowReturns(t *testing.T) { + for _, name := range []string{"real result archive and restart repeats"} { + t.Run(name, func(t *testing.T) { + idText := os.Getenv("ED029_RETURN_GAME_ID") + if idText == "" { + t.Skip("run python3 tests/venue/flow.py --apply") + } + gameID, err := strconv.Atoi(idText) + if err != nil { + t.Fatal(err) + } + ctx := context.Background() + pool, err := pgxpool.New(ctx, os.Getenv("TEST_DATABASE_URL")) + if err != nil { + t.Fatal(err) + } + defer pool.Close() + storage, err := file_storage.NewPrivateRustFSStorage(ctx, os.Getenv("ED029_RETURN_S3"), "test", "test-only-password", "test-game-exports") + if err != nil { + t.Fatal(err) + } + repo := game_returns_repo.NewGameReturnsRepo(pool) + service := NewGameResultsApplyService(repo, storage, "prod", os.Getenv("ED029_RETURN_SOURCE")) + directory := os.Getenv("ED029_RESULTS_DIR") + raw, err := os.ReadFile(filepath.Join(directory, "results-"+idText+".zip")) + if err != nil { + t.Fatal(err) + } + bundle, err := Unpack(ctx, bytes.NewReader(raw), int64(len(raw))) + if err != nil { + t.Fatal(err) + } + previous, err := repo.FindReturn(ctx, bundle.Manifest.PackageID) + if errors.Is(err, pgx.ErrNoRows) { + export, err := repo.ReadExport(ctx, bundle.Manifest.SourcePackageID) + if err != nil { + t.Fatal(err) + } + var original game_package_service.Data + if err := json.Unmarshal(export.Snapshot, &original); err != nil { + t.Fatal(err) + } + games := games_repo.NewGamesRepo(pool) + if err := games.UpdateGame(ctx, gameID, "TEST Online title after export", "TEST Online description", nil, time.Now().Add(72*time.Hour), int32(original.Game.ScenarioID), original.Game.Format); err != nil { + t.Fatal(err) + } + } else if err != nil { + t.Fatal(err) + } + applied, err := service.Apply(ctx, bytes.NewReader(raw), int64(len(raw)), 0) + if err != nil { + t.Fatal(err) + } + if previous != nil && !previous.AppliedAt.Equal(applied.AppliedAt) { + t.Fatal("restart changed repeat") + } + correctedName := filepath.Join(directory, "corrected-"+idText+".zip") + corrected, err := os.ReadFile(correctedName) + if errors.Is(err, os.ErrNotExist) { + zero := 0 + found := false + for i := range bundle.Data.Answers { + if bundle.Data.Answers[i].Score != nil && *bundle.Data.Answers[i].Score == 5 { + bundle.Data.Answers[i].Score = &zero + found = true + break + } + } + if !found { + t.Fatal("scored venue answer missing") + } + var output bytes.Buffer + if err := Pack(ctx, bundle.Manifest, &bundle.Data, &output); err != nil { + t.Fatal(err) + } + corrected = output.Bytes() + if err := os.WriteFile(correctedName, corrected, 0600); err != nil { + t.Fatal(err) + } + } else if err != nil { + t.Fatal(err) + } + newer, err := service.Apply(ctx, bytes.NewReader(corrected), int64(len(corrected)), 0) + if err != nil { + t.Fatal(err) + } + if !newer.PreparedAt.After(applied.PreparedAt) { + t.Fatal("correction did not follow original") + } + repeated, err := service.Apply(ctx, bytes.NewReader(raw), int64(len(raw)), 0) + if err != nil { + t.Fatal(err) + } + if !repeated.AppliedAt.Equal(applied.AppliedAt) { + t.Fatal("old package reapplied") + } + games := games_repo.NewGamesRepo(pool) + game, err := games.GetGameByID(ctx, gameID) + if err != nil { + t.Fatal(err) + } + if game.Status != "finish" || game.Export != nil || game.Name != "TEST Online title after export" { + t.Fatal("status/export/presentation wrong") + } + answers, err := team_answers_repo.NewTeamAnswersRepo(pool).GetAnswersByGameID(ctx, gameID) + if err != nil { + t.Fatal(err) + } + zeroFound := false + for _, answer := range answers { + if answer.Score != nil && *answer.Score == 0 { + zeroFound = true + } + } + if !zeroFound { + t.Fatal("corrected zero score lost") + } + }) + } +} diff --git a/internal/services/game_results_package_service/snapshot.go b/internal/services/game_results_package_service/snapshot.go index 9b436e5..854daf6 100644 --- a/internal/services/game_results_package_service/snapshot.go +++ b/internal/services/game_results_package_service/snapshot.go @@ -31,46 +31,12 @@ func prepareData(snapshot *game_results_repo.Snapshot) (*Data, error) { if snapshot.ScenarioID != snapshot.ImportedScenarioID || original.Game.ID != data.Game.ID || original.Game.Format != data.Game.Format || original.Scenario.Story == nil { return nil, ErrScenarioChanged } - sourceStory, err := json.Marshal(original.Scenario.Story) + source, err := sourceFromOriginal(&original) if err != nil { return nil, err } - data.Source = Source{ - ScenarioID: original.Scenario.ID, - ScenarioSHA256: digest(sourceStory), - Teams: original.Teams, - Tickets: original.Tickets, - Materials: []Material{}, - QuestionCodes: []string{}, - } - textCleaner := cleaner.NewCleaner() - for _, place := range original.Scenario.Story.Places { - if place == nil { - return nil, ErrScenarioChanged - } - for _, material := range place.Applications { - if material == nil { - return nil, ErrScenarioChanged - } - portable := Material{ - Name: textCleaner.ClearText(material.Name), - Image: material.Image, - FileType: material.FileType, - } - if portable.FileType == "" { - portable.FileType = file_storage.FileType(portable.Image) - } - if !containsMaterial(data.Source.Materials, portable) { - data.Source.Materials = append(data.Source.Materials, portable) - } - } - } - for _, question := range original.Scenario.Story.Questions { - if question == nil { - return nil, ErrScenarioChanged - } - data.Source.QuestionCodes = append(data.Source.QuestionCodes, question.Code) - } + data.Source = source + names := map[string]string{} for _, file := range manifest.Files { if file.Path != "game.json" { @@ -163,3 +129,47 @@ func containsMaterial(materials []Material, candidate Material) bool { } return false } + +func sourceFromOriginal(original *game_package_service.Data) (Source, error) { + sourceStory, err := json.Marshal(original.Scenario.Story) + if err != nil { + return Source{}, err + } + source := Source{ + ScenarioID: original.Scenario.ID, + ScenarioSHA256: digest(sourceStory), + Teams: original.Teams, + Tickets: original.Tickets, + Materials: []Material{}, + QuestionCodes: []string{}, + } + textCleaner := cleaner.NewCleaner() + for _, place := range original.Scenario.Story.Places { + if place == nil { + return Source{}, ErrScenarioChanged + } + for _, material := range place.Applications { + if material == nil { + return Source{}, ErrScenarioChanged + } + portable := Material{ + Name: textCleaner.ClearText(material.Name), + Image: material.Image, + FileType: material.FileType, + } + if portable.FileType == "" { + portable.FileType = file_storage.FileType(portable.Image) + } + if !containsMaterial(source.Materials, portable) { + source.Materials = append(source.Materials, portable) + } + } + } + for _, question := range original.Scenario.Story.Questions { + if question == nil { + return Source{}, ErrScenarioChanged + } + source.QuestionCodes = append(source.QuestionCodes, question.Code) + } + return source, nil +} diff --git a/internal/services/game_service/lifecycle_test.go b/internal/services/game_service/lifecycle_test.go index f0a5a0e..678286f 100644 --- a/internal/services/game_service/lifecycle_test.go +++ b/internal/services/game_service/lifecycle_test.go @@ -46,6 +46,7 @@ func TestServicesLifecyclePostgres(t *testing.T) { defer pool.Close() _, err = pool.Exec(ctx, ` CREATE TEMP TABLE users(id INT, username TEXT); + CREATE TEMP TABLE game_returns(export_id TEXT); CREATE TEMP TABLE game_exports(id TEXT, game_id INT, state TEXT, package_id TEXT, size BIGINT, sha256 TEXT, failure TEXT, created_at TIMESTAMPTZ); CREATE TEMP TABLE scenarios(id INT PRIMARY KEY, playlist_id INT, 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); diff --git a/internal/services/gameplay_service/finished_story_test.go b/internal/services/gameplay_service/finished_story_test.go index 141cdd9..f6b7376 100644 --- a/internal/services/gameplay_service/finished_story_test.go +++ b/internal/services/gameplay_service/finished_story_test.go @@ -30,6 +30,7 @@ func TestFinishedTeamStoryPostgres(t *testing.T) { } defer pool.Close() _, err = pool.Exec(ctx, ` + CREATE TEMP TABLE game_returns(export_id TEXT); CREATE TEMP TABLE game_exports(id TEXT, game_id INT, state TEXT, package_id TEXT, size BIGINT, sha256 TEXT, failure TEXT, created_at TIMESTAMPTZ); CREATE TEMP TABLE games (id INT, name TEXT, description TEXT, location TEXT, start_at TIMESTAMP, format TEXT, status TEXT, scenario_id INT, started_at TIMESTAMP, ended_at TIMESTAMP); diff --git a/migrations/20261012120000_create_game_returns.sql b/migrations/20261012120000_create_game_returns.sql new file mode 100644 index 0000000..5e38759 --- /dev/null +++ b/migrations/20261012120000_create_game_returns.sql @@ -0,0 +1,18 @@ +-- +goose Up +CREATE TABLE game_returns ( + package_id TEXT PRIMARY KEY, + sha256 TEXT NOT NULL, + export_id TEXT NOT NULL REFERENCES game_exports(id) ON DELETE RESTRICT, + source_game_id INTEGER NOT NULL, + prepared_at TEXT NOT NULL, + applied_at TIMESTAMPTZ NOT NULL DEFAULT clock_timestamp(), + mappings JSONB NOT NULL, + local_actors JSONB NOT NULL, + initiator_id INTEGER REFERENCES users(id) ON DELETE SET NULL, + UNIQUE (export_id, prepared_at) +); +CREATE INDEX game_returns_game ON game_returns(source_game_id); +CREATE INDEX game_returns_export ON game_returns(export_id, prepared_at DESC); + +-- +goose Down +DROP TABLE game_returns;