This commit is contained in:
2026-10-11 13:09:34 +07:00
parent 26c65062e9
commit 74a45da402
18 changed files with 2294 additions and 82 deletions
+89 -7
View File
@@ -350,7 +350,7 @@ RustFS и создать `<marker>.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 и контрольного прогона используются только тестовые игры.
+39 -20
View File
@@ -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
}
)`
+260
View File
@@ -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)
}
@@ -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)
}
})
}
}
+304
View File
@@ -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
}
+12 -4
View File
@@ -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
+50 -14
View File
@@ -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
}
@@ -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);
@@ -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")
}
})
}
}
@@ -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
}
@@ -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
}
@@ -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")
}
}
})
}
}
@@ -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
}
@@ -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")
}
})
}
}
@@ -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
}
@@ -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);
@@ -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);
@@ -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;