Add Discord event announcement functionality to backend
This commit introduces a new Discord event announcer to the backend, allowing for event announcements via Discord. It includes the addition of new environment variables for Discord configuration in `.env.example` and `compose.yaml`. The `main.go` file has been updated to initialize the announcer, and a new `discord` package has been created, containing the announcer logic and tests. Additionally, the service layer has been modified to support bracket draft management, enhancing the overall event workflow. Integration tests have been updated to ensure proper functionality of the new features.
This commit is contained in:
203
backend/internal/adapter/discord/announcer.go
Normal file
203
backend/internal/adapter/discord/announcer.go
Normal file
@@ -0,0 +1,203 @@
|
||||
package discord
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"unicode/utf8"
|
||||
|
||||
"mixmaker/backend/internal/domain"
|
||||
)
|
||||
|
||||
const defaultAPIBaseURL = "https://discord.com/api/v10"
|
||||
|
||||
type Config struct {
|
||||
BotToken string
|
||||
ChannelID string
|
||||
PublicURL string
|
||||
Locale string
|
||||
APIBaseURL string
|
||||
HTTPClient *http.Client
|
||||
}
|
||||
|
||||
type Announcer struct {
|
||||
botToken string
|
||||
channelID string
|
||||
publicURL string
|
||||
locale string
|
||||
apiBaseURL string
|
||||
httpClient *http.Client
|
||||
}
|
||||
|
||||
type messagePayload struct {
|
||||
Content string `json:"content"`
|
||||
Embeds []embed `json:"embeds"`
|
||||
Components []actionRow `json:"components"`
|
||||
AllowedMentions allowedMentions `json:"allowed_mentions"`
|
||||
}
|
||||
|
||||
type embed struct {
|
||||
Title string `json:"title"`
|
||||
Description string `json:"description,omitempty"`
|
||||
URL string `json:"url"`
|
||||
Color int `json:"color"`
|
||||
Fields []embedField `json:"fields"`
|
||||
}
|
||||
|
||||
type embedField struct {
|
||||
Name string `json:"name"`
|
||||
Value string `json:"value"`
|
||||
Inline bool `json:"inline"`
|
||||
}
|
||||
|
||||
type actionRow struct {
|
||||
Type int `json:"type"`
|
||||
Components []button `json:"components"`
|
||||
}
|
||||
|
||||
type button struct {
|
||||
Type int `json:"type"`
|
||||
Style int `json:"style"`
|
||||
Label string `json:"label"`
|
||||
URL string `json:"url"`
|
||||
}
|
||||
|
||||
type allowedMentions struct {
|
||||
Parse []string `json:"parse"`
|
||||
}
|
||||
|
||||
type translations struct {
|
||||
titlePrefix string
|
||||
startsAt string
|
||||
deadline string
|
||||
registerButton string
|
||||
}
|
||||
|
||||
func New(config Config) (*Announcer, error) {
|
||||
if strings.TrimSpace(config.BotToken) == "" {
|
||||
return nil, errors.New("discord bot token is required")
|
||||
}
|
||||
if strings.TrimSpace(config.ChannelID) == "" {
|
||||
return nil, errors.New("discord announcement channel ID is required")
|
||||
}
|
||||
publicURL, err := url.Parse(strings.TrimSpace(config.PublicURL))
|
||||
if err != nil || (publicURL.Scheme != "http" && publicURL.Scheme != "https") || publicURL.Host == "" {
|
||||
return nil, errors.New("discord announcement public URL must be an absolute HTTP(S) URL")
|
||||
}
|
||||
locale := strings.ToLower(strings.TrimSpace(config.Locale))
|
||||
if locale == "" {
|
||||
locale = "ru"
|
||||
}
|
||||
if locale != "ru" && locale != "en" {
|
||||
return nil, fmt.Errorf("unsupported discord announcement locale %q", locale)
|
||||
}
|
||||
apiBaseURL := strings.TrimRight(config.APIBaseURL, "/")
|
||||
if apiBaseURL == "" {
|
||||
apiBaseURL = defaultAPIBaseURL
|
||||
}
|
||||
httpClient := config.HTTPClient
|
||||
if httpClient == nil {
|
||||
httpClient = http.DefaultClient
|
||||
}
|
||||
return &Announcer{
|
||||
botToken: config.BotToken,
|
||||
channelID: config.ChannelID,
|
||||
publicURL: strings.TrimRight(strings.TrimSpace(config.PublicURL), "/"),
|
||||
locale: locale,
|
||||
apiBaseURL: apiBaseURL,
|
||||
httpClient: httpClient,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (a *Announcer) AnnounceEventCreated(ctx context.Context, event domain.Event) error {
|
||||
err := a.announceEventCreated(ctx, event)
|
||||
if err != nil {
|
||||
slog.Error("Discord event announcement failed", "event_id", event.ID, "error", err)
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
func (a *Announcer) announceEventCreated(ctx context.Context, event domain.Event) error {
|
||||
payload, err := json.Marshal(a.createdMessage(event))
|
||||
if err != nil {
|
||||
return fmt.Errorf("encode Discord message: %w", err)
|
||||
}
|
||||
endpoint := fmt.Sprintf("%s/channels/%s/messages", a.apiBaseURL, url.PathEscape(a.channelID))
|
||||
request, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, bytes.NewReader(payload))
|
||||
if err != nil {
|
||||
return fmt.Errorf("create Discord request: %w", err)
|
||||
}
|
||||
request.Header.Set("Authorization", "Bot "+a.botToken)
|
||||
request.Header.Set("Content-Type", "application/json")
|
||||
|
||||
response, err := a.httpClient.Do(request)
|
||||
if err != nil {
|
||||
return fmt.Errorf("send Discord message: %w", err)
|
||||
}
|
||||
defer response.Body.Close()
|
||||
if response.StatusCode < http.StatusOK || response.StatusCode >= http.StatusMultipleChoices {
|
||||
body, _ := io.ReadAll(io.LimitReader(response.Body, 4096))
|
||||
return fmt.Errorf("Discord returned %s: %s", response.Status, strings.TrimSpace(string(body)))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (a *Announcer) createdMessage(event domain.Event) messagePayload {
|
||||
text := announcementTranslations(a.locale)
|
||||
eventURL := a.publicURL + "/events/" + url.PathEscape(event.ID)
|
||||
return messagePayload{
|
||||
Content: "@everyone",
|
||||
Embeds: []embed{{
|
||||
Title: truncate(text.titlePrefix+event.Name, 256),
|
||||
Description: truncate(event.Description, 4096),
|
||||
URL: eventURL,
|
||||
Color: 0xe9358f,
|
||||
Fields: []embedField{
|
||||
{Name: text.startsAt, Value: fmt.Sprintf("<t:%d:F> · <t:%d:R>", event.StartsAt.Unix(), event.StartsAt.Unix()), Inline: true},
|
||||
{Name: text.deadline, Value: fmt.Sprintf("<t:%d:F>", event.RegistrationDeadline.Unix()), Inline: true},
|
||||
},
|
||||
}},
|
||||
Components: []actionRow{{
|
||||
Type: 1,
|
||||
Components: []button{{
|
||||
Type: 2,
|
||||
Style: 5,
|
||||
Label: text.registerButton,
|
||||
URL: eventURL,
|
||||
}},
|
||||
}},
|
||||
AllowedMentions: allowedMentions{Parse: []string{"everyone"}},
|
||||
}
|
||||
}
|
||||
|
||||
func announcementTranslations(locale string) translations {
|
||||
if locale == "en" {
|
||||
return translations{
|
||||
titlePrefix: "New mix: ",
|
||||
startsAt: "Starts",
|
||||
deadline: "Registration deadline",
|
||||
registerButton: "Register",
|
||||
}
|
||||
}
|
||||
return translations{
|
||||
titlePrefix: "Новый микс: ",
|
||||
startsAt: "Начало",
|
||||
deadline: "Регистрация до",
|
||||
registerButton: "Зарегистрироваться",
|
||||
}
|
||||
}
|
||||
|
||||
func truncate(value string, maxRunes int) string {
|
||||
if utf8.RuneCountInString(value) <= maxRunes {
|
||||
return value
|
||||
}
|
||||
runes := []rune(value)
|
||||
return string(runes[:maxRunes-1]) + "…"
|
||||
}
|
||||
97
backend/internal/adapter/discord/announcer_test.go
Normal file
97
backend/internal/adapter/discord/announcer_test.go
Normal file
@@ -0,0 +1,97 @@
|
||||
package discord
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"mixmaker/backend/internal/domain"
|
||||
)
|
||||
|
||||
func TestAnnounceEventCreated(t *testing.T) {
|
||||
var received messagePayload
|
||||
server := httptest.NewServer(http.HandlerFunc(func(response http.ResponseWriter, request *http.Request) {
|
||||
if request.Method != http.MethodPost || request.URL.Path != "/channels/channel-123/messages" {
|
||||
t.Errorf("unexpected request: %s %s", request.Method, request.URL.Path)
|
||||
}
|
||||
if authorization := request.Header.Get("Authorization"); authorization != "Bot secret-token" {
|
||||
t.Errorf("unexpected Authorization header: %q", authorization)
|
||||
}
|
||||
if err := json.NewDecoder(request.Body).Decode(&received); err != nil {
|
||||
t.Errorf("decode payload: %v", err)
|
||||
}
|
||||
response.WriteHeader(http.StatusCreated)
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
announcer, err := New(Config{
|
||||
BotToken: "secret-token",
|
||||
ChannelID: "channel-123",
|
||||
PublicURL: "https://mix.example.com",
|
||||
Locale: "ru",
|
||||
APIBaseURL: server.URL,
|
||||
HTTPClient: server.Client(),
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
start := time.Date(2026, time.July, 20, 18, 0, 0, 0, time.UTC)
|
||||
event := domain.Event{
|
||||
ID: "event-456",
|
||||
Name: "Воскресный микс",
|
||||
Description: "Собираемся на Bo3",
|
||||
StartsAt: start,
|
||||
RegistrationDeadline: start.Add(-2 * time.Hour),
|
||||
}
|
||||
|
||||
if err := announcer.AnnounceEventCreated(context.Background(), event); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if received.Content != "@everyone" {
|
||||
t.Fatalf("unexpected content: %q", received.Content)
|
||||
}
|
||||
if len(received.AllowedMentions.Parse) != 1 || received.AllowedMentions.Parse[0] != "everyone" {
|
||||
t.Fatalf("unexpected allowed mentions: %+v", received.AllowedMentions)
|
||||
}
|
||||
if len(received.Embeds) != 1 {
|
||||
t.Fatalf("expected one embed, got %d", len(received.Embeds))
|
||||
}
|
||||
eventURL := "https://mix.example.com/events/event-456"
|
||||
if received.Embeds[0].Title != "Новый микс: Воскресный микс" ||
|
||||
received.Embeds[0].Description != event.Description ||
|
||||
received.Embeds[0].URL != eventURL ||
|
||||
len(received.Embeds[0].Fields) != 2 {
|
||||
t.Fatalf("unexpected embed: %+v", received.Embeds[0])
|
||||
}
|
||||
if len(received.Components) != 1 || len(received.Components[0].Components) != 1 {
|
||||
t.Fatalf("unexpected components: %+v", received.Components)
|
||||
}
|
||||
linkButton := received.Components[0].Components[0]
|
||||
if linkButton.Style != 5 || linkButton.Label != "Зарегистрироваться" || linkButton.URL != eventURL {
|
||||
t.Fatalf("unexpected registration button: %+v", linkButton)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAnnounceEventCreatedReturnsDiscordError(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(response http.ResponseWriter, _ *http.Request) {
|
||||
http.Error(response, `{"message":"Missing Permissions"}`, http.StatusForbidden)
|
||||
}))
|
||||
defer server.Close()
|
||||
announcer, err := New(Config{
|
||||
BotToken: "secret-token",
|
||||
ChannelID: "channel-123",
|
||||
PublicURL: "https://mix.example.com",
|
||||
APIBaseURL: server.URL,
|
||||
HTTPClient: server.Client(),
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if err := announcer.AnnounceEventCreated(context.Background(), domain.Event{ID: "event-456"}); err == nil {
|
||||
t.Fatal("expected Discord error")
|
||||
}
|
||||
}
|
||||
@@ -93,6 +93,11 @@ func New(service *application.Service, store application.Store, hub *realtime.Hu
|
||||
api.Post("/api/events/{eventID}/roster/emergency-substitute", s.emergencySubstitute)
|
||||
api.Put("/api/events/{eventID}/roster/captain", s.setRosterCaptain)
|
||||
api.Post("/api/events/{eventID}/roster/confirm", s.confirmRosters)
|
||||
api.Get("/api/events/{eventID}/bracket-draft", s.getBracketDraft)
|
||||
api.Post("/api/events/{eventID}/bracket-draft/initialize", s.initializeBracket)
|
||||
api.Put("/api/events/{eventID}/bracket-draft", s.updateBracket)
|
||||
api.Post("/api/events/{eventID}/bracket-draft/reset", s.resetBracket)
|
||||
api.Post("/api/events/{eventID}/bracket-draft/confirm", s.confirmBracket)
|
||||
api.Post("/api/events/{eventID}/workflow/back", s.revertWorkflowStage)
|
||||
api.Post("/api/events/{eventID}/start", s.startScrim)
|
||||
api.Put("/api/teams/{teamID}/captain", s.assignCaptain)
|
||||
|
||||
@@ -168,6 +168,56 @@ func (s *Server) confirmRosters(w http.ResponseWriter, r *http.Request) {
|
||||
respond(w, out, err, http.StatusOK)
|
||||
}
|
||||
|
||||
func (s *Server) getBracketDraft(w http.ResponseWriter, r *http.Request) {
|
||||
out, err := s.store.GetBracketDraft(r.Context(), chi.URLParam(r, "eventID"))
|
||||
respond(w, out, err, http.StatusOK)
|
||||
}
|
||||
|
||||
func (s *Server) initializeBracket(w http.ResponseWriter, r *http.Request) {
|
||||
var in struct {
|
||||
ExpectedVersion int `json:"expectedVersion"`
|
||||
}
|
||||
if !decode(w, r, &in) {
|
||||
return
|
||||
}
|
||||
out, err := s.service.InitializeBracket(r.Context(), who(r).account, chi.URLParam(r, "eventID"), in.ExpectedVersion)
|
||||
respond(w, out, err, http.StatusCreated)
|
||||
}
|
||||
|
||||
func (s *Server) updateBracket(w http.ResponseWriter, r *http.Request) {
|
||||
var in struct {
|
||||
Matches []domain.BracketMatch `json:"matches"`
|
||||
ExpectedVersion int `json:"expectedVersion"`
|
||||
}
|
||||
if !decode(w, r, &in) {
|
||||
return
|
||||
}
|
||||
out, err := s.service.UpdateBracket(r.Context(), who(r).account, chi.URLParam(r, "eventID"), in.Matches, in.ExpectedVersion)
|
||||
respond(w, out, err, http.StatusOK)
|
||||
}
|
||||
|
||||
func (s *Server) resetBracket(w http.ResponseWriter, r *http.Request) {
|
||||
var in struct {
|
||||
ExpectedVersion int `json:"expectedVersion"`
|
||||
}
|
||||
if !decode(w, r, &in) {
|
||||
return
|
||||
}
|
||||
out, err := s.service.ResetBracket(r.Context(), who(r).account, chi.URLParam(r, "eventID"), in.ExpectedVersion)
|
||||
respond(w, out, err, http.StatusOK)
|
||||
}
|
||||
|
||||
func (s *Server) confirmBracket(w http.ResponseWriter, r *http.Request) {
|
||||
var in struct {
|
||||
ExpectedVersion int `json:"expectedVersion"`
|
||||
}
|
||||
if !decode(w, r, &in) {
|
||||
return
|
||||
}
|
||||
out, err := s.service.ConfirmBracket(r.Context(), who(r).account, chi.URLParam(r, "eventID"), in.ExpectedVersion)
|
||||
respond(w, out, err, http.StatusOK)
|
||||
}
|
||||
|
||||
func (s *Server) revertWorkflowStage(w http.ResponseWriter, r *http.Request) {
|
||||
var in struct {
|
||||
ExpectedVersion int `json:"expectedVersion"`
|
||||
|
||||
@@ -371,6 +371,40 @@ func (s *Store) ResetRoster(ctx context.Context, eventID string) error {
|
||||
return tx.Commit(ctx)
|
||||
}
|
||||
|
||||
func (s *Store) SaveBracketDraft(ctx context.Context, draft domain.BracketDraft, expectedVersion int) (domain.BracketDraft, error) {
|
||||
body, _ := json.Marshal(draft)
|
||||
if expectedVersion < 0 {
|
||||
_, err := s.pool.Exec(ctx, `INSERT INTO event_bracket_drafts(event_id,body,version,confirmed) VALUES($1,$2,$3,$4)
|
||||
ON CONFLICT(event_id) DO UPDATE SET body=excluded.body,version=excluded.version,confirmed=excluded.confirmed,updated_at=now()`,
|
||||
draft.EventID, body, draft.Version, draft.Confirmed)
|
||||
return draft, err
|
||||
}
|
||||
tag, err := s.pool.Exec(ctx, `UPDATE event_bracket_drafts SET body=$2,version=$3,confirmed=$4,updated_at=now()
|
||||
WHERE event_id=$1 AND version=$5`, draft.EventID, body, draft.Version, draft.Confirmed, expectedVersion)
|
||||
if err == nil && tag.RowsAffected() == 0 {
|
||||
err = domain.ErrConflict
|
||||
}
|
||||
return draft, err
|
||||
}
|
||||
|
||||
func (s *Store) GetBracketDraft(ctx context.Context, eventID string) (domain.BracketDraft, error) {
|
||||
var body []byte
|
||||
var version int
|
||||
var confirmed bool
|
||||
var draft domain.BracketDraft
|
||||
err := s.pool.QueryRow(ctx, `SELECT body,version,confirmed FROM event_bracket_drafts WHERE event_id=$1`, eventID).Scan(&body, &version, &confirmed)
|
||||
if err == nil {
|
||||
err = json.Unmarshal(body, &draft)
|
||||
draft.Version, draft.Confirmed = version, confirmed
|
||||
}
|
||||
return draft, mapError(err)
|
||||
}
|
||||
|
||||
func (s *Store) DeleteBracketDraft(ctx context.Context, eventID string) error {
|
||||
_, err := s.pool.Exec(ctx, `DELETE FROM event_bracket_drafts WHERE event_id=$1`, eventID)
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *Store) StartScrim(ctx context.Context, event domain.Event, expectedVersion int, series []domain.Series, tournament *domain.Tournament) error {
|
||||
tx, err := s.pool.Begin(ctx)
|
||||
if err != nil {
|
||||
@@ -386,8 +420,8 @@ func (s *Store) StartScrim(ctx context.Context, event domain.Event, expectedVers
|
||||
}
|
||||
if tournament != nil {
|
||||
body, _ := json.Marshal(tournament)
|
||||
if _, err = tx.Exec(ctx, `INSERT INTO tournaments(id,event_id,body) VALUES($1,$2,$3)`,
|
||||
tournament.ID, tournament.EventID, body); err != nil {
|
||||
if _, err = tx.Exec(ctx, `INSERT INTO tournaments(id,event_id,body,version) VALUES($1,$2,$3,$4)`,
|
||||
tournament.ID, tournament.EventID, body, tournament.Version); err != nil {
|
||||
return mapError(err)
|
||||
}
|
||||
}
|
||||
@@ -600,8 +634,12 @@ func (s *Store) GetSeries(ctx context.Context, id string) (domain.Series, error)
|
||||
|
||||
func (s *Store) SaveTournament(ctx context.Context, t domain.Tournament) (domain.Tournament, error) {
|
||||
body, _ := json.Marshal(t)
|
||||
_, err := s.pool.Exec(ctx, `INSERT INTO tournaments(id,event_id,body) VALUES($1,$2,$3)
|
||||
ON CONFLICT(id) DO UPDATE SET body=excluded.body`, t.ID, t.EventID, body)
|
||||
tag, err := s.pool.Exec(ctx, `INSERT INTO tournaments(id,event_id,body,version) VALUES($1,$2,$3,$4)
|
||||
ON CONFLICT(id) DO UPDATE SET body=excluded.body,version=excluded.version
|
||||
WHERE excluded.version=0 OR tournaments.version=excluded.version-1`, t.ID, t.EventID, body, t.Version)
|
||||
if err == nil && tag.RowsAffected() == 0 {
|
||||
err = domain.ErrConflict
|
||||
}
|
||||
return t, err
|
||||
}
|
||||
|
||||
@@ -652,6 +690,24 @@ func (s *Store) hydrateTournament(ctx context.Context, tournament *domain.Tourna
|
||||
if err := rows.Err(); err != nil {
|
||||
return err
|
||||
}
|
||||
if len(tournament.Matches) > 0 {
|
||||
for index := range tournament.Matches {
|
||||
match := &tournament.Matches[index]
|
||||
if series, ok := live[match.SeriesID]; ok {
|
||||
match.TeamAID, match.TeamBID, match.WinnerTeamID = series.TeamAID, series.TeamBID, series.WinnerTeamID
|
||||
match.Series = &series
|
||||
} else if series, ok := live[match.ID]; ok {
|
||||
match.SeriesID = series.ID
|
||||
match.TeamAID, match.TeamBID, match.WinnerTeamID = series.TeamAID, series.TeamBID, series.WinnerTeamID
|
||||
match.Series = &series
|
||||
}
|
||||
}
|
||||
if err := tournament.Resolve(); err != nil {
|
||||
return err
|
||||
}
|
||||
tournament.BuildRounds()
|
||||
return nil
|
||||
}
|
||||
for round := range tournament.Rounds {
|
||||
for match := range tournament.Rounds[round] {
|
||||
if series, ok := live[tournament.Rounds[round][match].ID]; ok {
|
||||
|
||||
@@ -47,6 +47,13 @@ func TestMigrationsAndReadiness(t *testing.T) {
|
||||
if !rosterTable {
|
||||
t.Fatal("event_rosters table is missing")
|
||||
}
|
||||
var bracketTable bool
|
||||
if err := store.pool.QueryRow(ctx, `SELECT to_regclass('public.event_bracket_drafts') IS NOT NULL`).Scan(&bracketTable); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !bracketTable {
|
||||
t.Fatal("event_bracket_drafts table is missing")
|
||||
}
|
||||
}
|
||||
|
||||
func TestFullScrimPipeline(t *testing.T) {
|
||||
@@ -84,7 +91,7 @@ func TestFullScrimPipeline(t *testing.T) {
|
||||
_, _ = store.pool.Exec(ctx, `DELETE FROM accounts WHERE id=$1`, accountID)
|
||||
}()
|
||||
|
||||
service := application.New(store, discardPublisher{})
|
||||
service := application.New(store, discardPublisher{}, nil)
|
||||
admin := domain.Account{ID: accountID, Role: domain.RoleAdmin}
|
||||
now := time.Now().UTC()
|
||||
event, err := service.CreateEvent(ctx, admin, domain.Event{
|
||||
@@ -130,6 +137,18 @@ func TestFullScrimPipeline(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
draft, err := service.InitializeBracket(ctx, admin, eventID, event.Version)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
draft, err = service.ConfirmBracket(ctx, admin, eventID, draft.Version)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
event, err = store.GetEvent(ctx, eventID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
started, err := service.StartScrim(ctx, admin, eventID, event.Version)
|
||||
if err != nil || started.Series == nil {
|
||||
t.Fatalf("start failed: %#v, %v", started, err)
|
||||
@@ -200,7 +219,7 @@ func TestParallelTournamentHydratesLiveSeriesAndFinal(t *testing.T) {
|
||||
_, _ = store.pool.Exec(ctx, `DELETE FROM events WHERE id=$1`, eventID)
|
||||
_, _ = store.pool.Exec(ctx, `DELETE FROM accounts WHERE id=$1`, accountID)
|
||||
}()
|
||||
service := application.New(store, discardPublisher{})
|
||||
service := application.New(store, discardPublisher{}, nil)
|
||||
event, err := service.CreateEvent(ctx, domain.Account{ID: accountID, Role: domain.RoleAdmin}, domain.Event{
|
||||
ID: eventID, Name: "Parallel tournament", StartsAt: time.Now().Add(time.Hour), EndsAt: time.Now().Add(3 * time.Hour),
|
||||
RegistrationDeadline: time.Now(), RulesetID: "standard-control-hybrid-control",
|
||||
@@ -213,15 +232,15 @@ func TestParallelTournamentHydratesLiveSeriesAndFinal(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
tournament, err := domain.NewTournament(tournamentID, eventID, event.Name, []string{"team-a", "team-b", "team-c", "team-d"})
|
||||
draft, err := domain.NewBracketDraft(eventID, []string{"team-a", "team-b", "team-c", "team-d"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err = store.SaveTournament(ctx, *tournament); err != nil {
|
||||
tournament, err := domain.NewGraphTournament(tournamentID, eventID, event.Name, *draft)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for match := range tournament.Rounds[0] {
|
||||
slot := tournament.Rounds[0][match]
|
||||
for match, slot := range tournament.ReadyMatches() {
|
||||
series, createErr := domain.NewSeries(slot.ID, eventID, tournamentID, [2]string{slot.TeamAID, slot.TeamBID}, rules)
|
||||
if createErr != nil {
|
||||
t.Fatal(createErr)
|
||||
@@ -234,6 +253,16 @@ func TestParallelTournamentHydratesLiveSeriesAndFinal(t *testing.T) {
|
||||
if _, createErr = store.SaveSeries(ctx, *series); createErr != nil {
|
||||
t.Fatal(createErr)
|
||||
}
|
||||
for index := range tournament.Matches {
|
||||
if tournament.Matches[index].ID == slot.ID {
|
||||
tournament.Matches[index].SeriesID = series.ID
|
||||
tournament.Matches[index].Series = series
|
||||
}
|
||||
}
|
||||
}
|
||||
tournament.BuildRounds()
|
||||
if _, err = store.SaveTournament(ctx, *tournament); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
hydrated, err := store.GetTournamentByEvent(ctx, eventID)
|
||||
if err != nil {
|
||||
@@ -242,17 +271,23 @@ func TestParallelTournamentHydratesLiveSeriesAndFinal(t *testing.T) {
|
||||
if hydrated.Rounds[0][0].Phase != domain.MapBanPhase || hydrated.Rounds[0][1].Phase != domain.CoinTossPending {
|
||||
t.Fatalf("parallel series were not hydrated independently: %s / %s", hydrated.Rounds[0][0].Phase, hydrated.Rounds[0][1].Phase)
|
||||
}
|
||||
for match := range hydrated.Rounds[0] {
|
||||
hydrated.Rounds[0][match].WinnerTeamID = hydrated.Rounds[0][match].TeamAID
|
||||
hydrated.Rounds[0][match].Phase = domain.SeriesComplete
|
||||
if err = hydrated.Advance(0, match); err != nil {
|
||||
for index := range hydrated.Matches {
|
||||
if hydrated.Matches[index].Round != 0 || hydrated.Matches[index].Series == nil {
|
||||
continue
|
||||
}
|
||||
completed := *hydrated.Matches[index].Series
|
||||
completed.WinnerTeamID, completed.Phase, completed.Version = completed.TeamAID, domain.SeriesComplete, completed.Version+1
|
||||
if _, err = store.SaveSeries(ctx, completed); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err = hydrated.ApplySeries(completed); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err = store.SaveTournament(ctx, hydrated); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
if _, err = store.SaveTournament(ctx, hydrated); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
finalSlot := hydrated.Rounds[1][0]
|
||||
finalSlot := hydrated.ReadyMatches()[0]
|
||||
final, err := domain.NewSeries(finalSlot.ID, eventID, tournamentID, [2]string{finalSlot.TeamAID, finalSlot.TeamBID}, rules)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
@@ -261,8 +296,8 @@ func TestParallelTournamentHydratesLiveSeriesAndFinal(t *testing.T) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
hydrated, err = store.GetTournamentByEvent(ctx, eventID)
|
||||
if err != nil || hydrated.Rounds[1][0].Phase != domain.CoinTossPending {
|
||||
t.Fatalf("final was not persisted and hydrated: phase=%s err=%v", hydrated.Rounds[1][0].Phase, err)
|
||||
if err != nil || hydrated.Matches[len(hydrated.Matches)-1].Series == nil || hydrated.Matches[len(hydrated.Matches)-1].Series.Phase != domain.CoinTossPending {
|
||||
t.Fatalf("final was not persisted and hydrated: err=%v", err)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user