add accept

This commit is contained in:
2026-09-18 17:46:35 +07:00
parent ca7659f732
commit f07038f43a
10 changed files with 699 additions and 207 deletions
@@ -0,0 +1,93 @@
package tickets_repo
import (
"context"
"crypto/sha256"
"errors"
"fmt"
"os"
"testing"
"time"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgxpool"
)
func TestAcceptIsAtomic(t *testing.T) {
dsn := os.Getenv("TEST_DATABASE_URL")
if dsn == "" {
t.Skip("set TEST_DATABASE_URL to run PostgreSQL integration check")
}
ctx := context.Background()
admin, err := pgxpool.New(ctx, dsn)
if err != nil {
t.Fatal(err)
}
defer admin.Close()
schema := fmt.Sprintf("tickets_test_%d", time.Now().UnixNano())
quoted := pgx.Identifier{schema}.Sanitize()
if _, err := admin.Exec(ctx, "CREATE SCHEMA "+quoted); err != nil {
t.Fatal(err)
}
defer func() { _, _ = admin.Exec(ctx, "DROP SCHEMA "+quoted+" CASCADE") }()
config, err := pgxpool.ParseConfig(dsn)
if err != nil {
t.Fatal(err)
}
config.ConnConfig.RuntimeParams["search_path"] = schema
pool, err := pgxpool.NewWithConfig(ctx, config)
if err != nil {
t.Fatal(err)
}
defer pool.Close()
hash := sha256.Sum256([]byte("token"))
_, err = pool.Exec(ctx, `
CREATE TABLE users (id SERIAL PRIMARY KEY, username TEXT NOT NULL);
CREATE TABLE scenarios (id SERIAL PRIMARY KEY, name TEXT NOT NULL);
CREATE TABLE games (id SERIAL PRIMARY KEY, scenario_id INTEGER NOT NULL, status TEXT NOT NULL, start_at TIMESTAMP NOT NULL, location TEXT);
CREATE TABLE tickets (id SERIAL PRIMARY KEY, game_id INTEGER NOT NULL, token_hash BYTEA NOT NULL UNIQUE, status TEXT NOT NULL DEFAULT 'active', used_at TIMESTAMPTZ, used_by INTEGER)`)
if err != nil {
t.Fatal(err)
}
_, err = pool.Exec(ctx, `INSERT INTO users (username) VALUES ('organizer'), ('other')`)
if err != nil {
t.Fatal(err)
}
_, err = pool.Exec(ctx, `INSERT INTO scenarios (name) VALUES ('Сценарий')`)
if err != nil {
t.Fatal(err)
}
_, err = pool.Exec(ctx, `INSERT INTO games (scenario_id, status, start_at) VALUES (1, 'run', NOW())`)
if err != nil {
t.Fatal(err)
}
_, err = pool.Exec(ctx, `INSERT INTO tickets (game_id, token_hash) VALUES (1, $1)`, hash[:])
if err != nil {
t.Fatal(err)
}
repo := New(pool)
results := make(chan error, 8)
for i := 0; i < 8; i++ {
go func(userID int) {
_, err := repo.Accept(ctx, hash[:], userID)
results <- err
}(i%2 + 1)
}
successes := 0
for range 8 {
if err := <-results; err == nil {
successes++
} else if !errors.Is(err, ErrTicketUsed) {
t.Fatalf("unexpected accept error: %v", err)
}
}
if successes != 1 {
t.Fatalf("successful accepts = %d, want 1", successes)
}
var status string
if err := pool.QueryRow(ctx, "SELECT status FROM tickets WHERE id=1").Scan(&status); err != nil || status != "used" {
t.Fatalf("ticket status = %q, err = %v", status, err)
}
}
+42 -3
View File
@@ -9,7 +9,11 @@ import (
"github.com/jackc/pgx/v5/pgxpool"
)
var ErrTicketNotFound = errors.New("Билет не найден")
var (
ErrTicketNotFound = errors.New("Билет не найден")
ErrTicketUsed = errors.New("Билет уже использован")
ErrGameFinished = errors.New("Игра уже завершена")
)
type Info struct {
ID int
@@ -19,6 +23,7 @@ type Info struct {
Location *string
GameStatus string
UsedAt *time.Time
UsedBy *string
}
type Repo struct {
@@ -56,14 +61,48 @@ func (r *Repo) CreateBatch(ctx context.Context, gameID int, tokenHashes [][]byte
func (r *Repo) GetByHash(ctx context.Context, tokenHash []byte) (*Info, error) {
info := &Info{}
err := r.pool.QueryRow(ctx, `
SELECT t.id, t.status, s.name, g.start_at, g.location, g.status, t.used_at
SELECT t.id, t.status, s.name, g.start_at, g.location, g.status, t.used_at, u.username
FROM tickets t
JOIN games g ON g.id = t.game_id
JOIN scenarios s ON s.id = g.scenario_id
LEFT JOIN users u ON u.id = t.used_by
WHERE t.token_hash = $1`, tokenHash).Scan(
&info.ID, &info.Status, &info.GameName, &info.StartAt, &info.Location, &info.GameStatus, &info.UsedAt)
&info.ID, &info.Status, &info.GameName, &info.StartAt, &info.Location, &info.GameStatus, &info.UsedAt, &info.UsedBy)
if errors.Is(err, pgx.ErrNoRows) {
return nil, ErrTicketNotFound
}
return info, err
}
func (r *Repo) Accept(ctx context.Context, tokenHash []byte, userID int) (*Info, error) {
var status, gameStatus string
if err := r.pool.QueryRow(ctx, `
SELECT t.status, g.status
FROM tickets t JOIN games g ON g.id = t.game_id
WHERE t.token_hash = $1`, tokenHash).Scan(&status, &gameStatus); errors.Is(err, pgx.ErrNoRows) {
return nil, ErrTicketNotFound
} else if err != nil {
return nil, err
}
if status == "used" {
return nil, ErrTicketUsed
}
if gameStatus == "finish" {
return nil, ErrGameFinished
}
tag, err := r.pool.Exec(ctx, `
UPDATE tickets t SET status = 'used', used_at = NOW(), used_by = $2
FROM games g
WHERE t.game_id = g.id AND t.token_hash = $1 AND t.status = 'active' AND g.status <> 'finish'`, tokenHash, userID)
if err != nil {
return nil, err
}
if tag.RowsAffected() == 0 {
if status == "used" {
return nil, ErrTicketUsed
}
return nil, ErrGameFinished
}
return r.GetByHash(ctx, tokenHash)
}