Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions internal/api/blast/client.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
package blast

// BaseURL is the base URL for the Blast.tv API
const BaseURL = "https://api.blast.tv/v2"
2 changes: 1 addition & 1 deletion internal/cmd/get_brackets.go
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
81 changes: 81 additions & 0 deletions internal/cmd/get_match.go
Original file line number Diff line number Diff line change
@@ -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
}
264 changes: 264 additions & 0 deletions internal/cmd/get_match_test.go
Original file line number Diff line number Diff line change
@@ -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)
Comment on lines +210 to +211

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

This test could be more robust. It currently only checks that the command runs without error. It should also verify that the HTTP mock was consumed (gock.IsDone()) and that valid JSON was written to standard output. This feedback also applies to the 'YAML output format' test.

You will need to add os, io, and encoding/json to your imports to apply this suggestion.

		oldStdout := os.Stdout
		r, w, _ := os.Pipe()
		os.Stdout = w

		err := cmd.Run(ctx)

		w.Close()
		out, _ := io.ReadAll(r)
		os.Stdout = oldStdout

		assert.NoError(t, err)
		assert.True(t, gock.IsDone())
		assert.True(t, json.Valid(out))

})

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")
})
}
2 changes: 1 addition & 1 deletion internal/cmd/get_matches.go
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
2 changes: 1 addition & 1 deletion internal/cmd/list_tournaments.go
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
1 change: 1 addition & 0 deletions internal/cmd/root.go
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
6 changes: 6 additions & 0 deletions internal/mapper/match.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading
Loading