cricinfo-cli-go 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/AGENTS.md +63 -0
- package/CONTRIBUTORS.md +75 -0
- package/LICENSE +21 -0
- package/Makefile +131 -0
- package/README.md +130 -0
- package/bin/cricinfo.js +44 -0
- package/cmd/cricinfo/main.go +15 -0
- package/go.mod +10 -0
- package/go.sum +10 -0
- package/internal/app/app.go +11 -0
- package/internal/app/app_test.go +122 -0
- package/internal/buildinfo/buildinfo.go +16 -0
- package/internal/cli/analysis.go +262 -0
- package/internal/cli/analysis_test.go +175 -0
- package/internal/cli/competitions.go +154 -0
- package/internal/cli/competitions_test.go +165 -0
- package/internal/cli/leagues.go +297 -0
- package/internal/cli/leagues_test.go +194 -0
- package/internal/cli/matches.go +403 -0
- package/internal/cli/matches_test.go +413 -0
- package/internal/cli/players.go +263 -0
- package/internal/cli/players_test.go +384 -0
- package/internal/cli/root.go +141 -0
- package/internal/cli/search.go +119 -0
- package/internal/cli/teams.go +214 -0
- package/internal/cli/teams_test.go +192 -0
- package/internal/cricinfo/analysis.go +1401 -0
- package/internal/cricinfo/analysis_phase15_test.go +267 -0
- package/internal/cricinfo/client.go +471 -0
- package/internal/cricinfo/client_test.go +280 -0
- package/internal/cricinfo/cmd/fixture-refresh/main.go +145 -0
- package/internal/cricinfo/competitions.go +405 -0
- package/internal/cricinfo/competitions_phase13_test.go +234 -0
- package/internal/cricinfo/coverage_ledger.go +122 -0
- package/internal/cricinfo/coverage_ledger_test.go +253 -0
- package/internal/cricinfo/decode.go +115 -0
- package/internal/cricinfo/decode_test.go +100 -0
- package/internal/cricinfo/entity_index.go +618 -0
- package/internal/cricinfo/entity_index_test.go +175 -0
- package/internal/cricinfo/fixture_matrix.go +243 -0
- package/internal/cricinfo/fixture_matrix_test.go +49 -0
- package/internal/cricinfo/fixtures_test.go +264 -0
- package/internal/cricinfo/historical_hydration.go +1641 -0
- package/internal/cricinfo/historical_phase14_test.go +542 -0
- package/internal/cricinfo/leagues.go +1210 -0
- package/internal/cricinfo/leagues_phase12_test.go +324 -0
- package/internal/cricinfo/live_leagues_test.go +169 -0
- package/internal/cricinfo/live_matches_test.go +203 -0
- package/internal/cricinfo/live_matrix_test.go +118 -0
- package/internal/cricinfo/live_players_test.go +122 -0
- package/internal/cricinfo/live_search_test.go +86 -0
- package/internal/cricinfo/live_smoke_test.go +213 -0
- package/internal/cricinfo/live_teams_test.go +104 -0
- package/internal/cricinfo/matches.go +1508 -0
- package/internal/cricinfo/matches_phase7_test.go +207 -0
- package/internal/cricinfo/matches_phase9_test.go +253 -0
- package/internal/cricinfo/normalize_entities.go +1727 -0
- package/internal/cricinfo/normalize_leagues.go +346 -0
- package/internal/cricinfo/players.go +1332 -0
- package/internal/cricinfo/players_phase10_test.go +174 -0
- package/internal/cricinfo/players_phase11_test.go +373 -0
- package/internal/cricinfo/render_contract.go +1088 -0
- package/internal/cricinfo/render_phase4_test.go +633 -0
- package/internal/cricinfo/renderer.go +1689 -0
- package/internal/cricinfo/resolver.go +813 -0
- package/internal/cricinfo/resolver_test.go +244 -0
- package/internal/cricinfo/teams.go +603 -0
- package/internal/cricinfo/teams_phase8_test.go +231 -0
- package/internal/cricinfo/testdata/fixtures/README.md +43 -0
- package/internal/cricinfo/testdata/fixtures/aux-competition-metadata/broadcasts.json +11 -0
- package/internal/cricinfo/testdata/fixtures/aux-competition-metadata/officials.json +150 -0
- package/internal/cricinfo/testdata/fixtures/details-plays/detail-110.json +157 -0
- package/internal/cricinfo/testdata/fixtures/details-plays/detail-52545007.json +145 -0
- package/internal/cricinfo/testdata/fixtures/details-plays/detail-52559021.json +143 -0
- package/internal/cricinfo/testdata/fixtures/details-plays/plays.json +15 -0
- package/internal/cricinfo/testdata/fixtures/endpoint-matrix.tsv +19 -0
- package/internal/cricinfo/testdata/fixtures/innings-fow-partnerships/fow-1.json +12 -0
- package/internal/cricinfo/testdata/fixtures/innings-fow-partnerships/fow.json +42 -0
- package/internal/cricinfo/testdata/fixtures/innings-fow-partnerships/innings-1-2.json +38 -0
- package/internal/cricinfo/testdata/fixtures/innings-fow-partnerships/partnership-1.json +31 -0
- package/internal/cricinfo/testdata/fixtures/innings-fow-partnerships/partnerships.json +42 -0
- package/internal/cricinfo/testdata/fixtures/leagues-seasons-standings/calendar-offdays.json +20 -0
- package/internal/cricinfo/testdata/fixtures/leagues-seasons-standings/calendar-ondays.json +21 -0
- package/internal/cricinfo/testdata/fixtures/leagues-seasons-standings/calendar.json +14 -0
- package/internal/cricinfo/testdata/fixtures/leagues-seasons-standings/season-2025.json +13 -0
- package/internal/cricinfo/testdata/fixtures/leagues-seasons-standings/season-group-1.json +13 -0
- package/internal/cricinfo/testdata/fixtures/leagues-seasons-standings/season-groups.json +11 -0
- package/internal/cricinfo/testdata/fixtures/leagues-seasons-standings/season-type-1.json +13 -0
- package/internal/cricinfo/testdata/fixtures/leagues-seasons-standings/season-types.json +11 -0
- package/internal/cricinfo/testdata/fixtures/leagues-seasons-standings/seasons.json +30 -0
- package/internal/cricinfo/testdata/fixtures/leagues-seasons-standings/standings-item-1.json +72 -0
- package/internal/cricinfo/testdata/fixtures/leagues-seasons-standings/standings-root.json +3 -0
- package/internal/cricinfo/testdata/fixtures/leagues-seasons-standings/standings.json +15 -0
- package/internal/cricinfo/testdata/fixtures/matches-competitions/competition.json +460 -0
- package/internal/cricinfo/testdata/fixtures/matches-competitions/event-1529474.json +86 -0
- package/internal/cricinfo/testdata/fixtures/matches-competitions/matchcards-1527966.json +368 -0
- package/internal/cricinfo/testdata/fixtures/matches-competitions/situation-1529474.json +10 -0
- package/internal/cricinfo/testdata/fixtures/players/athlete-1361257-statistics.json +126 -0
- package/internal/cricinfo/testdata/fixtures/players/athlete-1361257.json +113 -0
- package/internal/cricinfo/testdata/fixtures/players/roster-1361257-linescores-1-1-statistics-0.json +208 -0
- package/internal/cricinfo/testdata/fixtures/players/roster-1361257-linescores-1-2-statistics-0.json +252 -0
- package/internal/cricinfo/testdata/fixtures/players/roster-1361257-linescores.json +74 -0
- package/internal/cricinfo/testdata/fixtures/players/roster-1361257-statistics-0.json +1008 -0
- package/internal/cricinfo/testdata/fixtures/root-discovery/events.json +72 -0
- package/internal/cricinfo/testdata/fixtures/root-discovery/root.json +28 -0
- package/internal/cricinfo/testdata/fixtures/team-competitor/competitor-789643.json +40 -0
- package/internal/cricinfo/testdata/fixtures/team-competitor/leaders-789643.json +353 -0
- package/internal/cricinfo/testdata/fixtures/team-competitor/records-789643.json +91 -0
- package/internal/cricinfo/testdata/fixtures/team-competitor/roster-1147772-object.json +231 -0
- package/internal/cricinfo/testdata/fixtures/team-competitor/roster-1147772.json +235 -0
- package/internal/cricinfo/testdata/fixtures/team-competitor/roster-789643.json +322 -0
- package/internal/cricinfo/testdata/fixtures/team-competitor/scores-789643.json +19 -0
- package/internal/cricinfo/testdata/fixtures/team-competitor/statistics-789643.json +629 -0
- package/internal/cricinfo/testdata/fixtures/team-competitor/team-789643-athletes.json +7 -0
- package/internal/cricinfo/testdata/fixtures/team-competitor/team-789643.json +67 -0
- package/internal/cricinfo/testdata/golden/match-empty.golden +1 -0
- package/internal/cricinfo/testdata/golden/match-list.golden +2 -0
- package/internal/cricinfo/testdata/golden/match-partial.golden +3 -0
- package/internal/cricinfo/types.go +54 -0
- package/package.json +51 -0
- package/scripts/postinstall.js +153 -0
|
@@ -0,0 +1,405 @@
|
|
|
1
|
+
package cricinfo
|
|
2
|
+
|
|
3
|
+
import (
|
|
4
|
+
"context"
|
|
5
|
+
"fmt"
|
|
6
|
+
"strings"
|
|
7
|
+
)
|
|
8
|
+
|
|
9
|
+
// CompetitionServiceConfig configures competition metadata command behavior.
|
|
10
|
+
type CompetitionServiceConfig struct {
|
|
11
|
+
Client *Client
|
|
12
|
+
Resolver *Resolver
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
// CompetitionLookupOptions controls resolver-backed competition lookup behavior.
|
|
16
|
+
type CompetitionLookupOptions struct {
|
|
17
|
+
LeagueID string
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
// CompetitionService implements competition metadata command surfaces.
|
|
21
|
+
type CompetitionService struct {
|
|
22
|
+
client *Client
|
|
23
|
+
resolver *Resolver
|
|
24
|
+
ownsResolver bool
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
// NewCompetitionService builds a competition service using default client/resolver when omitted.
|
|
28
|
+
func NewCompetitionService(cfg CompetitionServiceConfig) (*CompetitionService, error) {
|
|
29
|
+
client := cfg.Client
|
|
30
|
+
if client == nil {
|
|
31
|
+
var err error
|
|
32
|
+
client, err = NewClient(Config{})
|
|
33
|
+
if err != nil {
|
|
34
|
+
return nil, err
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
resolver := cfg.Resolver
|
|
39
|
+
ownsResolver := false
|
|
40
|
+
if resolver == nil {
|
|
41
|
+
var err error
|
|
42
|
+
resolver, err = NewResolver(ResolverConfig{Client: client})
|
|
43
|
+
if err != nil {
|
|
44
|
+
return nil, err
|
|
45
|
+
}
|
|
46
|
+
ownsResolver = true
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
return &CompetitionService{
|
|
50
|
+
client: client,
|
|
51
|
+
resolver: resolver,
|
|
52
|
+
ownsResolver: ownsResolver,
|
|
53
|
+
}, nil
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// Close persists resolver cache when owned by this service.
|
|
57
|
+
func (s *CompetitionService) Close() error {
|
|
58
|
+
if !s.ownsResolver || s.resolver == nil {
|
|
59
|
+
return nil
|
|
60
|
+
}
|
|
61
|
+
return s.resolver.Close()
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// Show resolves and returns one competition summary.
|
|
65
|
+
func (s *CompetitionService) Show(ctx context.Context, query string, opts CompetitionLookupOptions) (NormalizedResult, error) {
|
|
66
|
+
lookup, passthrough := s.resolveCompetitionLookup(ctx, query, opts)
|
|
67
|
+
if passthrough != nil {
|
|
68
|
+
passthrough.Kind = EntityCompetition
|
|
69
|
+
return *passthrough, nil
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
result := NewDataResult(EntityCompetition, lookup.competition)
|
|
73
|
+
if len(lookup.warnings) > 0 {
|
|
74
|
+
result = NewPartialResult(EntityCompetition, lookup.competition, lookup.warnings...)
|
|
75
|
+
}
|
|
76
|
+
result.RequestedRef = lookup.resolved.RequestedRef
|
|
77
|
+
result.CanonicalRef = lookup.resolved.CanonicalRef
|
|
78
|
+
return result, nil
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// Officials resolves and returns competition officials entries.
|
|
82
|
+
func (s *CompetitionService) Officials(ctx context.Context, query string, opts CompetitionLookupOptions) (NormalizedResult, error) {
|
|
83
|
+
return s.subresourceList(ctx, query, opts, EntityCompOfficial, "officials", "officials")
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// Broadcasts resolves and returns competition broadcast entries.
|
|
87
|
+
func (s *CompetitionService) Broadcasts(ctx context.Context, query string, opts CompetitionLookupOptions) (NormalizedResult, error) {
|
|
88
|
+
return s.subresourceList(ctx, query, opts, EntityCompBroadcast, "broadcasts", "broadcasts")
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// Tickets resolves and returns competition ticket entries.
|
|
92
|
+
func (s *CompetitionService) Tickets(ctx context.Context, query string, opts CompetitionLookupOptions) (NormalizedResult, error) {
|
|
93
|
+
return s.subresourceList(ctx, query, opts, EntityCompTicket, "tickets", "tickets")
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// Odds resolves and returns competition odds entries.
|
|
97
|
+
func (s *CompetitionService) Odds(ctx context.Context, query string, opts CompetitionLookupOptions) (NormalizedResult, error) {
|
|
98
|
+
return s.subresourceList(ctx, query, opts, EntityCompOdds, "odds", "odds")
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// Metadata resolves and returns an aggregated competition metadata view.
|
|
102
|
+
func (s *CompetitionService) Metadata(ctx context.Context, query string, opts CompetitionLookupOptions) (NormalizedResult, error) {
|
|
103
|
+
lookup, passthrough := s.resolveCompetitionLookup(ctx, query, opts)
|
|
104
|
+
if passthrough != nil {
|
|
105
|
+
passthrough.Kind = EntityCompMetadata
|
|
106
|
+
return *passthrough, nil
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
warnings := append([]string{}, lookup.warnings...)
|
|
110
|
+
summary := CompetitionMetadataSummary{
|
|
111
|
+
Competition: lookup.competition,
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
subresources := []struct {
|
|
115
|
+
key string
|
|
116
|
+
suffix string
|
|
117
|
+
assign func([]CompetitionMetadataEntry)
|
|
118
|
+
}{
|
|
119
|
+
{key: "officials", suffix: "officials", assign: func(entries []CompetitionMetadataEntry) { summary.Officials = entries }},
|
|
120
|
+
{key: "broadcasts", suffix: "broadcasts", assign: func(entries []CompetitionMetadataEntry) { summary.Broadcasts = entries }},
|
|
121
|
+
{key: "tickets", suffix: "tickets", assign: func(entries []CompetitionMetadataEntry) { summary.Tickets = entries }},
|
|
122
|
+
{key: "odds", suffix: "odds", assign: func(entries []CompetitionMetadataEntry) { summary.Odds = entries }},
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
for _, subresource := range subresources {
|
|
126
|
+
ref := competitionSubresourceRef(lookup.competition, lookup.match, subresource.key, subresource.suffix)
|
|
127
|
+
if ref == "" {
|
|
128
|
+
warnings = append(warnings, fmt.Sprintf("%s route unavailable for match %q", subresource.key, lookup.match.ID))
|
|
129
|
+
continue
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
resolved, err := s.client.ResolveRefChain(ctx, ref)
|
|
133
|
+
if err != nil {
|
|
134
|
+
warnings = append(warnings, fmt.Sprintf("%s %s: %v", subresource.key, ref, err))
|
|
135
|
+
continue
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
payload, err := decodePayloadMap(resolved.Body)
|
|
139
|
+
if err != nil {
|
|
140
|
+
warnings = append(warnings, fmt.Sprintf("%s %s: %v", subresource.key, resolved.CanonicalRef, err))
|
|
141
|
+
continue
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
entries, _ := normalizeCompetitionMetadataPayload(payload)
|
|
145
|
+
subresource.assign(entries)
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
result := NewDataResult(EntityCompMetadata, summary)
|
|
149
|
+
if len(compactWarnings(warnings)) > 0 {
|
|
150
|
+
result = NewPartialResult(EntityCompMetadata, summary, warnings...)
|
|
151
|
+
}
|
|
152
|
+
result.RequestedRef = lookup.resolved.RequestedRef
|
|
153
|
+
result.CanonicalRef = lookup.resolved.CanonicalRef
|
|
154
|
+
return result, nil
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
type competitionLookup struct {
|
|
158
|
+
competition Competition
|
|
159
|
+
match Match
|
|
160
|
+
resolved *ResolvedDocument
|
|
161
|
+
warnings []string
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
func (s *CompetitionService) resolveCompetitionLookup(
|
|
165
|
+
ctx context.Context,
|
|
166
|
+
query string,
|
|
167
|
+
opts CompetitionLookupOptions,
|
|
168
|
+
) (*competitionLookup, *NormalizedResult) {
|
|
169
|
+
helper := &MatchService{client: s.client, resolver: s.resolver}
|
|
170
|
+
lookup, passthrough := helper.resolveMatchLookup(ctx, query, MatchLookupOptions{LeagueID: strings.TrimSpace(opts.LeagueID)})
|
|
171
|
+
if passthrough != nil {
|
|
172
|
+
passthrough.Kind = EntityCompetition
|
|
173
|
+
return nil, passthrough
|
|
174
|
+
}
|
|
175
|
+
helper.enrichMatchTeamsFromIndex(lookup.match)
|
|
176
|
+
|
|
177
|
+
competition, err := NormalizeCompetition(lookup.resolved.Body, *lookup.match)
|
|
178
|
+
if err != nil {
|
|
179
|
+
result := NormalizedResult{
|
|
180
|
+
Kind: EntityCompetition,
|
|
181
|
+
Status: ResultStatusError,
|
|
182
|
+
Message: fmt.Sprintf("normalize competition %q: %v", lookup.resolved.CanonicalRef, err),
|
|
183
|
+
}
|
|
184
|
+
return nil, &result
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
return &competitionLookup{
|
|
188
|
+
competition: *competition,
|
|
189
|
+
match: *lookup.match,
|
|
190
|
+
resolved: lookup.resolved,
|
|
191
|
+
warnings: lookup.warnings,
|
|
192
|
+
}, nil
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
func (s *CompetitionService) subresourceList(
|
|
196
|
+
ctx context.Context,
|
|
197
|
+
query string,
|
|
198
|
+
opts CompetitionLookupOptions,
|
|
199
|
+
kind EntityKind,
|
|
200
|
+
key string,
|
|
201
|
+
suffix string,
|
|
202
|
+
) (NormalizedResult, error) {
|
|
203
|
+
lookup, passthrough := s.resolveCompetitionLookup(ctx, query, opts)
|
|
204
|
+
if passthrough != nil {
|
|
205
|
+
passthrough.Kind = kind
|
|
206
|
+
return *passthrough, nil
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
ref := competitionSubresourceRef(lookup.competition, lookup.match, key, suffix)
|
|
210
|
+
if ref == "" {
|
|
211
|
+
return NormalizedResult{
|
|
212
|
+
Kind: kind,
|
|
213
|
+
Status: ResultStatusEmpty,
|
|
214
|
+
Message: fmt.Sprintf("%s route unavailable for match %q", key, lookup.match.ID),
|
|
215
|
+
}, nil
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
resolved, err := s.client.ResolveRefChain(ctx, ref)
|
|
219
|
+
if err != nil {
|
|
220
|
+
return NewTransportErrorResult(kind, ref, err), nil
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
payload, err := decodePayloadMap(resolved.Body)
|
|
224
|
+
if err != nil {
|
|
225
|
+
return NormalizedResult{}, fmt.Errorf("decode %s payload %q: %w", key, resolved.CanonicalRef, err)
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
entries, isCollection := normalizeCompetitionMetadataPayload(payload)
|
|
229
|
+
items := make([]any, 0, len(entries))
|
|
230
|
+
for _, entry := range entries {
|
|
231
|
+
items = append(items, entry)
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
result := NewListResult(kind, items)
|
|
235
|
+
warnings := compactWarnings(lookup.warnings)
|
|
236
|
+
if isCollection && len(items) == 0 {
|
|
237
|
+
// Empty collection envelopes are valid for metadata routes and should render
|
|
238
|
+
// as a clean zero-result state rather than inheriting lookup warnings.
|
|
239
|
+
result.Status = ResultStatusEmpty
|
|
240
|
+
result.Warnings = nil
|
|
241
|
+
result.RequestedRef = resolved.RequestedRef
|
|
242
|
+
result.CanonicalRef = resolved.CanonicalRef
|
|
243
|
+
return result, nil
|
|
244
|
+
}
|
|
245
|
+
if len(warnings) > 0 {
|
|
246
|
+
result = NewPartialListResult(kind, items, warnings...)
|
|
247
|
+
}
|
|
248
|
+
result.RequestedRef = resolved.RequestedRef
|
|
249
|
+
result.CanonicalRef = resolved.CanonicalRef
|
|
250
|
+
return result, nil
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
// NormalizeCompetition maps a competition payload into the normalized competition shape.
|
|
254
|
+
func NormalizeCompetition(data []byte, match Match) (*Competition, error) {
|
|
255
|
+
payload, err := decodePayloadMap(data)
|
|
256
|
+
if err != nil {
|
|
257
|
+
return nil, err
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
competition := &Competition{
|
|
261
|
+
Ref: nonEmpty(stringField(payload, "$ref"), match.Ref, matchSubresourceRef(match, "", "")),
|
|
262
|
+
ID: nonEmpty(stringField(payload, "id"), match.ID, match.CompetitionID),
|
|
263
|
+
LeagueID: nonEmpty(match.LeagueID, refIDs(stringField(payload, "$ref"))["leagueId"]),
|
|
264
|
+
EventID: nonEmpty(match.EventID, refIDs(stringField(payload, "$ref"))["eventId"]),
|
|
265
|
+
CompetitionID: nonEmpty(match.CompetitionID, stringField(payload, "id"), match.ID),
|
|
266
|
+
Description: nonEmpty(stringField(payload, "description"), match.Description),
|
|
267
|
+
ShortDescription: nonEmpty(stringField(payload, "shortDescription"), match.ShortDescription),
|
|
268
|
+
Date: nonEmpty(stringField(payload, "date"), match.Date),
|
|
269
|
+
EndDate: nonEmpty(stringField(payload, "endDate"), match.EndDate),
|
|
270
|
+
MatchState: nonEmpty(stringField(payload, "state"), stringField(payload, "summary"), match.MatchState),
|
|
271
|
+
VenueName: nonEmpty(stringField(mapField(payload, "venue"), "fullName"), match.VenueName),
|
|
272
|
+
VenueSummary: nonEmpty(venueAddressSummary(mapField(payload, "venue")), match.VenueSummary),
|
|
273
|
+
ScoreSummary: nonEmpty(match.ScoreSummary, matchScoreSummary(match.Teams)),
|
|
274
|
+
StatusRef: nonEmpty(refFromField(payload, "status"), match.StatusRef, matchSubresourceRef(match, "status", "status")),
|
|
275
|
+
DetailsRef: nonEmpty(refFromField(payload, "details"), match.DetailsRef, matchSubresourceRef(match, "details", "details")),
|
|
276
|
+
MatchcardsRef: nonEmpty(refFromField(payload, "matchcards"), matchSubresourceRef(match, "matchcards", "matchcards")),
|
|
277
|
+
SituationRef: nonEmpty(refFromField(payload, "situation"), matchSubresourceRef(match, "situation", "situation")),
|
|
278
|
+
OfficialsRef: nonEmpty(refFromField(payload, "officials"), matchSubresourceRef(match, "officials", "officials")),
|
|
279
|
+
BroadcastsRef: nonEmpty(refFromField(payload, "broadcasts"), matchSubresourceRef(match, "broadcasts", "broadcasts")),
|
|
280
|
+
TicketsRef: nonEmpty(refFromField(payload, "tickets"), matchSubresourceRef(match, "tickets", "tickets")),
|
|
281
|
+
OddsRef: nonEmpty(refFromField(payload, "odds"), matchSubresourceRef(match, "odds", "odds")),
|
|
282
|
+
Teams: match.Teams,
|
|
283
|
+
Extensions: extensionsFromMap(payload,
|
|
284
|
+
"$ref", "id", "description", "shortDescription", "date", "endDate", "state", "summary",
|
|
285
|
+
"venue", "status", "details", "matchcards", "situation", "officials", "broadcasts", "tickets", "odds", "competitors",
|
|
286
|
+
),
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
return competition, nil
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
func competitionSubresourceRef(competition Competition, match Match, extensionKey, suffix string) string {
|
|
293
|
+
switch strings.TrimSpace(extensionKey) {
|
|
294
|
+
case "officials":
|
|
295
|
+
if strings.TrimSpace(competition.OfficialsRef) != "" {
|
|
296
|
+
return strings.TrimSpace(competition.OfficialsRef)
|
|
297
|
+
}
|
|
298
|
+
case "broadcasts":
|
|
299
|
+
if strings.TrimSpace(competition.BroadcastsRef) != "" {
|
|
300
|
+
return strings.TrimSpace(competition.BroadcastsRef)
|
|
301
|
+
}
|
|
302
|
+
case "tickets":
|
|
303
|
+
if strings.TrimSpace(competition.TicketsRef) != "" {
|
|
304
|
+
return strings.TrimSpace(competition.TicketsRef)
|
|
305
|
+
}
|
|
306
|
+
case "odds":
|
|
307
|
+
if strings.TrimSpace(competition.OddsRef) != "" {
|
|
308
|
+
return strings.TrimSpace(competition.OddsRef)
|
|
309
|
+
}
|
|
310
|
+
case "status":
|
|
311
|
+
if strings.TrimSpace(competition.StatusRef) != "" {
|
|
312
|
+
return strings.TrimSpace(competition.StatusRef)
|
|
313
|
+
}
|
|
314
|
+
case "details":
|
|
315
|
+
if strings.TrimSpace(competition.DetailsRef) != "" {
|
|
316
|
+
return strings.TrimSpace(competition.DetailsRef)
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
return matchSubresourceRef(match, extensionKey, suffix)
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
func normalizeCompetitionMetadataPayload(payload map[string]any) ([]CompetitionMetadataEntry, bool) {
|
|
324
|
+
if payload == nil {
|
|
325
|
+
return nil, false
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
_, hasItems := payload["items"]
|
|
329
|
+
if hasItems {
|
|
330
|
+
rows := mapSliceField(payload, "items")
|
|
331
|
+
entries := make([]CompetitionMetadataEntry, 0, len(rows))
|
|
332
|
+
for _, row := range rows {
|
|
333
|
+
entry := normalizeCompetitionMetadataEntry(row)
|
|
334
|
+
if isEmptyCompetitionMetadataEntry(entry) {
|
|
335
|
+
continue
|
|
336
|
+
}
|
|
337
|
+
entries = append(entries, entry)
|
|
338
|
+
}
|
|
339
|
+
return entries, true
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
entry := normalizeCompetitionMetadataEntry(payload)
|
|
343
|
+
if isEmptyCompetitionMetadataEntry(entry) {
|
|
344
|
+
return []CompetitionMetadataEntry{}, false
|
|
345
|
+
}
|
|
346
|
+
return []CompetitionMetadataEntry{entry}, false
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
func normalizeCompetitionMetadataEntry(payload map[string]any) CompetitionMetadataEntry {
|
|
350
|
+
if payload == nil {
|
|
351
|
+
return CompetitionMetadataEntry{}
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
position := mapField(payload, "position")
|
|
355
|
+
linksMap := mapField(payload, "links")
|
|
356
|
+
entry := CompetitionMetadataEntry{
|
|
357
|
+
Ref: stringField(payload, "$ref"),
|
|
358
|
+
ID: nonEmpty(stringField(payload, "id"), refIDs(stringField(payload, "$ref"))["detailId"]),
|
|
359
|
+
DisplayName: nonEmpty(stringField(payload, "displayName"), stringField(payload, "shortDisplayName")),
|
|
360
|
+
Name: nonEmpty(stringField(payload, "name"), stringField(payload, "description")),
|
|
361
|
+
Role: nonEmpty(stringField(position, "displayName"), stringField(position, "name"), stringField(payload, "position")),
|
|
362
|
+
Type: nonEmpty(stringField(payload, "type"), stringField(position, "name")),
|
|
363
|
+
Order: intField(payload, "order"),
|
|
364
|
+
Text: nonEmpty(stringField(payload, "text"), stringField(payload, "shortText"), stringField(payload, "summary")),
|
|
365
|
+
Value: nonEmpty(stringField(payload, "displayValue"), stringField(payload, "value")),
|
|
366
|
+
Href: nonEmpty(stringField(payload, "href"), stringField(linksMap, "href"), firstHrefFromLinks(payload)),
|
|
367
|
+
Extensions: extensionsFromMap(payload,
|
|
368
|
+
"$ref", "id", "displayName", "shortDisplayName", "name", "description", "position", "type",
|
|
369
|
+
"order", "text", "shortText", "summary", "displayValue", "value", "href", "links",
|
|
370
|
+
),
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
if entry.Name == "" {
|
|
374
|
+
entry.Name = entry.DisplayName
|
|
375
|
+
}
|
|
376
|
+
if entry.DisplayName == "" {
|
|
377
|
+
entry.DisplayName = entry.Name
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
return entry
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
func firstHrefFromLinks(payload map[string]any) string {
|
|
384
|
+
for _, item := range mapSliceField(payload, "links") {
|
|
385
|
+
href := stringField(item, "href")
|
|
386
|
+
if href != "" {
|
|
387
|
+
return href
|
|
388
|
+
}
|
|
389
|
+
}
|
|
390
|
+
return ""
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
func isEmptyCompetitionMetadataEntry(entry CompetitionMetadataEntry) bool {
|
|
394
|
+
return strings.TrimSpace(entry.Ref) == "" &&
|
|
395
|
+
strings.TrimSpace(entry.ID) == "" &&
|
|
396
|
+
strings.TrimSpace(entry.DisplayName) == "" &&
|
|
397
|
+
strings.TrimSpace(entry.Name) == "" &&
|
|
398
|
+
strings.TrimSpace(entry.Role) == "" &&
|
|
399
|
+
strings.TrimSpace(entry.Type) == "" &&
|
|
400
|
+
entry.Order == 0 &&
|
|
401
|
+
strings.TrimSpace(entry.Text) == "" &&
|
|
402
|
+
strings.TrimSpace(entry.Value) == "" &&
|
|
403
|
+
strings.TrimSpace(entry.Href) == "" &&
|
|
404
|
+
len(entry.Extensions) == 0
|
|
405
|
+
}
|
|
@@ -0,0 +1,234 @@
|
|
|
1
|
+
package cricinfo
|
|
2
|
+
|
|
3
|
+
import (
|
|
4
|
+
"context"
|
|
5
|
+
"net/http"
|
|
6
|
+
"net/http/httptest"
|
|
7
|
+
"path/filepath"
|
|
8
|
+
"testing"
|
|
9
|
+
"time"
|
|
10
|
+
)
|
|
11
|
+
|
|
12
|
+
func TestCompetitionServicePhase13MetadataRoutesAndEmptyCollections(t *testing.T) {
|
|
13
|
+
t.Parallel()
|
|
14
|
+
|
|
15
|
+
service := newPhase13CompetitionTestService(t)
|
|
16
|
+
|
|
17
|
+
ctx, cancel := context.WithTimeout(context.Background(), 12*time.Second)
|
|
18
|
+
defer cancel()
|
|
19
|
+
|
|
20
|
+
showResult, err := service.Show(ctx, "3rd Match", CompetitionLookupOptions{LeagueID: "19138"})
|
|
21
|
+
if err != nil {
|
|
22
|
+
t.Fatalf("Show error: %v", err)
|
|
23
|
+
}
|
|
24
|
+
if showResult.Kind != EntityCompetition {
|
|
25
|
+
t.Fatalf("expected show kind %q, got %q", EntityCompetition, showResult.Kind)
|
|
26
|
+
}
|
|
27
|
+
showCompetition, ok := showResult.Data.(Competition)
|
|
28
|
+
if !ok {
|
|
29
|
+
t.Fatalf("expected show data type Competition, got %T", showResult.Data)
|
|
30
|
+
}
|
|
31
|
+
if showCompetition.OfficialsRef == "" || showCompetition.BroadcastsRef == "" || showCompetition.TicketsRef == "" || showCompetition.OddsRef == "" {
|
|
32
|
+
t.Fatalf("expected competition metadata refs in show payload, got %+v", showCompetition)
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
officialsResult, err := service.Officials(ctx, "3rd Match", CompetitionLookupOptions{LeagueID: "19138"})
|
|
36
|
+
if err != nil {
|
|
37
|
+
t.Fatalf("Officials error: %v", err)
|
|
38
|
+
}
|
|
39
|
+
if officialsResult.Kind != EntityCompOfficial {
|
|
40
|
+
t.Fatalf("expected officials kind %q, got %q", EntityCompOfficial, officialsResult.Kind)
|
|
41
|
+
}
|
|
42
|
+
if len(officialsResult.Items) == 0 {
|
|
43
|
+
t.Fatalf("expected officials entries")
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
broadcastsResult, err := service.Broadcasts(ctx, "3rd Match", CompetitionLookupOptions{LeagueID: "19138"})
|
|
47
|
+
if err != nil {
|
|
48
|
+
t.Fatalf("Broadcasts error: %v", err)
|
|
49
|
+
}
|
|
50
|
+
if broadcastsResult.Kind != EntityCompBroadcast {
|
|
51
|
+
t.Fatalf("expected broadcasts kind %q, got %q", EntityCompBroadcast, broadcastsResult.Kind)
|
|
52
|
+
}
|
|
53
|
+
if broadcastsResult.Status != ResultStatusEmpty {
|
|
54
|
+
t.Fatalf("expected empty status for empty broadcasts collection, got %q", broadcastsResult.Status)
|
|
55
|
+
}
|
|
56
|
+
if len(broadcastsResult.Warnings) > 0 {
|
|
57
|
+
t.Fatalf("expected no warnings for empty-but-valid broadcasts collection, got %+v", broadcastsResult.Warnings)
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
ticketsResult, err := service.Tickets(ctx, "3rd Match", CompetitionLookupOptions{LeagueID: "19138"})
|
|
61
|
+
if err != nil {
|
|
62
|
+
t.Fatalf("Tickets error: %v", err)
|
|
63
|
+
}
|
|
64
|
+
if ticketsResult.Status != ResultStatusEmpty {
|
|
65
|
+
t.Fatalf("expected empty status for empty tickets collection, got %q", ticketsResult.Status)
|
|
66
|
+
}
|
|
67
|
+
if len(ticketsResult.Warnings) > 0 {
|
|
68
|
+
t.Fatalf("expected no warnings for empty-but-valid tickets collection, got %+v", ticketsResult.Warnings)
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
oddsResult, err := service.Odds(ctx, "3rd Match", CompetitionLookupOptions{LeagueID: "19138"})
|
|
72
|
+
if err != nil {
|
|
73
|
+
t.Fatalf("Odds error: %v", err)
|
|
74
|
+
}
|
|
75
|
+
if oddsResult.Kind != EntityCompOdds {
|
|
76
|
+
t.Fatalf("expected odds kind %q, got %q", EntityCompOdds, oddsResult.Kind)
|
|
77
|
+
}
|
|
78
|
+
if len(oddsResult.Items) == 0 {
|
|
79
|
+
t.Fatalf("expected odds entries")
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
metadataResult, err := service.Metadata(ctx, "3rd Match", CompetitionLookupOptions{LeagueID: "19138"})
|
|
83
|
+
if err != nil {
|
|
84
|
+
t.Fatalf("Metadata error: %v", err)
|
|
85
|
+
}
|
|
86
|
+
if metadataResult.Kind != EntityCompMetadata {
|
|
87
|
+
t.Fatalf("expected metadata kind %q, got %q", EntityCompMetadata, metadataResult.Kind)
|
|
88
|
+
}
|
|
89
|
+
if metadataResult.Status != ResultStatusOK {
|
|
90
|
+
t.Fatalf("expected metadata status ok, got %q (warnings=%+v)", metadataResult.Status, metadataResult.Warnings)
|
|
91
|
+
}
|
|
92
|
+
metadataSummary, ok := metadataResult.Data.(CompetitionMetadataSummary)
|
|
93
|
+
if !ok {
|
|
94
|
+
t.Fatalf("expected metadata data type CompetitionMetadataSummary, got %T", metadataResult.Data)
|
|
95
|
+
}
|
|
96
|
+
if len(metadataSummary.Officials) == 0 {
|
|
97
|
+
t.Fatalf("expected aggregated officials entries")
|
|
98
|
+
}
|
|
99
|
+
if len(metadataSummary.Broadcasts) != 0 || len(metadataSummary.Tickets) != 0 {
|
|
100
|
+
t.Fatalf("expected empty broadcasts/tickets in metadata summary, got broadcasts=%d tickets=%d", len(metadataSummary.Broadcasts), len(metadataSummary.Tickets))
|
|
101
|
+
}
|
|
102
|
+
if len(metadataSummary.Odds) == 0 {
|
|
103
|
+
t.Fatalf("expected aggregated odds entries")
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
func newPhase13CompetitionTestService(t *testing.T) *CompetitionService {
|
|
108
|
+
t.Helper()
|
|
109
|
+
|
|
110
|
+
competitionFixture := mustReadFixtureBytes(t, "matches-competitions/competition.json")
|
|
111
|
+
officialsFixture := mustReadFixtureBytes(t, "aux-competition-metadata/officials.json")
|
|
112
|
+
broadcastsFixture := mustReadFixtureBytes(t, "aux-competition-metadata/broadcasts.json")
|
|
113
|
+
leagueFixture := []byte(`{"$ref":"http://core.espnuk.org/v2/sports/cricket/leagues/19138","id":"19138","name":"Indian Premier League","shortName":"IPL","slug":"ipl","abbreviation":"IPL"}`)
|
|
114
|
+
oddsFixture := []byte(`{"count":1,"pageIndex":1,"pageSize":25,"pageCount":1,"items":[{"$ref":"http://core.espnuk.org/v2/sports/cricket/leagues/19138/events/1529474/competitions/1529474/odds/1","displayName":"Win Probability","value":"0.61","type":"win-probability"}]}`)
|
|
115
|
+
ticketsFixture := []byte(`{"count":0,"pageIndex":1,"pageSize":25,"pageCount":1,"items":[]}`)
|
|
116
|
+
|
|
117
|
+
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
118
|
+
base := "http://" + r.Host + "/v2/sports/cricket"
|
|
119
|
+
competitionPath := "/v2/sports/cricket/leagues/19138/events/1529474/competitions/1529474"
|
|
120
|
+
|
|
121
|
+
switch r.URL.Path {
|
|
122
|
+
case "/v2/sports/cricket/leagues/19138":
|
|
123
|
+
_, _ = w.Write(rewriteFixtureBaseURL(leagueFixture, base))
|
|
124
|
+
case competitionPath:
|
|
125
|
+
_, _ = w.Write(rewriteFixtureBaseURL(competitionFixture, base))
|
|
126
|
+
case competitionPath + "/officials":
|
|
127
|
+
_, _ = w.Write(rewriteFixtureBaseURL(officialsFixture, base))
|
|
128
|
+
case competitionPath + "/broadcasts":
|
|
129
|
+
_, _ = w.Write(rewriteFixtureBaseURL(broadcastsFixture, base))
|
|
130
|
+
case competitionPath + "/tickets":
|
|
131
|
+
_, _ = w.Write(rewriteFixtureBaseURL(ticketsFixture, base))
|
|
132
|
+
case competitionPath + "/odds":
|
|
133
|
+
_, _ = w.Write(rewriteFixtureBaseURL(oddsFixture, base))
|
|
134
|
+
default:
|
|
135
|
+
http.NotFound(w, r)
|
|
136
|
+
}
|
|
137
|
+
}))
|
|
138
|
+
t.Cleanup(server.Close)
|
|
139
|
+
|
|
140
|
+
index, err := OpenEntityIndex(filepath.Join(t.TempDir(), "resolver-index.json"))
|
|
141
|
+
if err != nil {
|
|
142
|
+
t.Fatalf("OpenEntityIndex error: %v", err)
|
|
143
|
+
}
|
|
144
|
+
if err := index.Upsert(IndexedEntity{
|
|
145
|
+
Kind: EntityMatch,
|
|
146
|
+
ID: "1529474",
|
|
147
|
+
Ref: "/leagues/19138/events/1529474/competitions/1529474",
|
|
148
|
+
Name: "3rd Match",
|
|
149
|
+
ShortName: "3rd Match",
|
|
150
|
+
LeagueID: "19138",
|
|
151
|
+
EventID: "1529474",
|
|
152
|
+
MatchID: "1529474",
|
|
153
|
+
Aliases: []string{"3rd Match", "1529474"},
|
|
154
|
+
UpdatedAt: time.Now().UTC(),
|
|
155
|
+
}); err != nil {
|
|
156
|
+
t.Fatalf("index upsert error: %v", err)
|
|
157
|
+
}
|
|
158
|
+
index.SetLastEventsSeedAt(time.Now().UTC())
|
|
159
|
+
|
|
160
|
+
client, err := NewClient(Config{BaseURL: server.URL + "/v2/sports/cricket"})
|
|
161
|
+
if err != nil {
|
|
162
|
+
t.Fatalf("NewClient error: %v", err)
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
resolver, err := NewResolver(ResolverConfig{
|
|
166
|
+
Client: client,
|
|
167
|
+
Index: index,
|
|
168
|
+
EventSeedTTL: 24 * time.Hour,
|
|
169
|
+
Now: func() time.Time { return time.Now().UTC() },
|
|
170
|
+
})
|
|
171
|
+
if err != nil {
|
|
172
|
+
t.Fatalf("NewResolver error: %v", err)
|
|
173
|
+
}
|
|
174
|
+
t.Cleanup(func() {
|
|
175
|
+
_ = resolver.Close()
|
|
176
|
+
})
|
|
177
|
+
|
|
178
|
+
service, err := NewCompetitionService(CompetitionServiceConfig{Client: client, Resolver: resolver})
|
|
179
|
+
if err != nil {
|
|
180
|
+
t.Fatalf("NewCompetitionService error: %v", err)
|
|
181
|
+
}
|
|
182
|
+
t.Cleanup(func() {
|
|
183
|
+
_ = service.Close()
|
|
184
|
+
})
|
|
185
|
+
|
|
186
|
+
return service
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
func TestLiveCompetitionMetadataRoutes(t *testing.T) {
|
|
190
|
+
t.Parallel()
|
|
191
|
+
requireLiveMatrix(t)
|
|
192
|
+
|
|
193
|
+
client, err := NewClient(Config{
|
|
194
|
+
Timeout: 12 * time.Second,
|
|
195
|
+
MaxRetries: 3,
|
|
196
|
+
})
|
|
197
|
+
if err != nil {
|
|
198
|
+
t.Fatalf("NewClient error: %v", err)
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
routes := []struct {
|
|
202
|
+
name string
|
|
203
|
+
ref string
|
|
204
|
+
keys []string
|
|
205
|
+
}{
|
|
206
|
+
{name: "competition", ref: "/leagues/19138/events/1529474/competitions/1529474", keys: []string{"officials", "broadcasts", "tickets", "odds"}},
|
|
207
|
+
{name: "officials", ref: "/leagues/11132/events/1527944/competitions/1527944/officials", keys: []string{"items", "count"}},
|
|
208
|
+
{name: "broadcasts", ref: "/leagues/11132/events/1527944/competitions/1527944/broadcasts", keys: []string{"items", "count"}},
|
|
209
|
+
{name: "tickets", ref: "/leagues/11132/events/1527944/competitions/1527944/tickets", keys: []string{"items", "count"}},
|
|
210
|
+
{name: "odds", ref: "/leagues/11132/events/1527944/competitions/1527944/odds", keys: []string{"items", "count"}},
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
for _, tc := range routes {
|
|
214
|
+
tc := tc
|
|
215
|
+
t.Run(tc.name, func(t *testing.T) {
|
|
216
|
+
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
|
217
|
+
defer cancel()
|
|
218
|
+
|
|
219
|
+
resolved, err := client.ResolveRefChain(ctx, tc.ref)
|
|
220
|
+
if err != nil {
|
|
221
|
+
if isLive503(err) {
|
|
222
|
+
t.Skipf("skipping %s after transient 503: %v", tc.name, err)
|
|
223
|
+
}
|
|
224
|
+
t.Fatalf("ResolveRefChain(%q) error: %v", tc.ref, err)
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
payload, err := decodePayloadMap(resolved.Body)
|
|
228
|
+
if err != nil {
|
|
229
|
+
t.Fatalf("decode payload for %s: %v", tc.name, err)
|
|
230
|
+
}
|
|
231
|
+
requireAnyKey(t, payload, tc.keys...)
|
|
232
|
+
})
|
|
233
|
+
}
|
|
234
|
+
}
|