Skip to content
Open
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 app/widget_maker.go
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,7 @@ import (
"github.com/wtfutil/wtf/modules/stocks/yfinance"
"github.com/wtfutil/wtf/modules/subreddit"
"github.com/wtfutil/wtf/modules/system"
"github.com/wtfutil/wtf/modules/tennis"
"github.com/wtfutil/wtf/modules/textfile"
"github.com/wtfutil/wtf/modules/todo"
"github.com/wtfutil/wtf/modules/todo_plus"
Expand Down Expand Up @@ -313,6 +314,9 @@ func MakeWidget(
case "system":
settings := system.NewSettingsFromYAML(moduleName, moduleConfig, config)
widget = system.NewWidget(tviewApp, redrawChan, buildDate(), buildVersion(), settings)
case "tennis":
settings := tennis.NewSettingsFromYAML(moduleName, moduleConfig, config)
widget = tennis.NewWidget(tviewApp, redrawChan, pages, settings)
case "textfile":
settings := textfile.NewSettingsFromYAML(moduleName, moduleConfig, config)
widget = textfile.NewWidget(tviewApp, redrawChan, pages, settings)
Expand Down
101 changes: 101 additions & 0 deletions modules/tennis/client.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
package tennis

import (
"context"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"net/url"
"strconv"
)

const (
defaultBaseURL = "https://api.livetennisapi.com/api/public/v1"

// FreeKeyURL is where users can sign up for a free API key.
FreeKeyURL = "https://livetennisapi.com/subscribe/free"
)

// Sentinel errors so the widget can render specific help text per failure mode.
var (
errUnauthorized = errors.New("unauthorized (401): invalid or missing API key")
errRateLimited = errors.New("rate limited (429): too many requests")
)

// Client fetches matches from the Live Tennis API.
type Client struct {
apiKey string
httpClient *http.Client
baseURL string
}

// NewClient creates a Client. Pass nil for httpClient to use http.DefaultClient.
// baseURL overrides the API endpoint (useful for testing); pass "" for the default.
func NewClient(apiKey string, httpClient *http.Client, baseURL string) *Client {
if httpClient == nil {
httpClient = http.DefaultClient
}
if baseURL == "" {
baseURL = defaultBaseURL
}
return &Client{apiKey: apiKey, httpClient: httpClient, baseURL: baseURL}
}

// FetchMatches retrieves matches filtered by status (live|upcoming),
// tour (optional, e.g. atp/wta) and limit (0 = API default).
func (c *Client) FetchMatches(ctx context.Context, status, tour string, limit int) ([]Match, error) {
u, err := url.Parse(c.baseURL + "/matches")
if err != nil {
return nil, fmt.Errorf("invalid base URL: %w", err)
}

q := u.Query()
if status != "" {
q.Set("status", status)
}
if tour != "" {
q.Set("tour", tour)
}
if limit > 0 {
q.Set("limit", strconv.Itoa(limit))
}
u.RawQuery = q.Encode()

req, err := http.NewRequestWithContext(ctx, http.MethodGet, u.String(), http.NoBody)
if err != nil {
return nil, err
}
req.Header.Set("Accept", "application/json")
req.Header.Set("x-api-key", c.apiKey)

resp, err := c.httpClient.Do(req)
if err != nil {
return nil, err
}
defer func() { _ = resp.Body.Close() }()

switch resp.StatusCode {
case http.StatusOK:
// fall through to parsing
case http.StatusUnauthorized:
return nil, errUnauthorized
case http.StatusTooManyRequests:
return nil, errRateLimited
default:
return nil, fmt.Errorf("unexpected status %d from Live Tennis API", resp.StatusCode)
}

body, err := io.ReadAll(resp.Body)
if err != nil {
return nil, err
}

var envelope matchesResponse
if err := json.Unmarshal(body, &envelope); err != nil {
return nil, fmt.Errorf("parsing Live Tennis API response: %w", err)
}

return envelope.Data, nil
}
199 changes: 199 additions & 0 deletions modules/tennis/client_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,199 @@
package tennis

import (
"context"
"errors"
"net/http"
"net/http/httptest"
"testing"
)

const liveFixture = `{
"data": [
{
"tournament": "Tampere",
"round": "QF",
"players": {
"p1": {"name": "Sinner", "ranking": 1},
"p2": {"name": "Alcaraz", "ranking": 2}
},
"score": {
"sets": [1, 1],
"games": [[6, 4, 2], [3, 6, 1]],
"points": ["40", "AD"],
"server": 1,
"is_tiebreak": false
},
"scheduled_time": "2026-07-24T15:00:00Z",
"winner": null
},
{
"tournament": "Umag",
"round": "R16",
"players": {
"p1": {"name": "Djokovic", "ranking": 7},
"p2": {"name": "Musetti", "ranking": 10}
},
"score": null,
"scheduled_time": "2026-07-24T18:30:00Z",
"winner": null
}
],
"meta": {"count": 2}
}`

func TestFetchMatches_Success(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if got := r.Header.Get("x-api-key"); got != "test-key" {
t.Errorf("expected x-api-key header 'test-key', got %q", got)
}
if got := r.URL.Query().Get("status"); got != "live" {
t.Errorf("expected status=live, got %q", got)
}
if got := r.URL.Query().Get("tour"); got != "atp" {
t.Errorf("expected tour=atp, got %q", got)
}
if got := r.URL.Query().Get("limit"); got != "5" {
t.Errorf("expected limit=5, got %q", got)
}
if r.URL.Path != "/matches" {
t.Errorf("expected path /matches, got %q", r.URL.Path)
}
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(liveFixture))
}))
defer srv.Close()

