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.
Files changed (121) hide show
  1. package/AGENTS.md +63 -0
  2. package/CONTRIBUTORS.md +75 -0
  3. package/LICENSE +21 -0
  4. package/Makefile +131 -0
  5. package/README.md +130 -0
  6. package/bin/cricinfo.js +44 -0
  7. package/cmd/cricinfo/main.go +15 -0
  8. package/go.mod +10 -0
  9. package/go.sum +10 -0
  10. package/internal/app/app.go +11 -0
  11. package/internal/app/app_test.go +122 -0
  12. package/internal/buildinfo/buildinfo.go +16 -0
  13. package/internal/cli/analysis.go +262 -0
  14. package/internal/cli/analysis_test.go +175 -0
  15. package/internal/cli/competitions.go +154 -0
  16. package/internal/cli/competitions_test.go +165 -0
  17. package/internal/cli/leagues.go +297 -0
  18. package/internal/cli/leagues_test.go +194 -0
  19. package/internal/cli/matches.go +403 -0
  20. package/internal/cli/matches_test.go +413 -0
  21. package/internal/cli/players.go +263 -0
  22. package/internal/cli/players_test.go +384 -0
  23. package/internal/cli/root.go +141 -0
  24. package/internal/cli/search.go +119 -0
  25. package/internal/cli/teams.go +214 -0
  26. package/internal/cli/teams_test.go +192 -0
  27. package/internal/cricinfo/analysis.go +1401 -0
  28. package/internal/cricinfo/analysis_phase15_test.go +267 -0
  29. package/internal/cricinfo/client.go +471 -0
  30. package/internal/cricinfo/client_test.go +280 -0
  31. package/internal/cricinfo/cmd/fixture-refresh/main.go +145 -0
  32. package/internal/cricinfo/competitions.go +405 -0
  33. package/internal/cricinfo/competitions_phase13_test.go +234 -0
  34. package/internal/cricinfo/coverage_ledger.go +122 -0
  35. package/internal/cricinfo/coverage_ledger_test.go +253 -0
  36. package/internal/cricinfo/decode.go +115 -0
  37. package/internal/cricinfo/decode_test.go +100 -0
  38. package/internal/cricinfo/entity_index.go +618 -0
  39. package/internal/cricinfo/entity_index_test.go +175 -0
  40. package/internal/cricinfo/fixture_matrix.go +243 -0
  41. package/internal/cricinfo/fixture_matrix_test.go +49 -0
  42. package/internal/cricinfo/fixtures_test.go +264 -0
  43. package/internal/cricinfo/historical_hydration.go +1641 -0
  44. package/internal/cricinfo/historical_phase14_test.go +542 -0
  45. package/internal/cricinfo/leagues.go +1210 -0
  46. package/internal/cricinfo/leagues_phase12_test.go +324 -0
  47. package/internal/cricinfo/live_leagues_test.go +169 -0
  48. package/internal/cricinfo/live_matches_test.go +203 -0
  49. package/internal/cricinfo/live_matrix_test.go +118 -0
  50. package/internal/cricinfo/live_players_test.go +122 -0
  51. package/internal/cricinfo/live_search_test.go +86 -0
  52. package/internal/cricinfo/live_smoke_test.go +213 -0
  53. package/internal/cricinfo/live_teams_test.go +104 -0
  54. package/internal/cricinfo/matches.go +1508 -0
  55. package/internal/cricinfo/matches_phase7_test.go +207 -0
  56. package/internal/cricinfo/matches_phase9_test.go +253 -0
  57. package/internal/cricinfo/normalize_entities.go +1727 -0
  58. package/internal/cricinfo/normalize_leagues.go +346 -0
  59. package/internal/cricinfo/players.go +1332 -0
  60. package/internal/cricinfo/players_phase10_test.go +174 -0
  61. package/internal/cricinfo/players_phase11_test.go +373 -0
  62. package/internal/cricinfo/render_contract.go +1088 -0
  63. package/internal/cricinfo/render_phase4_test.go +633 -0
  64. package/internal/cricinfo/renderer.go +1689 -0
  65. package/internal/cricinfo/resolver.go +813 -0
  66. package/internal/cricinfo/resolver_test.go +244 -0
  67. package/internal/cricinfo/teams.go +603 -0
  68. package/internal/cricinfo/teams_phase8_test.go +231 -0
  69. package/internal/cricinfo/testdata/fixtures/README.md +43 -0
  70. package/internal/cricinfo/testdata/fixtures/aux-competition-metadata/broadcasts.json +11 -0
  71. package/internal/cricinfo/testdata/fixtures/aux-competition-metadata/officials.json +150 -0
  72. package/internal/cricinfo/testdata/fixtures/details-plays/detail-110.json +157 -0
  73. package/internal/cricinfo/testdata/fixtures/details-plays/detail-52545007.json +145 -0
  74. package/internal/cricinfo/testdata/fixtures/details-plays/detail-52559021.json +143 -0
  75. package/internal/cricinfo/testdata/fixtures/details-plays/plays.json +15 -0
  76. package/internal/cricinfo/testdata/fixtures/endpoint-matrix.tsv +19 -0
  77. package/internal/cricinfo/testdata/fixtures/innings-fow-partnerships/fow-1.json +12 -0
  78. package/internal/cricinfo/testdata/fixtures/innings-fow-partnerships/fow.json +42 -0
  79. package/internal/cricinfo/testdata/fixtures/innings-fow-partnerships/innings-1-2.json +38 -0
  80. package/internal/cricinfo/testdata/fixtures/innings-fow-partnerships/partnership-1.json +31 -0
  81. package/internal/cricinfo/testdata/fixtures/innings-fow-partnerships/partnerships.json +42 -0
  82. package/internal/cricinfo/testdata/fixtures/leagues-seasons-standings/calendar-offdays.json +20 -0
  83. package/internal/cricinfo/testdata/fixtures/leagues-seasons-standings/calendar-ondays.json +21 -0
  84. package/internal/cricinfo/testdata/fixtures/leagues-seasons-standings/calendar.json +14 -0
  85. package/internal/cricinfo/testdata/fixtures/leagues-seasons-standings/season-2025.json +13 -0
  86. package/internal/cricinfo/testdata/fixtures/leagues-seasons-standings/season-group-1.json +13 -0
  87. package/internal/cricinfo/testdata/fixtures/leagues-seasons-standings/season-groups.json +11 -0
  88. package/internal/cricinfo/testdata/fixtures/leagues-seasons-standings/season-type-1.json +13 -0
  89. package/internal/cricinfo/testdata/fixtures/leagues-seasons-standings/season-types.json +11 -0
  90. package/internal/cricinfo/testdata/fixtures/leagues-seasons-standings/seasons.json +30 -0
  91. package/internal/cricinfo/testdata/fixtures/leagues-seasons-standings/standings-item-1.json +72 -0
  92. package/internal/cricinfo/testdata/fixtures/leagues-seasons-standings/standings-root.json +3 -0
  93. package/internal/cricinfo/testdata/fixtures/leagues-seasons-standings/standings.json +15 -0
  94. package/internal/cricinfo/testdata/fixtures/matches-competitions/competition.json +460 -0
  95. package/internal/cricinfo/testdata/fixtures/matches-competitions/event-1529474.json +86 -0
  96. package/internal/cricinfo/testdata/fixtures/matches-competitions/matchcards-1527966.json +368 -0
  97. package/internal/cricinfo/testdata/fixtures/matches-competitions/situation-1529474.json +10 -0
  98. package/internal/cricinfo/testdata/fixtures/players/athlete-1361257-statistics.json +126 -0
  99. package/internal/cricinfo/testdata/fixtures/players/athlete-1361257.json +113 -0
  100. package/internal/cricinfo/testdata/fixtures/players/roster-1361257-linescores-1-1-statistics-0.json +208 -0
  101. package/internal/cricinfo/testdata/fixtures/players/roster-1361257-linescores-1-2-statistics-0.json +252 -0
  102. package/internal/cricinfo/testdata/fixtures/players/roster-1361257-linescores.json +74 -0
  103. package/internal/cricinfo/testdata/fixtures/players/roster-1361257-statistics-0.json +1008 -0
  104. package/internal/cricinfo/testdata/fixtures/root-discovery/events.json +72 -0
  105. package/internal/cricinfo/testdata/fixtures/root-discovery/root.json +28 -0
  106. package/internal/cricinfo/testdata/fixtures/team-competitor/competitor-789643.json +40 -0
  107. package/internal/cricinfo/testdata/fixtures/team-competitor/leaders-789643.json +353 -0
  108. package/internal/cricinfo/testdata/fixtures/team-competitor/records-789643.json +91 -0
  109. package/internal/cricinfo/testdata/fixtures/team-competitor/roster-1147772-object.json +231 -0
  110. package/internal/cricinfo/testdata/fixtures/team-competitor/roster-1147772.json +235 -0
  111. package/internal/cricinfo/testdata/fixtures/team-competitor/roster-789643.json +322 -0
  112. package/internal/cricinfo/testdata/fixtures/team-competitor/scores-789643.json +19 -0
  113. package/internal/cricinfo/testdata/fixtures/team-competitor/statistics-789643.json +629 -0
  114. package/internal/cricinfo/testdata/fixtures/team-competitor/team-789643-athletes.json +7 -0
  115. package/internal/cricinfo/testdata/fixtures/team-competitor/team-789643.json +67 -0
  116. package/internal/cricinfo/testdata/golden/match-empty.golden +1 -0
  117. package/internal/cricinfo/testdata/golden/match-list.golden +2 -0
  118. package/internal/cricinfo/testdata/golden/match-partial.golden +3 -0
  119. package/internal/cricinfo/types.go +54 -0
  120. package/package.json +51 -0
  121. 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
+ }