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
@@ -6,7 +6,7 @@ import (
)
type Participation struct {
TeamID int
TeamID string
TeamName string
GameName string
StartAt time.Time
@@ -29,8 +29,8 @@ func (s *participationSender) Send(_ context.Context, m email_sender.Message) er
}
func TestParticipationEmail(t *testing.T) {
token := participationToken("test-secret", 42)
if len(token) != 64 || token != participationToken("test-secret", 42) || token == participationToken("test-secret", 43) || token == participationToken("other", 42) {
token := participationToken("test-secret", "019a1234-5678-7abc-8def-00000000002a")
if len(token) != 64 || token != participationToken("test-secret", "019a1234-5678-7abc-8def-00000000002a") || token == participationToken("test-secret", "019a1234-5678-7abc-8def-00000000002b") || token == participationToken("other", "019a1234-5678-7abc-8def-00000000002a") {
t.Fatal("token isolation or retry stability broken")
}
p := &Participation{TeamName: "<script>team</script>", GameName: "Game", Email: "captain@example.org", StartAt: time.Date(2026, 9, 20, 12, 0, 0, 0, time.UTC), ParticipantsCount: 5}
@@ -80,9 +80,9 @@ func TestParticipationPostgres(t *testing.T) {
}
exec(`CREATE TABLE game_exports(game_id INT, state TEXT);
CREATE TABLE games(id SERIAL PRIMARY KEY,name TEXT DEFAULT 'Game',start_at TIMESTAMP NOT NULL,status TEXT DEFAULT 'draft');
CREATE TABLE teams(id SERIAL PRIMARY KEY,name TEXT DEFAULT 'Team',creator_id INTEGER,game_id INTEGER REFERENCES games(id),password TEXT DEFAULT 'secret',status TEXT DEFAULT 'register',created_at TIMESTAMP DEFAULT (NOW() AT TIME ZONE 'UTC'));
CREATE TABLE teams(id UUID PRIMARY KEY,name TEXT DEFAULT 'Team',creator_id INTEGER,game_id INTEGER REFERENCES games(id),password TEXT DEFAULT 'secret',status TEXT DEFAULT 'register',created_at TIMESTAMP DEFAULT (NOW() AT TIME ZONE 'UTC'));
INSERT INTO games(start_at) VALUES((NOW() AT TIME ZONE 'UTC')+INTERVAL '12 hours'),((NOW() AT TIME ZONE 'UTC')+INTERVAL '2 days');
INSERT INTO teams(game_id,created_at) VALUES(1,(NOW() AT TIME ZONE 'UTC')-INTERVAL '2 days'),(1,(NOW() AT TIME ZONE 'UTC')-INTERVAL '1 hour');`)
INSERT INTO teams(id,game_id,created_at) VALUES('019a1234-5678-7abc-8def-000000000001',1,(NOW() AT TIME ZONE 'UTC')-INTERVAL '2 days'),('019a1234-5678-7abc-8def-000000000002',1,(NOW() AT TIME ZONE 'UTC')-INTERVAL '1 hour');`)
for _, name := range []string{"20260913130000_add_team_registration.sql", "20260915120000_add_team_participation.sql"} {
data, err := os.ReadFile("../../../migrations/" + name)
if err != nil {
@@ -94,7 +94,7 @@ func TestParticipationPostgres(t *testing.T) {
service := NewParticipationService(repo, "https://example.org")
state := func(id int, want string) {
t.Helper()
team, err := repo.GetTeamByID(ctx, id)
team, err := repo.GetTeamByID(ctx, fmt.Sprintf("019a1234-5678-7abc-8def-%012x", id))
if err != nil || team.ParticipationStatus != want {
t.Fatalf("team %d want %s got %+v, %v", id, want, team, err)
}
@@ -102,20 +102,20 @@ func TestParticipationPostgres(t *testing.T) {
state(1, "pending")
state(2, "pending")
// Model registration output explicitly: direct SQL inserts do not auto-confirm teams.
exec(`UPDATE teams SET captain_email='captain@example.org',participants_count=5 WHERE id=1;
INSERT INTO teams(game_id,created_at,captain_email) SELECT 1,start_at-INTERVAL '24 hours','boundary@example.org' FROM games WHERE id=1;
INSERT INTO teams(game_id,created_at,captain_email,participation_status,participation_source,participation_answered_at) SELECT 1,start_at-INTERVAL '24 hours'+INTERVAL '1 microsecond','late@example.org','confirmed','automatic',NOW() FROM games WHERE id=1;
INSERT INTO teams(game_id,captain_email) VALUES(2,'future@example.org');
INSERT INTO teams(game_id,created_at) SELECT 1,start_at-INTERVAL '25 hours' FROM games WHERE id=1;`)
exec(`UPDATE teams SET captain_email='captain@example.org',participants_count=5 WHERE id='019a1234-5678-7abc-8def-000000000001';
INSERT INTO teams(id,game_id,created_at,captain_email) SELECT '019a1234-5678-7abc-8def-000000000003',1,start_at-INTERVAL '24 hours','boundary@example.org' FROM games WHERE id=1;
INSERT INTO teams(id,game_id,created_at,captain_email,participation_status,participation_source,participation_answered_at) SELECT '019a1234-5678-7abc-8def-000000000004',1,start_at-INTERVAL '24 hours'+INTERVAL '1 microsecond','late@example.org','confirmed','automatic',NOW() FROM games WHERE id=1;
INSERT INTO teams(id,game_id,captain_email) VALUES('019a1234-5678-7abc-8def-000000000005',2,'future@example.org');
INSERT INTO teams(id,game_id,created_at) SELECT '019a1234-5678-7abc-8def-000000000006',1,start_at-INTERVAL '25 hours' FROM games WHERE id=1;`)
state(3, "pending")
state(4, "confirmed")
state(5, "pending")
if err := repo.ConfirmParticipationByFirstAction(ctx, 5); err != nil {
if err := repo.ConfirmParticipationByFirstAction(ctx, "019a1234-5678-7abc-8def-000000000005"); err != nil {
t.Fatal(err)
}
state(5, "confirmed")
var wg sync.WaitGroup
ids := make(chan int, 8)
ids := make(chan string, 8)
errs := make(chan error, 8)
for range 8 {
wg.Add(1)
@@ -132,17 +132,17 @@ func TestParticipationPostgres(t *testing.T) {
wg.Wait()
close(ids)
close(errs)
seen := map[int]bool{}
seen := map[string]bool{}
for id := range ids {
if seen[id] {
t.Fatalf("duplicate claim %d", id)
t.Fatalf("duplicate claim %s", id)
}
seen[id] = true
}
for err := range errs {
t.Error(err)
}
if len(seen) != 2 || !seen[1] || !seen[3] {
if len(seen) != 2 || !seen["019a1234-5678-7abc-8def-000000000001"] || !seen["019a1234-5678-7abc-8def-000000000003"] {
t.Fatalf("wrong eligible teams: %v", seen)
}
exec(`UPDATE teams SET participation_next_attempt_at=NULL`)
@@ -153,26 +153,26 @@ func TestParticipationPostgres(t *testing.T) {
if len(sender.messages) != 2 {
t.Fatalf("wanted two attempts: %d", len(sender.messages))
}
team, err := repo.GetTeamByID(ctx, 1)
team, err := repo.GetTeamByID(ctx, "019a1234-5678-7abc-8def-000000000001")
if err != nil || team.ParticipationEmailError == "" || team.ParticipationEmailSent {
t.Fatalf("failed send not recorded: %+v %v", team, err)
}
sender.err = nil
exec(`UPDATE teams SET participation_next_attempt_at=NOW()-INTERVAL '1 minute' WHERE id IN (1,3)`)
exec(`UPDATE teams SET participation_next_attempt_at=NOW()-INTERVAL '1 minute' WHERE id IN ('019a1234-5678-7abc-8def-000000000001','019a1234-5678-7abc-8def-000000000003')`)
if err := service.sendParticipationEmails(ctx, sender, "secret", time.UTC); err != nil {
t.Fatal(err)
}
if len(sender.messages) != 4 {
t.Fatal("expired lease not recovered")
}
exec(`UPDATE teams SET participation_attempts=8, participation_next_attempt_at=NULL, participation_sent_at=NULL WHERE id=1`)
exec(`UPDATE teams SET participation_attempts=8, participation_next_attempt_at=NULL, participation_sent_at=NULL WHERE id='019a1234-5678-7abc-8def-000000000001'`)
if err := service.sendParticipationEmails(ctx, sender, "secret", time.UTC); err != nil {
t.Fatal(err)
}
if len(sender.messages) != 4 {
t.Fatal("email retry exceeded the eight-attempt limit")
}
token := participationToken("secret", 1)
token := participationToken("secret", "019a1234-5678-7abc-8def-000000000001")
if _, err := service.GetParticipation(ctx, "bad"); !errors.Is(err, ErrParticipationLink) {
t.Fatalf("invalid token: %v", err)
}
@@ -208,7 +208,7 @@ func TestParticipationPostgres(t *testing.T) {
if _, err := service.AnswerParticipation(ctx, token, p.Status); err != nil {
t.Fatalf("same answer not idempotent: %v", err)
}
token3 := participationToken("secret", 3)
token3 := participationToken("secret", "019a1234-5678-7abc-8def-000000000003")
if _, err := service.AnswerParticipation(ctx, token3, "cancelled"); err != nil {
t.Fatal(err)
}
@@ -217,23 +217,23 @@ func TestParticipationPostgres(t *testing.T) {
t.Fatal(err)
}
for _, team := range active {
if team.ID == 3 {
if team.ID == "019a1234-5678-7abc-8def-000000000003" {
t.Fatal("cancelled team in active list")
}
}
gameplayService := gameplay_service.NewGameplayService(nil, repo, nil, nil, nil, nil, nil, "")
if _, _, _, err := gameplayService.GetTeamActions(ctx, 3, "secret"); !errors.Is(err, gameplay_service.ErrTeamNotFound) {
if _, _, _, err := gameplayService.GetTeamActions(ctx, "019a1234-5678-7abc-8def-000000000003", "secret"); !errors.Is(err, gameplay_service.ErrTeamNotFound) {
t.Fatalf("cancelled team got story: %v", err)
}
if err := gameplayService.AddTeamAction(ctx, 3, "secret", "anything", false); !errors.Is(err, gameplay_service.ErrTeamNotFound) {
if err := gameplayService.AddTeamAction(ctx, "019a1234-5678-7abc-8def-000000000003", "secret", "anything", false); !errors.Is(err, gameplay_service.ErrTeamNotFound) {
t.Fatalf("cancelled team added action: %v", err)
}
answerService := answer_service.NewAnswerService(nil, repo, nil, nil, nil)
if _, err := answerService.GetTeamAnswers(ctx, 3, "secret"); !errors.Is(err, answer_service.ErrTeamNotFound) {
if _, err := answerService.GetTeamAnswers(ctx, "019a1234-5678-7abc-8def-000000000003", "secret"); !errors.Is(err, answer_service.ErrTeamNotFound) {
t.Fatalf("cancelled team got answers: %v", err)
}
token6 := participationToken("secret", 6)
if err := repo.SetParticipationToken(ctx, 6, token6); err != nil {
token6 := participationToken("secret", "019a1234-5678-7abc-8def-000000000006")
if err := repo.SetParticipationToken(ctx, "019a1234-5678-7abc-8def-000000000006", token6); err != nil {
t.Fatal(err)
}
exec(`UPDATE games SET status='run' WHERE id=1`)
@@ -58,9 +58,9 @@ func mapParticipationError(err error) error {
}
}
func participationToken(secret string, id int) string {
func participationToken(secret string, id string) string {
mac := hmac.New(sha256.New, []byte(secret))
_, _ = fmt.Fprintf(mac, "team-participation:v1:%d", id)
_, _ = fmt.Fprintf(mac, "team-participation:v1:%s", id)
return hex.EncodeToString(mac.Sum(nil))
}