generated from VLADIMIR/template
up
This commit is contained in:
+89
-7
@@ -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 и контрольного прогона используются только тестовые игры.
|
||||
|
||||
@@ -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
|
||||
}
|
||||
)`
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
Reference in New Issue
Block a user