This commit is contained in:
2026-10-11 16:00:18 +07:00
parent 504b49f7cc
commit 030ca56900
77 changed files with 793 additions and 638 deletions
+22
View File
@@ -0,0 +1,22 @@
package teams_repo
import (
"context"
"errors"
"testing"
)
func TestTeamOperationsRejectInvalidIDs(t *testing.T) {
for _, id := range []string{"", "12", "invalid", "00000000-0000-0000-0000-000000000000", "019A1234-5678-7ABC-8DEF-000000000001", "019a123456787abc8def000000000001"} {
t.Run(id, func(t *testing.T) {
repo := NewTeamsRepo(nil)
ctx := context.Background()
_, err := repo.GetTeamByID(ctx, id)
for _, result := range []error{err, repo.UpdateTeam(ctx, id, "Team"), repo.DeleteTeam(ctx, id)} {
if !errors.Is(result, ErrTeamNotFound) {
t.Fatal(result)
}
}
})
}
}
+6 -6
View File
@@ -26,7 +26,7 @@ const maxReviewReminderAttempts = 8
// Participation describes a team's participation state.
type Participation struct {
TeamID int
TeamID string
TeamName string
GameName string
StartAt time.Time
@@ -39,7 +39,7 @@ type Participation struct {
// ReviewReminder describes a pending review reminder.
type ReviewReminder struct {
TeamID int
TeamID string
TeamName string
GameID int
GameName string
@@ -130,7 +130,7 @@ func (r *TeamsRepo) ClaimParticipationEmail(ctx context.Context) (*Participation
}
// SetParticipationToken stores a participation token.
func (r *TeamsRepo) SetParticipationToken(ctx context.Context, id int, token string) error {
func (r *TeamsRepo) SetParticipationToken(ctx context.Context, id string, token string) error {
hash, err := tokenHash(token)
if err != nil {
return err
@@ -140,7 +140,7 @@ func (r *TeamsRepo) SetParticipationToken(ctx context.Context, id int, token str
}
// ConfirmParticipationByFirstAction confirms participation after the first action.
func (r *TeamsRepo) ConfirmParticipationByFirstAction(ctx context.Context, teamID int) error {
func (r *TeamsRepo) ConfirmParticipationByFirstAction(ctx context.Context, teamID string) error {
_, err := r.pool.Exec(ctx, `UPDATE teams SET participation_status='confirmed',
participation_source='first_action', participation_answered_at=NOW()
WHERE id=$1 AND participation_status='pending'`, teamID)
@@ -148,7 +148,7 @@ func (r *TeamsRepo) ConfirmParticipationByFirstAction(ctx context.Context, teamI
}
// FinishParticipationEmail completes a participation email job.
func (r *TeamsRepo) FinishParticipationEmail(ctx context.Context, id, attempt int, sendErr error) error {
func (r *TeamsRepo) FinishParticipationEmail(ctx context.Context, id string, attempt int, sendErr error) error {
message := ""
if sendErr != nil {
message = sendErr.Error()
@@ -216,7 +216,7 @@ func (r *TeamsRepo) ClaimReviewReminder(ctx context.Context) (*ReviewReminder, e
}
// FinishReviewReminder completes a review reminder job.
func (r *TeamsRepo) FinishReviewReminder(ctx context.Context, id, attempt int, sendErr error) error {
func (r *TeamsRepo) FinishReviewReminder(ctx context.Context, id string, attempt int, sendErr error) error {
message := ""
if sendErr != nil {
message = sendErr.Error()
@@ -45,7 +45,7 @@ func TestRegistrationPostgres(t *testing.T) {
}
defer pool.Close()
_, err = pool.Exec(ctx, `CREATE TABLE games (id SERIAL PRIMARY KEY, status TEXT DEFAULT 'draft');
CREATE TABLE teams (id SERIAL PRIMARY KEY, name VARCHAR(255), creator_id INTEGER,
CREATE TABLE teams (id UUID PRIMARY KEY, name VARCHAR(255), creator_id INTEGER,
game_id INTEGER REFERENCES games(id), password TEXT, status TEXT DEFAULT 'register', created_at TIMESTAMP DEFAULT NOW());
INSERT INTO games DEFAULT VALUES; INSERT INTO games DEFAULT VALUES;`)
if err != nil {
@@ -67,7 +67,7 @@ func TestRegistrationPostgres(t *testing.T) {
}
repo := NewTeamsRepo(pool)
type result struct {
id int
id string
err error
}
results := make(chan result, 8)
@@ -77,13 +77,13 @@ func TestRegistrationPostgres(t *testing.T) {
results <- result{id, err}
}()
}
var id int
var id string
for range 8 {
got := <-results
if got.err != nil {
t.Fatal(got.err)
}
if id != 0 && got.id != id {
if id != "" && got.id != id {
t.Fatal("concurrent delivery created a duplicate")
}
id = got.id
@@ -107,7 +107,7 @@ func TestRegistrationPostgres(t *testing.T) {
t.Fatal(err)
}
if got, err := repo.RegisterTeam(ctx, 1, "delivery", "Changed", "other@example.org", 9, "different"); err != nil || got != id {
t.Fatalf("retry after start: %d %v", got, err)
t.Fatalf("retry after start: %s %v", got, err)
}
if _, err := repo.RegisterTeam(ctx, 1, "new", "Team", "captain@example.org", 5, "abcdefgh"); !errors.Is(err, ErrRegistrationClosed) {
t.Fatalf("registration after start: %v", err)
@@ -116,7 +116,7 @@ func TestRegistrationPostgres(t *testing.T) {
t.Fatalf("missing game: %v", err)
}
if otherID, err := repo.RegisterTeam(ctx, 2, "delivery", "Team", "captain@example.org", 5, "abcdefgh"); err != nil || otherID == id {
t.Fatalf("other game: %d %v", otherID, err)
t.Fatalf("other game: %s %v", otherID, err)
}
for _, email := range []string{"", "manual@example.org"} {
manualID, err := repo.AddTeamWithParticipation(ctx, 1, 2, "Manual", "password", email, 0, false)
+101 -27
View File
@@ -5,6 +5,7 @@ import (
"errors"
"evening_detective_server/internal/repos"
"github.com/google/uuid"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgxpool"
)
@@ -27,7 +28,10 @@ func NewTeamsRepo(pool *pgxpool.Pool) *TeamsRepo {
}
// GetTeamByID returns a team by ID.
func (r *TeamsRepo) GetTeamByID(ctx context.Context, id int) (*repos.Team, error) {
func (r *TeamsRepo) GetTeamByID(ctx context.Context, id string) (*repos.Team, error) {
if !validTeamID(id) {
return nil, ErrTeamNotFound
}
team := &repos.Team{}
row := r.pool.QueryRow(
ctx,
@@ -36,7 +40,10 @@ func (r *TeamsRepo) GetTeamByID(ctx context.Context, id int) (*repos.Team, error
name,
captain_email,
participants_count,
participation_status, participation_source, participation_email_error, participation_sent_at IS NOT NULL,
participation_status,
participation_source,
participation_email_error,
participation_sent_at IS NOT NULL,
status,
password,
game_id
@@ -76,7 +83,10 @@ func (r *TeamsRepo) GetTeamsByGameID(
name,
captain_email,
participants_count,
participation_status, participation_source, participation_email_error, participation_sent_at IS NOT NULL,
participation_status,
participation_source,
participation_email_error,
participation_sent_at IS NOT NULL,
status,
password
FROM teams
@@ -120,22 +130,33 @@ func (r *TeamsRepo) AddTeam(
gameID int,
name string,
password string,
) (int, error) {
) (string, error) {
return r.AddTeamWithParticipation(ctx, creatorID, gameID, name, password, "", 0, false)
}
// AddTeamWithParticipation creates a team with participation data.
func (r *TeamsRepo) AddTeamWithParticipation(ctx context.Context, creatorID, gameID int, name, password, captainEmail string, participantsCount int32, autoConfirm bool) (int, error) {
id := 0
func (r *TeamsRepo) AddTeamWithParticipation(ctx context.Context, creatorID, gameID int, name, password, captainEmail string, participantsCount int32, autoConfirm bool) (string, error) {
id, err := uuid.NewV7()
if err != nil {
return "", err
}
var teamID string
status, source := "pending", ""
if autoConfirm {
status, source = "confirmed", "automatic"
}
err := r.pool.QueryRow(
err = r.pool.QueryRow(
ctx,
`INSERT INTO teams (name, creator_id, game_id, password, participation_status, participation_source, participation_answered_at, captain_email, participants_count)
VALUES ($1, $2, $3, $4, $5, $6, CASE WHEN $5 = 'confirmed' THEN NOW() ELSE NULL END, $7, $8)
RETURNING id`,
`INSERT INTO teams (
id, name, creator_id, game_id, password, participation_status,
participation_source, participation_answered_at, captain_email, participants_count
)
VALUES (
$9, $1, $2, $3, $4, $5, $6,
CASE WHEN $5 = 'confirmed' THEN NOW() ELSE NULL END, $7, $8
)
RETURNING
id`,
name,
creatorID,
gameID,
@@ -144,16 +165,20 @@ func (r *TeamsRepo) AddTeamWithParticipation(ctx context.Context, creatorID, gam
source,
captainEmail,
participantsCount,
).Scan(&id)
id.String(),
).Scan(&teamID)
if err != nil {
return 0, err
return "", err
}
return id, nil
return teamID, nil
}
// UpdateTeam changes a team's name.
func (r *TeamsRepo) UpdateTeam(ctx context.Context, teamID int, name string) error {
func (r *TeamsRepo) UpdateTeam(ctx context.Context, teamID string, name string) error {
if !validTeamID(teamID) {
return ErrTeamNotFound
}
tag, err := r.pool.Exec(
ctx,
`UPDATE teams
@@ -173,7 +198,10 @@ func (r *TeamsRepo) UpdateTeam(ctx context.Context, teamID int, name string) err
}
// DeleteTeam removes a team.
func (r *TeamsRepo) DeleteTeam(ctx context.Context, teamID int) error {
func (r *TeamsRepo) DeleteTeam(ctx context.Context, teamID string) error {
if !validTeamID(teamID) {
return ErrTeamNotFound
}
tag, err := r.pool.Exec(
ctx,
`DELETE FROM teams
@@ -192,32 +220,73 @@ func (r *TeamsRepo) DeleteTeam(ctx context.Context, teamID int) error {
// ErrRegistrationClosed indicates that team registration is unavailable.
var ErrRegistrationClosed = errors.New("игра не найдена или регистрация уже закрыта")
func (r *TeamsRepo) RegisterTeam(ctx context.Context, gameID int, deliveryID, name, email string, participantsCount int32, password string) (int, error) {
func (r *TeamsRepo) RegisterTeam(ctx context.Context, gameID int, deliveryID, name, email string, participantsCount int32, password string) (string, error) {
return r.RegisterTeamWithParticipation(ctx, gameID, deliveryID, name, email, participantsCount, password, false)
}
// RegisterTeamWithParticipation registers a team and its participation data.
func (r *TeamsRepo) RegisterTeamWithParticipation(ctx context.Context, gameID int, deliveryID, name, email string, participantsCount int32, password string, autoConfirm bool) (int, error) {
var id int
err := r.pool.QueryRow(ctx, `SELECT id FROM teams WHERE game_id = $1 AND registration_delivery_id = $2`, gameID, deliveryID).Scan(&id)
func (r *TeamsRepo) RegisterTeamWithParticipation(ctx context.Context, gameID int, deliveryID, name, email string, participantsCount int32, password string, autoConfirm bool) (string, error) {
var id string
err := r.pool.QueryRow(
ctx,
`SELECT
id
FROM teams
WHERE game_id = $1 AND registration_delivery_id = $2`,
gameID,
deliveryID,
).Scan(&id)
if err == nil {
return id, nil
}
if !errors.Is(err, pgx.ErrNoRows) {
return 0, err
return "", err
}
newID, err := uuid.NewV7()
if err != nil {
return "", err
}
status, source := "pending", ""
if autoConfirm {
status, source = "confirmed", "automatic"
}
err = r.pool.QueryRow(ctx, `
INSERT INTO teams (game_id, registration_delivery_id, name, captain_email, participants_count, password, participation_status, participation_source, participation_answered_at)
SELECT id, $2, $3, $4, $5, $6, $7, $8, CASE WHEN $7 = 'confirmed' THEN NOW() ELSE NULL END FROM games WHERE id = $1 AND status = 'draft'
ON CONFLICT (game_id, registration_delivery_id)
DO UPDATE SET registration_delivery_id = EXCLUDED.registration_delivery_id
RETURNING id`, gameID, deliveryID, name, email, participantsCount, password, status, source).Scan(&id)
err = r.pool.QueryRow(
ctx,
`INSERT INTO teams (
id, game_id, registration_delivery_id, name, captain_email,
participants_count, password, participation_status,
participation_source, participation_answered_at
)
SELECT
$9,
id,
$2,
$3,
$4,
$5,
$6,
$7,
$8,
CASE WHEN $7 = 'confirmed' THEN NOW() ELSE NULL END
FROM games
WHERE id = $1 AND status = 'draft'
ON CONFLICT (game_id, registration_delivery_id)
DO UPDATE
SET registration_delivery_id = EXCLUDED.registration_delivery_id
RETURNING
id`,
gameID,
deliveryID,
name,
email,
participantsCount,
password,
status,
source,
newID.String(),
).Scan(&id)
if errors.Is(err, pgx.ErrNoRows) {
return 0, ErrRegistrationClosed
return "", ErrRegistrationClosed
}
return id, err
}
@@ -232,3 +301,8 @@ func (r *TeamsRepo) GetActiveTeamsByGameID(ctx context.Context, gameID int) ([]*
}
return active, err
}
func validTeamID(id string) bool {
parsed, err := uuid.Parse(id)
return err == nil && parsed != uuid.Nil && parsed.String() == id
}