449 lines
16 KiB
Go
449 lines
16 KiB
Go
package postgres
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"os"
|
|
"testing"
|
|
"time"
|
|
|
|
"mixmaker/backend/internal/application"
|
|
"mixmaker/backend/internal/domain"
|
|
)
|
|
|
|
type discardPublisher struct{}
|
|
|
|
func (discardPublisher) Publish(string, any) {}
|
|
|
|
func TestMigrationsAndReadiness(t *testing.T) {
|
|
databaseURL := os.Getenv("TEST_DATABASE_URL")
|
|
if databaseURL == "" {
|
|
t.Skip("TEST_DATABASE_URL is not configured")
|
|
}
|
|
ctx := context.Background()
|
|
if err := Migrate(ctx, databaseURL, "../../../migrations"); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
store, err := Open(ctx, databaseURL)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
defer store.Close()
|
|
if err := store.Ready(ctx); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
var workflowColumns int
|
|
if err := store.pool.QueryRow(ctx, `SELECT count(*) FROM information_schema.columns
|
|
WHERE table_name='events' AND column_name IN ('state','version','ruleset_id','active_series_id','tournament_id')`).Scan(&workflowColumns); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if workflowColumns != 5 {
|
|
t.Fatalf("workflow migration is incomplete: found %d event columns", workflowColumns)
|
|
}
|
|
var rosterTable bool
|
|
if err := store.pool.QueryRow(ctx, `SELECT to_regclass('public.event_rosters') IS NOT NULL`).Scan(&rosterTable); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
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")
|
|
}
|
|
for _, table := range []string{"discord_managed_roles", "discord_role_assignments", "discord_role_sync_jobs"} {
|
|
var exists bool
|
|
if err := store.pool.QueryRow(ctx, `SELECT to_regclass('public.' || $1) IS NOT NULL`, table).Scan(&exists); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if !exists {
|
|
t.Fatalf("%s table is missing", table)
|
|
}
|
|
}
|
|
var identityColumns int
|
|
if err := store.pool.QueryRow(ctx, `SELECT count(*) FROM information_schema.columns
|
|
WHERE table_name='players' AND column_name IN ('display_name','battle_tag')`).Scan(&identityColumns); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if identityColumns != 2 {
|
|
t.Fatalf("player identity migration is incomplete: found %d columns", identityColumns)
|
|
}
|
|
var settingsTable, nicknameIndex, seriesEventIndex bool
|
|
if err := store.pool.QueryRow(ctx, `SELECT
|
|
to_regclass('public.community_settings') IS NOT NULL,
|
|
to_regclass('public.players_display_name_lower_uidx') IS NOT NULL,
|
|
to_regclass('public.series_event_id_idx') IS NOT NULL`).Scan(&settingsTable, &nicknameIndex, &seriesEventIndex); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if !settingsTable || !nicknameIndex || !seriesEventIndex {
|
|
t.Fatalf("profile/settings migrations missing: settings=%v nickname=%v series=%v", settingsTable, nicknameIndex, seriesEventIndex)
|
|
}
|
|
}
|
|
|
|
func TestDiscordRoleSyncPersistence(t *testing.T) {
|
|
databaseURL := os.Getenv("TEST_DATABASE_URL")
|
|
if databaseURL == "" {
|
|
t.Skip("TEST_DATABASE_URL is not configured")
|
|
}
|
|
ctx := context.Background()
|
|
if err := Migrate(ctx, databaseURL, "../../../migrations"); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
store, err := Open(ctx, databaseURL)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
defer store.Close()
|
|
eventID := fmt.Sprintf("discord-sync-%d", time.Now().UnixNano())
|
|
role := application.DiscordManagedRole{
|
|
Scope: application.DiscordRoleScopeEvent, EventID: eventID, TeamID: "team-1",
|
|
Kind: application.DiscordRoleKindTeam, DiscordRoleID: eventID + "-role", RoleName: "Alpha",
|
|
}
|
|
defer func() {
|
|
_, _ = store.pool.Exec(ctx, `DELETE FROM discord_role_sync_jobs WHERE event_id=$1`, eventID)
|
|
_, _ = store.pool.Exec(ctx, `DELETE FROM discord_managed_roles WHERE event_id=$1`, eventID)
|
|
}()
|
|
if err = store.UpsertDiscordManagedRole(ctx, role); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err = store.UpsertDiscordRoleAssignment(ctx, role.DiscordRoleID, "discord-user"); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
assignments, err := store.ListDiscordRoleAssignments(ctx, role.DiscordRoleID)
|
|
if err != nil || len(assignments) != 1 || assignments[0] != "discord-user" {
|
|
t.Fatalf("unexpected assignments: %v err=%v", assignments, err)
|
|
}
|
|
if _, err = store.pool.Exec(ctx, `UPDATE discord_role_sync_jobs SET status='completed' WHERE status='pending'`); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err = store.EnqueueDiscordRoleSync(ctx, eventID, application.DiscordRoleActionReconcile, 1); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
job, err := store.ClaimDiscordRoleSyncJob(ctx)
|
|
if err != nil || job.EventID != eventID || len(job.RoleSnapshot) != 1 || job.Attempts != 1 {
|
|
t.Fatalf("unexpected claimed job: %+v err=%v", job, err)
|
|
}
|
|
if err = store.RetryDiscordRoleSyncJob(ctx, job.ID, "retry", time.Now().Add(-time.Second)); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
job, err = store.ClaimDiscordRoleSyncJob(ctx)
|
|
if err != nil || job.Attempts != 2 {
|
|
t.Fatalf("unexpected retried job: %+v err=%v", job, err)
|
|
}
|
|
if err = store.CompleteDiscordRoleSyncJob(ctx, job.ID, "warning"); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
var status, warning string
|
|
if err = store.pool.QueryRow(ctx, `SELECT status,last_error FROM discord_role_sync_jobs WHERE id=$1`, job.ID).Scan(&status, &warning); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if status != "completed" || warning != "warning" {
|
|
t.Fatalf("unexpected completed job state: status=%s warning=%s", status, warning)
|
|
}
|
|
bucket := time.Now().UnixNano()
|
|
defer func() {
|
|
_, _ = store.pool.Exec(ctx, `DELETE FROM discord_role_sync_jobs
|
|
WHERE event_id='__global__' AND action='full_reconcile' AND generation=$1`, bucket)
|
|
}()
|
|
if err = store.ScheduleGlobalDiscordRoleSync(ctx, bucket); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err = store.ScheduleGlobalDiscordRoleSync(ctx, bucket); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
var bucketJobs int
|
|
if err = store.pool.QueryRow(ctx, `SELECT count(*) FROM discord_role_sync_jobs
|
|
WHERE event_id='__global__' AND action='full_reconcile' AND generation=$1`, bucket).Scan(&bucketJobs); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if bucketJobs != 1 {
|
|
t.Fatalf("expected one global bucket job, got %d", bucketJobs)
|
|
}
|
|
job, err = store.ClaimDiscordRoleSyncJob(ctx)
|
|
if err != nil || job.Action != application.DiscordRoleActionFullReconcile || job.Generation != bucket {
|
|
t.Fatalf("unexpected global job: %+v err=%v", job, err)
|
|
}
|
|
}
|
|
|
|
func TestFullScrimPipeline(t *testing.T) {
|
|
databaseURL := os.Getenv("TEST_DATABASE_URL")
|
|
if databaseURL == "" {
|
|
t.Skip("TEST_DATABASE_URL is not configured")
|
|
}
|
|
ctx := context.Background()
|
|
if err := Migrate(ctx, databaseURL, "../../../migrations"); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
store, err := Open(ctx, databaseURL)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
defer store.Close()
|
|
|
|
suffix := fmt.Sprintf("%d", time.Now().UnixNano())
|
|
accountID, eventID := "pipeline-admin-"+suffix, "pipeline-event-"+suffix
|
|
playerIDs, seriesIDs := make([]string, 0, 10), make([]string, 0, 1)
|
|
_, err = store.pool.Exec(ctx, `INSERT INTO accounts(id,discord_id,username,avatar_url,role,created_at)
|
|
VALUES($1,$2,'Pipeline Admin','','admin',now())`, accountID, accountID)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
defer func() {
|
|
_, _ = store.pool.Exec(ctx, `DELETE FROM audit_log WHERE actor_account_id=$1`, accountID)
|
|
_, _ = store.pool.Exec(ctx, `DELETE FROM events WHERE id=$1`, eventID)
|
|
_, _ = store.pool.Exec(ctx, `DELETE FROM discord_role_sync_jobs WHERE event_id=$1`, eventID)
|
|
for _, seriesID := range seriesIDs {
|
|
_, _ = store.pool.Exec(ctx, `DELETE FROM series WHERE id=$1`, seriesID)
|
|
}
|
|
for _, playerID := range playerIDs {
|
|
_, _ = store.pool.Exec(ctx, `DELETE FROM players WHERE id=$1`, playerID)
|
|
}
|
|
_, _ = store.pool.Exec(ctx, `DELETE FROM accounts WHERE id=$1`, accountID)
|
|
}()
|
|
|
|
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{
|
|
ID: eventID, Name: "Pipeline", StartsAt: now.Add(24 * time.Hour), EndsAt: now.Add(28 * time.Hour),
|
|
RegistrationDeadline: now.Add(23 * time.Hour), RulesetID: "standard-control-hybrid-control",
|
|
}, application.CreateEventOptions{})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
// CreateEvent owns the identifier, so use the returned value from here onward.
|
|
eventID = event.ID
|
|
for i := 0; i < 10; i++ {
|
|
player, _, createErr := service.CreateParticipant(ctx, admin, eventID, fmt.Sprintf("Player %d", i+1),
|
|
domain.Ratings{Tank: 18 + i%5, Damage: 19 + i%5, Support: 20 + i%5}, domain.Going)
|
|
if createErr != nil {
|
|
t.Fatal(createErr)
|
|
}
|
|
playerIDs = append(playerIDs, player.ID)
|
|
}
|
|
event, err = service.CloseRegistration(ctx, admin, eventID, event.RulesetID, event.Version)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
balance, err := service.GenerateBalance(ctx, admin, eventID, event.Version)
|
|
if err != nil || len(balance.Candidates) == 0 {
|
|
t.Fatalf("balance failed: candidates=%d err=%v", len(balance.Candidates), err)
|
|
}
|
|
roster, err := service.SelectWorkflowBalance(ctx, admin, eventID, balance.Candidates[0], balance.Event.Version)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
for _, team := range roster.Teams {
|
|
roster, err = service.SetRosterCaptain(ctx, admin, eventID, team.ID, team.Slots[0].PlayerID, roster.Version)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}
|
|
event, err = store.GetEvent(ctx, eventID)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
event, err = service.ConfirmRosters(ctx, admin, eventID, event.Version, roster.Version)
|
|
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)
|
|
}
|
|
seriesIDs = append(seriesIDs, started.Series.ID)
|
|
series, err := service.TossSeriesCoin(ctx, admin, domain.Player{}, started.Series.ID, "integration-seed", started.Series.TeamAID, started.Series.Version)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
for mapNumber := 0; mapNumber < 2; mapNumber++ {
|
|
if series.Phase == domain.MapPickPhase {
|
|
series, err = service.PickSeriesMap(ctx, admin, domain.Player{}, series.ID, series.NextMapPickerID, series.AvailableMaps[0], series.Version)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}
|
|
for series.Phase != domain.HeroBanPhase {
|
|
name := firstUnbanned(series.MapDraft.Pool, series.MapDraft.Banned)
|
|
series, err = service.BanSeriesMap(ctx, admin, domain.Player{}, series.ID, series.MapDraft.NextTeam(), name, series.Version)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}
|
|
for series.Phase != domain.PlayingPhase {
|
|
teamID := series.HeroDraft.NextTeam()
|
|
hero := firstAvailableHero(series.HeroDraft, teamID)
|
|
series, err = service.BanSeriesHero(ctx, admin, domain.Player{}, series.ID, teamID, hero, series.Version)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}
|
|
series, err = service.RecordSeriesResult(ctx, admin, domain.Player{}, series.ID, series.TeamAID, domain.TeamAWin, series.Version)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}
|
|
if series.Phase != domain.SeriesComplete {
|
|
t.Fatalf("series did not complete: %s", series.Phase)
|
|
}
|
|
event, err = store.GetEvent(ctx, eventID)
|
|
if err != nil || event.State != domain.Completed {
|
|
t.Fatalf("event did not complete: state=%s err=%v", event.State, err)
|
|
}
|
|
}
|
|
|
|
func TestParallelTournamentHydratesLiveSeriesAndFinal(t *testing.T) {
|
|
databaseURL := os.Getenv("TEST_DATABASE_URL")
|
|
if databaseURL == "" {
|
|
t.Skip("TEST_DATABASE_URL is not configured")
|
|
}
|
|
ctx := context.Background()
|
|
if err := Migrate(ctx, databaseURL, "../../../migrations"); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
store, err := Open(ctx, databaseURL)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
defer store.Close()
|
|
suffix := fmt.Sprintf("%d", time.Now().UnixNano())
|
|
accountID, eventID, tournamentID := "parallel-admin-"+suffix, "parallel-event-"+suffix, "parallel-tournament-"+suffix
|
|
_, err = store.pool.Exec(ctx, `INSERT INTO accounts(id,discord_id,username,avatar_url,role,created_at) VALUES($1,$1,'Parallel Admin','','admin',now())`, accountID)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
defer func() {
|
|
_, _ = store.pool.Exec(ctx, `DELETE FROM series WHERE tournament_id=$1`, tournamentID)
|
|
_, _ = store.pool.Exec(ctx, `DELETE FROM events WHERE id=$1`, eventID)
|
|
_, _ = store.pool.Exec(ctx, `DELETE FROM discord_role_sync_jobs WHERE event_id=$1`, eventID)
|
|
_, _ = store.pool.Exec(ctx, `DELETE FROM accounts WHERE id=$1`, accountID)
|
|
}()
|
|
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",
|
|
}, application.CreateEventOptions{})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
eventID = event.ID
|
|
rules, err := store.GetRuleset(ctx, event.RulesetID)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
draft, err := domain.NewBracketDraft(eventID, []string{"team-a", "team-b", "team-c", "team-d"})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
tournament, err := domain.NewGraphTournament(tournamentID, eventID, event.Name, *draft)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
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)
|
|
}
|
|
if match == 0 {
|
|
if createErr = series.Toss("parallel-seed", accountID, time.Now().UTC(), rules); createErr != nil {
|
|
t.Fatal(createErr)
|
|
}
|
|
}
|
|
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 {
|
|
t.Fatal(err)
|
|
}
|
|
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 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)
|
|
}
|
|
}
|
|
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)
|
|
}
|
|
if _, err = store.SaveSeries(ctx, *final); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
hydrated, err = store.GetTournamentByEvent(ctx, eventID)
|
|
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)
|
|
}
|
|
}
|
|
|
|
func firstUnbanned(pool, banned []string) string {
|
|
for _, name := range pool {
|
|
found := false
|
|
for _, item := range banned {
|
|
found = found || item == name
|
|
}
|
|
if !found {
|
|
return name
|
|
}
|
|
}
|
|
return ""
|
|
}
|
|
|
|
func firstAvailableHero(draft *domain.HeroDraft, teamID string) string {
|
|
for _, hero := range draft.Heroes {
|
|
usedRole, usedHero, ownRepeat := false, false, false
|
|
for _, role := range draft.CurrentRoles[teamID] {
|
|
usedRole = usedRole || role == hero.Role
|
|
}
|
|
for _, ban := range draft.CurrentBans {
|
|
usedHero = usedHero || ban.Value == hero.Name
|
|
}
|
|
for _, name := range draft.SeriesBans[teamID] {
|
|
ownRepeat = ownRepeat || name == hero.Name
|
|
}
|
|
if !usedRole && !usedHero && !ownRepeat {
|
|
return hero.Name
|
|
}
|
|
}
|
|
return ""
|
|
}
|