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") } } 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) 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{}) 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", }) 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) } 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, started.Series.ID, "integration-seed", 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, 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, 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, series.ID, teamID, hero, series.Version) if err != nil { t.Fatal(err) } } series, err = service.RecordSeriesResult(ctx, admin, series.ID, 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 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 "" }