c := NewClient("test-key", srv.Client(), srv.URL)
matches, err := c.FetchMatches(context.Background(), "live", "atp", 5)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if len(matches) != 2 {
t.Fatalf("expected 2 matches, got %d", len(matches))
}

m := matches[0]
if m.Tournament != "Tampere" || m.Round != "QF" {
t.Errorf("unexpected tournament/round: %q %q", m.Tournament, m.Round)
}
if m.Players.P1.Name != "Sinner" || m.Players.P1.Ranking != 1 {
t.Errorf("unexpected p1: %+v", m.Players.P1)
}
if m.Score == nil {
t.Fatal("expected non-nil score for live match")
}
if m.Score.Server != 1 {
t.Errorf("expected server=1, got %d", m.Score.Server)
}
if len(m.Score.Games) != 2 || len(m.Score.Games[0]) != 3 || m.Score.Games[0][0] != 6 {
t.Errorf("unexpected games: %+v", m.Score.Games)
}
if len(m.Score.Points) != 2 || m.Score.Points[1] != "AD" {
t.Errorf("unexpected points: %+v", m.Score.Points)
}
if m.Winner != 0 {
t.Errorf("expected winner=0 for null winner, got %d", m.Winner)
}

if matches[1].Score != nil {
t.Error("expected nil score for upcoming match")
}
}

func TestFetchMatches_OmitsEmptyParams(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
q := r.URL.Query()
if _, ok := q["tour"]; ok {
t.Error("expected no tour param")
}
if _, ok := q["limit"]; ok {
t.Error("expected no limit param")
}
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"data": [], "meta": {}}`))
}))
defer srv.Close()

c := NewClient("k", srv.Client(), srv.URL)
matches, err := c.FetchMatches(context.Background(), "live", "", 0)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if len(matches) != 0 {
t.Fatalf("expected 0 matches, got %d", len(matches))
}
}

func TestFetchMatches_Unauthorized(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusUnauthorized)
}))
defer srv.Close()

c := NewClient("bad-key", srv.Client(), srv.URL)
_, err := c.FetchMatches(context.Background(), "live", "", 0)
if !errors.Is(err, errUnauthorized) {
t.Fatalf("expected errUnauthorized, got %v", err)
}
}

func TestFetchMatches_RateLimited(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusTooManyRequests)
}))
defer srv.Close()

c := NewClient("k", srv.Client(), srv.URL)
_, err := c.FetchMatches(context.Background(), "live", "", 0)
if !errors.Is(err, errRateLimited) {
t.Fatalf("expected errRateLimited, got %v", err)
}
}

func TestFetchMatches_ServerError(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusInternalServerError)
}))
defer srv.Close()

c := NewClient("k", srv.Client(), srv.URL)
_, err := c.FetchMatches(context.Background(), "live", "", 0)
if err == nil {
t.Fatal("expected error for 500 status")
}
if errors.Is(err, errUnauthorized) || errors.Is(err, errRateLimited) {
t.Fatalf("expected generic error, got %v", err)
}
}

func TestFetchMatches_InvalidJSON(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
_, _ = w.Write([]byte("not json"))
}))
defer srv.Close()

c := NewClient("k", srv.Client(), srv.URL)
_, err := c.FetchMatches(context.Background(), "live", "", 0)
if err == nil {
t.Fatal("expected error for invalid JSON")
}
}

func TestFetchMatches_RequestError(t *testing.T) {
c := NewClient("k", nil, "http://127.0.0.1:1") // port 1 should refuse
_, err := c.FetchMatches(context.Background(), "live", "", 0)
if err == nil {
t.Fatal("expected error for connection refused")
}
}

func TestNewClient_Defaults(t *testing.T) {
c := NewClient("k", nil, "")
if c.httpClient != http.DefaultClient {
t.Error("expected http.DefaultClient when nil passed")
}
if c.baseURL != defaultBaseURL {
t.Errorf("expected default base URL %q, got %q", defaultBaseURL, c.baseURL)
}
}
Loading