generated from VLADIMIR/template
add accept
This commit is contained in:
@@ -31,6 +31,9 @@ func (s *server) GetTicket(ctx context.Context, req *proto.GetTicketReq) (*proto
|
||||
Status: page.Status,
|
||||
GameStatus: page.GameStatus,
|
||||
}
|
||||
if page.UsedBy != nil {
|
||||
result.UsedBy = *page.UsedBy
|
||||
}
|
||||
if page.Location != nil {
|
||||
result.Location = *page.Location
|
||||
}
|
||||
@@ -40,6 +43,31 @@ func (s *server) GetTicket(ctx context.Context, req *proto.GetTicketReq) (*proto
|
||||
return &proto.GetTicketRsp{Ticket: result}, nil
|
||||
}
|
||||
|
||||
func (s *server) AcceptTicket(ctx context.Context, req *proto.AcceptTicketReq) (*proto.AcceptTicketRsp, error) {
|
||||
claims, ok := ctx.Value("claims").(*processor_jwt.JWTClaims)
|
||||
if !ok || claims == nil || (!roles.HasRole(claims, roles.Organizer) && !roles.HasRole(claims, roles.Author)) {
|
||||
return nil, status.Error(codes.PermissionDenied, "permission denied")
|
||||
}
|
||||
if req == nil {
|
||||
return &proto.AcceptTicketRsp{Error: ticket_service.ErrTicketNotFound.Error()}, nil
|
||||
}
|
||||
page, err := s.ticketService.Accept(ctx, req.Token, claims.UserID)
|
||||
if err != nil {
|
||||
return &proto.AcceptTicketRsp{Error: err.Error()}, nil
|
||||
}
|
||||
result := &proto.TicketPage{Id: int32(page.ID), GameName: page.GameName, StartAt: timestamppb.New(page.StartAt), Status: page.Status, GameStatus: page.GameStatus}
|
||||
if page.Location != nil {
|
||||
result.Location = *page.Location
|
||||
}
|
||||
if page.UsedAt != nil {
|
||||
result.UsedAt = timestamppb.New(*page.UsedAt)
|
||||
}
|
||||
if page.UsedBy != nil {
|
||||
result.UsedBy = *page.UsedBy
|
||||
}
|
||||
return &proto.AcceptTicketRsp{Ticket: result}, nil
|
||||
}
|
||||
|
||||
func (s *server) IssueGameTickets(ctx context.Context, req *proto.IssueGameTicketsReq) (*proto.IssueGameTicketsRsp, error) {
|
||||
claims, ok := ctx.Value("claims").(*processor_jwt.JWTClaims)
|
||||
if !ok || claims == nil {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -20,6 +20,8 @@ var (
|
||||
ErrInvalidTicketCount = errors.New("количество билетов должно быть от 1 до 100")
|
||||
ErrInvalidBaseURL = errors.New("необходимо настроить APP_BASE_URL — публичный адрес приложения")
|
||||
ErrTicketNotFound = tickets_repo.ErrTicketNotFound
|
||||
ErrTicketUsed = tickets_repo.ErrTicketUsed
|
||||
ErrGameFinished = tickets_repo.ErrGameFinished
|
||||
)
|
||||
|
||||
type Ticket struct {
|
||||
@@ -35,6 +37,7 @@ type Page struct {
|
||||
Location *string
|
||||
GameStatus string
|
||||
UsedAt *time.Time
|
||||
UsedBy *string
|
||||
}
|
||||
|
||||
type Service struct {
|
||||
@@ -100,9 +103,22 @@ func (s *Service) Get(ctx context.Context, token string) (*Page, error) {
|
||||
Location: info.Location,
|
||||
GameStatus: info.GameStatus,
|
||||
UsedAt: info.UsedAt,
|
||||
UsedBy: info.UsedBy,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (s *Service) Accept(ctx context.Context, token string, userID int) (*Page, error) {
|
||||
hash, err := tokenHash(token)
|
||||
if err != nil {
|
||||
return nil, ErrTicketNotFound
|
||||
}
|
||||
info, err := s.repo.Accept(ctx, hash, userID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &Page{ID: info.ID, Status: info.Status, GameName: info.GameName, StartAt: info.StartAt, Location: info.Location, GameStatus: info.GameStatus, UsedAt: info.UsedAt, UsedBy: info.UsedBy}, nil
|
||||
}
|
||||
|
||||
func newToken() (string, error) {
|
||||
data := make([]byte, 32)
|
||||
if _, err := rand.Read(data); err != nil {
|
||||
|
||||
Reference in New Issue
Block a user