diff --git a/internal/api/blast/client.go b/internal/api/blast/client.go new file mode 100644 index 0000000..6202812 --- /dev/null +++ b/internal/api/blast/client.go @@ -0,0 +1,4 @@ +package blast + +// BaseURL is the base URL for the Blast.tv API +const BaseURL = "https://api.blast.tv/v2" diff --git a/internal/cmd/get_brackets.go b/internal/cmd/get_brackets.go index 2df4c7a..10dd06a 100644 --- a/internal/cmd/get_brackets.go +++ b/internal/cmd/get_brackets.go @@ -72,7 +72,7 @@ func (g *GetBracketsCmd) Run(ctx *Context) error { return fmt.Errorf("cannot use multiple status filters together (completed-only, live-only, upcoming-only are mutually exclusive)") } - url := fmt.Sprintf("https://api.blast.tv/v2/games/rl/tournaments/%s/brackets", g.TournamentID) + url := fmt.Sprintf("%s/games/rl/tournaments/%s/brackets", blast.BaseURL, g.TournamentID) client := &http.Client{ Timeout: 10 * time.Second, diff --git a/internal/cmd/get_match.go b/internal/cmd/get_match.go new file mode 100644 index 0000000..7750809 --- /dev/null +++ b/internal/cmd/get_match.go @@ -0,0 +1,81 @@ +package cmd + +import ( + "encoding/json" + "fmt" + "io" + "net/http" + "os" + "time" + + "github.com/mgranderath/rlcs-cli/internal/api/blast" + "github.com/mgranderath/rlcs-cli/internal/domain" + "github.com/mgranderath/rlcs-cli/internal/mapper" + "github.com/mgranderath/rlcs-cli/internal/output" +) + +// GetMatchCmd retrieves detailed information for a specific match +type GetMatchCmd struct { + MatchID string `arg:"" help:"Match ID"` + Output output.MatchesFormat `help:"Output format (table, json, yaml)" default:"table" short:"o"` +} + +func (g *GetMatchCmd) Run(ctx *Context) error { + url := fmt.Sprintf("%s/matches/%s/detailed", blast.BaseURL, g.MatchID) + + client := &http.Client{ + Timeout: 10 * time.Second, + } + + req, err := http.NewRequest("GET", url, nil) + if err != nil { + return fmt.Errorf("failed to create request: %w", err) + } + + req.Header.Set("Accept", "application/json") + + resp, err := client.Do(req) + if err != nil { + return fmt.Errorf("failed to make request: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode == http.StatusNotFound { + return fmt.Errorf("match not found: %s", g.MatchID) + } + if resp.StatusCode != http.StatusOK { + return fmt.Errorf("unexpected status code: %d", resp.StatusCode) + } + + body, err := io.ReadAll(resp.Body) + if err != nil { + return fmt.Errorf("failed to read response body: %w", err) + } + + var apiMatch blast.MatchResponse + if err := json.Unmarshal(body, &apiMatch); err != nil { + return fmt.Errorf("failed to parse JSON: %w", err) + } + + // Map API response to domain model + match, err := mapper.ToDomainMatchFromDetailResponse(apiMatch) + if err != nil { + return fmt.Errorf("failed to map match: %w", err) + } + + // Wrap single match in a slice for formatter compatibility + matches := []domain.Match{match} + + // Get the appropriate formatter + formatter, err := output.GetMatchesFormatter(g.Output) + if err != nil { + return fmt.Errorf("failed to get formatter: %w", err) + } + + // Output using the selected formatter + if err := formatter.Format(os.Stdout, matches); err != nil { + return fmt.Errorf("failed to format output: %w", err) + } + + return nil +} diff --git a/internal/cmd/get_match_test.go b/internal/cmd/get_match_test.go new file mode 100644 index 0000000..f443f3d --- /dev/null +++ b/internal/cmd/get_match_test.go @@ -0,0 +1,264 @@ +package cmd + +import ( + "testing" + + "github.com/h2non/gock" + "github.com/mgranderath/rlcs-cli/internal/output" + "github.com/stretchr/testify/assert" +) + +func TestGetMatchCmd_Run_HTTPMock(t *testing.T) { + defer gock.Off() + + t.Run("successful fetch", func(t *testing.T) { + gock.New("https://api.blast.tv"). + Get("/v2/matches/test-match-id/detailed"). + Reply(200). + JSON(map[string]interface{}{ + "id": "test-match-id", + "name": "Quarter Final 1", + "scheduledAt": "2026-02-01T10:00:00.000Z", + "type": "BO7", + "index": 4, + "externalId": nil, + "circuit": map[string]interface{}{ + "gameId": "rl", + "id": "2026", + "name": "2026", + }, + "tournament": map[string]interface{}{ + "id": "rlcs-open-3-apac-2026", + "name": "RLCS Open 3 APAC 2026", + "startDate": "2026-01-30", + "endDate": "2026-02-01", + "prizePool": "$29,700", + "externalId": "regional-3-21op85bf52", + }, + "stage": map[string]interface{}{ + "id": "bdc700c8-cddc-44fd-8f59-faa3d4fb696a", + "name": "Playoffs", + "format": "afl-final-eight", + "numberOfTeams": nil, + "metadata": nil, + "startDate": "2026-01-31T16:00:00", + "endDate": "2026-02-01T20:00:00", + "index": 2, + }, + "teamA": map[string]interface{}{ + "id": "49737583-e29b-4056-a706-41ac13db0d39", + "name": "God Speed", + "shortName": "godspeed", + "nationality": "MY", + "externalId": nil, + "metadata": nil, + }, + "teamB": map[string]interface{}{ + "id": "20607998-f78d-4a56-b4ba-54078cc29752", + "name": "Ground Zero Gaming", + "shortName": "gzg", + "nationality": "AU", + "externalId": nil, + "metadata": nil, + }, + "teamAScore": 1, + "teamBScore": 3, + "maps": []map[string]interface{}{}, + "metadata": nil, + }) + + cmd := &GetMatchCmd{ + MatchID: "test-match-id", + Output: output.MatchesFormatTable, + } + ctx := &Context{Debug: false} + + err := cmd.Run(ctx) + assert.NoError(t, err) + assert.True(t, gock.IsDone()) + }) + + t.Run("404 response - match not found", func(t *testing.T) { + gock.New("https://api.blast.tv"). + Get("/v2/matches/invalid-id/detailed"). + Reply(404) + + cmd := &GetMatchCmd{ + MatchID: "invalid-id", + Output: output.MatchesFormatTable, + } + ctx := &Context{Debug: false} + + err := cmd.Run(ctx) + assert.Error(t, err) + assert.Contains(t, err.Error(), "match not found") + }) + + t.Run("500 response", func(t *testing.T) { + gock.New("https://api.blast.tv"). + Get("/v2/matches/test-id/detailed"). + Reply(500) + + cmd := &GetMatchCmd{ + MatchID: "test-id", + Output: output.MatchesFormatTable, + } + ctx := &Context{Debug: false} + + err := cmd.Run(ctx) + assert.Error(t, err) + assert.Contains(t, err.Error(), "unexpected status code: 500") + }) + + t.Run("invalid JSON response", func(t *testing.T) { + gock.New("https://api.blast.tv"). + Get("/v2/matches/test-id/detailed"). + Reply(200). + BodyString("invalid json") + + cmd := &GetMatchCmd{ + MatchID: "test-id", + Output: output.MatchesFormatTable, + } + ctx := &Context{Debug: false} + + err := cmd.Run(ctx) + assert.Error(t, err) + assert.Contains(t, err.Error(), "failed to parse JSON") + }) + + t.Run("match with maps", func(t *testing.T) { + gock.New("https://api.blast.tv"). + Get("/v2/matches/match-with-maps/detailed"). + Reply(200). + JSON(map[string]interface{}{ + "id": "match-with-maps", + "name": "Grand Final", + "scheduledAt": "2026-01-15T18:00:00.000Z", + "type": "BO7", + "teamA": map[string]interface{}{ + "id": "team-a", + "name": "Team A", + "shortName": "teama", + "nationality": "US", + }, + "teamB": map[string]interface{}{ + "id": "team-b", + "name": "Team B", + "shortName": "teamb", + "nationality": "EU", + }, + "teamAScore": 4, + "teamBScore": 2, + "maps": []map[string]interface{}{ + { + "id": "map-1", + "name": "Stadium_P", + "scheduledAt": "2026-01-15T18:00:00.000Z", + "startedAt": "2026-01-15T18:05:00.000Z", + "endedAt": "2026-01-15T18:15:00.000Z", + "teamAScore": 3, + "teamBScore": 2, + "externalId": "MANUAL", + }, + { + "id": "map-2", + "name": "Urban_P", + "scheduledAt": "2026-01-15T18:20:00.000Z", + "startedAt": "2026-01-15T18:25:00.000Z", + "endedAt": "2026-01-15T18:35:00.000Z", + "teamAScore": 4, + "teamBScore": 3, + "externalId": "MANUAL", + }, + }, + }) + + cmd := &GetMatchCmd{ + MatchID: "match-with-maps", + Output: output.MatchesFormatTable, + } + ctx := &Context{Debug: false} + + err := cmd.Run(ctx) + assert.NoError(t, err) + assert.True(t, gock.IsDone()) + }) + + t.Run("JSON output format", func(t *testing.T) { + gock.New("https://api.blast.tv"). + Get("/v2/matches/json-test/detailed"). + Reply(200). + JSON(map[string]interface{}{ + "id": "json-test", + "name": "Test Match", + "scheduledAt": "2026-01-15T18:00:00.000Z", + "type": "BO5", + "teamA": map[string]interface{}{"id": "a", "name": "Team A"}, + "teamB": map[string]interface{}{"id": "b", "name": "Team B"}, + "teamAScore": 3, + "teamBScore": 1, + "maps": []map[string]interface{}{}, + }) + + cmd := &GetMatchCmd{ + MatchID: "json-test", + Output: output.MatchesFormatJSON, + } + ctx := &Context{Debug: false} + + err := cmd.Run(ctx) + assert.NoError(t, err) + }) + + t.Run("YAML output format", func(t *testing.T) { + gock.New("https://api.blast.tv"). + Get("/v2/matches/yaml-test/detailed"). + Reply(200). + JSON(map[string]interface{}{ + "id": "yaml-test", + "name": "Test Match", + "scheduledAt": "2026-01-15T18:00:00.000Z", + "type": "BO5", + "teamA": map[string]interface{}{"id": "a", "name": "Team A"}, + "teamB": map[string]interface{}{"id": "b", "name": "Team B"}, + "teamAScore": 3, + "teamBScore": 1, + "maps": []map[string]interface{}{}, + }) + + cmd := &GetMatchCmd{ + MatchID: "yaml-test", + Output: output.MatchesFormatYAML, + } + ctx := &Context{Debug: false} + + err := cmd.Run(ctx) + assert.NoError(t, err) + }) + + t.Run("invalid time format in response", func(t *testing.T) { + gock.New("https://api.blast.tv"). + Get("/v2/matches/invalid-time/detailed"). + Reply(200). + JSON(map[string]interface{}{ + "id": "invalid-time", + "name": "Test Match", + "scheduledAt": "invalid-time-format", + "type": "BO5", + "teamA": map[string]interface{}{"id": "a", "name": "Team A"}, + "teamB": map[string]interface{}{"id": "b", "name": "Team B"}, + "maps": []map[string]interface{}{}, + }) + + cmd := &GetMatchCmd{ + MatchID: "invalid-time", + Output: output.MatchesFormatTable, + } + ctx := &Context{Debug: false} + + err := cmd.Run(ctx) + assert.Error(t, err) + assert.Contains(t, err.Error(), "failed to map match") + }) +} diff --git a/internal/cmd/get_matches.go b/internal/cmd/get_matches.go index a67b47f..22ec606 100644 --- a/internal/cmd/get_matches.go +++ b/internal/cmd/get_matches.go @@ -74,7 +74,7 @@ func (g *GetMatchesCmd) Run(ctx *Context) error { return fmt.Errorf("cannot use multiple status filters together (completed-only, live-only, upcoming-only are mutually exclusive)") } - url := fmt.Sprintf("https://api.blast.tv/v2/games/rl/tournaments/%s/matches", g.TournamentID) + url := fmt.Sprintf("%s/games/rl/tournaments/%s/matches", blast.BaseURL, g.TournamentID) client := &http.Client{ Timeout: 10 * time.Second, diff --git a/internal/cmd/list_tournaments.go b/internal/cmd/list_tournaments.go index ef77350..5d136cc 100644 --- a/internal/cmd/list_tournaments.go +++ b/internal/cmd/list_tournaments.go @@ -98,7 +98,7 @@ func (l *ListTournamentsCmd) Run(ctx *Context) error { circuit = fmt.Sprintf("%d", l.now().Year()) } - url := fmt.Sprintf("https://api.blast.tv/v2/circuits/%s/tournaments?game=rl", circuit) + url := fmt.Sprintf("%s/circuits/%s/tournaments?game=rl", blast.BaseURL, circuit) client := &http.Client{ Timeout: 10 * time.Second, diff --git a/internal/cmd/root.go b/internal/cmd/root.go index c5dc1f5..6194e6e 100644 --- a/internal/cmd/root.go +++ b/internal/cmd/root.go @@ -13,6 +13,7 @@ var cli struct { ListTournaments ListTournamentsCmd `cmd:"" name:"list-tournaments" help:"List all tournaments for RLCS."` GetBrackets GetBracketsCmd `cmd:"" name:"get-brackets" help:"Get tournament brackets for a specific tournament."` GetMatches GetMatchesCmd `cmd:"" name:"get-matches" help:"Get all matches for a specific tournament."` + GetMatch GetMatchCmd `cmd:"" name:"get-match" help:"Get detailed information for a specific match."` } func Execute(version string) { diff --git a/internal/mapper/match.go b/internal/mapper/match.go index 015dd73..e429110 100644 --- a/internal/mapper/match.go +++ b/internal/mapper/match.go @@ -105,6 +105,12 @@ func toDomainMatchMapFromResponse(api blast.MatchResponseMap) (domain.MatchMap, }, nil } +// ToDomainMatchFromDetailResponse converts a single API match response to domain match +// Used for the detailed match endpoint which returns a single match object +func ToDomainMatchFromDetailResponse(api blast.MatchResponse) (domain.Match, error) { + return toDomainMatchFromResponse(api) +} + // inferMatchStatus determines if a match is completed, live, or upcoming based on map timestamps // - Completed: at least one map has started and all maps that have started have ended // - Live: at least one map has started but not all started maps have ended diff --git a/internal/mapper/match_test.go b/internal/mapper/match_test.go index aae5520..ccf68e5 100644 --- a/internal/mapper/match_test.go +++ b/internal/mapper/match_test.go @@ -452,6 +452,190 @@ func TestToDomainMatchMapFromResponse(t *testing.T) { } } +func TestToDomainMatchFromDetailResponse(t *testing.T) { + tests := []struct { + name string + api blast.MatchResponse + expectError bool + checkFields func(t *testing.T, result domain.Match) + }{ + { + name: "detailed match with full tournament context", + api: blast.MatchResponse{ + ID: "667d39fa-65cb-46c1-b939-3554cdbd5cc0", + Name: "Quarter Final 1", + ScheduledAt: "2026-02-01T10:00:00.000Z", + Type: "BO7", + Index: 4, + ExternalID: "", + Circuit: blast.Circuit{ + GameID: "rl", + ID: "2026", + Name: "2026", + }, + Tournament: blast.Tournament{ + ID: "rlcs-open-3-apac-2026", + Name: "RLCS Open 3 APAC 2026", + StartDate: "2026-01-30", + EndDate: "2026-02-01", + PrizePool: "$29,700", + }, + Stage: blast.Stage{ + ID: "bdc700c8-cddc-44fd-8f59-faa3d4fb696a", + Name: "Playoffs", + Format: "afl-final-eight", + NumberOfTeams: nil, + Metadata: nil, + StartDate: "2026-01-31T16:00:00", + EndDate: "2026-02-01T20:00:00", + Index: 2, + }, + TeamA: blast.MatchResponseTeam{ + ID: "49737583-e29b-4056-a706-41ac13db0d39", + Name: "God Speed", + ShortName: "godspeed", + Nationality: "MY", + ExternalID: nil, + Metadata: nil, + }, + TeamB: blast.MatchResponseTeam{ + ID: "20607998-f78d-4a56-b4ba-54078cc29752", + Name: "Ground Zero Gaming", + ShortName: "gzg", + Nationality: "AU", + ExternalID: nil, + Metadata: nil, + }, + TeamAScore: 1, + TeamBScore: 3, + Maps: []blast.MatchResponseMap{}, + Metadata: blast.MatchResponseMetadata{}, + }, + expectError: false, + checkFields: func(t *testing.T, result domain.Match) { + assert.Equal(t, "667d39fa-65cb-46c1-b939-3554cdbd5cc0", result.UUID) + assert.Equal(t, "Quarter Final 1", result.Name) + assert.Equal(t, "BO7", result.Type) + assert.Equal(t, 4, result.Index) + assert.Equal(t, "God Speed", result.TeamA.Name) + assert.Equal(t, "godspeed", result.TeamA.Shorthand) + assert.Equal(t, "MY", result.TeamA.Location) + assert.Equal(t, "Ground Zero Gaming", result.TeamB.Name) + assert.Equal(t, "gzg", result.TeamB.Shorthand) + assert.Equal(t, "AU", result.TeamB.Location) + assert.Equal(t, 1, result.TeamAScore) + assert.Equal(t, 3, result.TeamBScore) + assert.False(t, result.IsCompleted) + assert.False(t, result.IsLive) + }, + }, + { + name: "detailed match with null external IDs and metadata", + api: blast.MatchResponse{ + ID: "test-match-id", + Name: "Test Match", + ScheduledAt: "2026-02-01T10:00:00.000Z", + Type: "BO5", + ExternalID: "", + TeamA: blast.MatchResponseTeam{ + ID: "team-a", + Name: "Team A", + ExternalID: nil, + Metadata: nil, + }, + TeamB: blast.MatchResponseTeam{ + ID: "team-b", + Name: "Team B", + ExternalID: nil, + Metadata: nil, + }, + Maps: []blast.MatchResponseMap{}, + Metadata: blast.MatchResponseMetadata{}, + }, + expectError: false, + checkFields: func(t *testing.T, result domain.Match) { + assert.Equal(t, "", result.ExternalID) + assert.Equal(t, "Team A", result.TeamA.Name) + assert.Equal(t, "Team B", result.TeamB.Name) + }, + }, + { + name: "detailed match with maps", + api: blast.MatchResponse{ + ID: "match-with-maps", + Name: "Final", + ScheduledAt: "2026-02-01T10:00:00.000Z", + Type: "BO7", + TeamA: blast.MatchResponseTeam{ + ID: "team-a", + Name: "Team A", + }, + TeamB: blast.MatchResponseTeam{ + ID: "team-b", + Name: "Team B", + }, + TeamAScore: 4, + TeamBScore: 2, + Maps: []blast.MatchResponseMap{ + { + ID: "map-1", + Name: "Stadium_P", + ScheduledAt: "2026-02-01T10:00:00.000Z", + StartedAt: "2026-02-01T10:05:00.000Z", + EndedAt: "2026-02-01T10:15:00.000Z", + TeamAScore: 3, + TeamBScore: 2, + }, + }, + Metadata: blast.MatchResponseMetadata{ + T: "rl_match", + TeamBlueTeamID: "team-a", + TeamOrangeTeamID: "team-b", + ExternalStreamURL: "https://example.com/stream", + }, + }, + expectError: false, + checkFields: func(t *testing.T, result domain.Match) { + assert.Equal(t, "match-with-maps", result.UUID) + assert.Equal(t, 4, result.TeamAScore) + assert.Equal(t, 2, result.TeamBScore) + assert.Len(t, result.Maps, 1) + assert.True(t, result.IsCompleted) + assert.False(t, result.IsLive) + }, + }, + { + name: "invalid scheduled time", + api: blast.MatchResponse{ + ID: "invalid", + Name: "Invalid", + ScheduledAt: "invalid-time", + Type: "BO5", + TeamA: blast.MatchResponseTeam{ID: "a", Name: "A"}, + TeamB: blast.MatchResponseTeam{ID: "b", Name: "B"}, + Maps: []blast.MatchResponseMap{}, + }, + expectError: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result, err := ToDomainMatchFromDetailResponse(tt.api) + + if tt.expectError { + assert.Error(t, err) + return + } + + require.NoError(t, err) + if tt.checkFields != nil { + tt.checkFields(t, result) + } + }) + } +} + func TestInferMatchStatus(t *testing.T) { tests := []struct { name string