From a36ac28fc36e1f45ee21a2658077c6f234206d09 Mon Sep 17 00:00:00 2001 From: Michael Madden Date: Sat, 14 Nov 2026 08:10:45 -0600 Subject: [PATCH] Split main into fetch and render, add tests - fetch(url) does the HTTP request and decoding and returns errors instead of exiting, so it can be tested against httptest servers. - render(w, summary) writes to an io.Writer and returns write errors. - Add tests for a good response, a 500, malformed JSON, and the rendered output, using a small fixture in testdata/. Co-Authored-By: Claude Opus 5.5 --- TODO.md | 2 +- github_status.go | 50 ++++++++++++++------- github_status_test.go | 100 ++++++++++++++++++++++++++++++++++++++++++ testdata/summary.json | 17 +++++++ 4 files changed, 153 insertions(+), 16 deletions(-) create mode 100644 github_status_test.go create mode 100644 testdata/summary.json diff --git a/TODO.md b/TODO.md index 84b01e5..df4c7b0 100644 --- a/TODO.md +++ b/TODO.md @@ -34,7 +34,7 @@ ## Tests / CI -- [ ] Split into `fetch(url)` and `render(w io.Writer, summary)`; test `render` +- [x] Split into `fetch(url)` and `render(w io.Writer, summary)`; test `render` with a JSON fixture and `fetch` with `httptest.NewServer` - [ ] Add a lint job (`golangci/golangci-lint-action`) to CI - [x] CI targets Go `oldstable` and `stable` (via `actions/setup-go` aliases) diff --git a/github_status.go b/github_status.go index e941b76..1a67068 100644 --- a/github_status.go +++ b/github_status.go @@ -7,6 +7,7 @@ package main import ( "encoding/json" "fmt" + "io" "net/http" "os" "time" @@ -44,33 +45,52 @@ type summary struct { } `json:"status"` } +const summaryURL = "https://kctbh9vrtdwd.statuspage.io/api/v2/summary.json" + func main() { - client := &http.Client{Timeout: 10 * time.Second} - response, err := client.Get("https://kctbh9vrtdwd.statuspage.io/api/v2/summary.json") + s, err := fetch(summaryURL) checkErr(err) + checkErr(render(os.Stdout, s)) +} + +// fetch retrieves and decodes the status page summary at url. +func fetch(url string) (summary, error) { + var s summary + + client := &http.Client{Timeout: 10 * time.Second} + response, err := client.Get(url) + if err != nil { + return s, err + } defer func() { _ = response.Body.Close() }() if response.StatusCode != http.StatusOK { - checkErr(fmt.Errorf("unexpected HTTP status: %s", response.Status)) + return s, fmt.Errorf("unexpected HTTP status: %s", response.Status) } - var responseObject summary - err = json.NewDecoder(response.Body).Decode(&responseObject) - checkErr(err) + if err := json.NewDecoder(response.Body).Decode(&s); err != nil { + return s, fmt.Errorf("decoding summary: %w", err) + } + return s, nil +} - for _, component := range responseObject.Components { - fmt.Printf("%-60s", component.Name) +// render writes one line per component to w, with the status colorized. +func render(w io.Writer, s summary) error { + for _, component := range s.Components { + c := color.New(color.FgYellow) if component.Status == "operational" { - color.Set(color.FgGreen) + c = color.New(color.FgGreen) } else if component.Status == "major_outage" { - color.Set(color.FgRed) - } else { - color.Set(color.FgYellow) + c = color.New(color.FgRed) + } + if _, err := fmt.Fprintf(w, "%-60s", component.Name); err != nil { + return err + } + if _, err := c.Fprintln(w, component.Status); err != nil { + return err } - fmt.Printf("%s\n", component.Status) - color.Unset() } - + return nil } func checkErr(err error) { diff --git a/github_status_test.go b/github_status_test.go new file mode 100644 index 0000000..d56e356 --- /dev/null +++ b/github_status_test.go @@ -0,0 +1,100 @@ +package main + +import ( + "bytes" + "fmt" + "net/http" + "net/http/httptest" + "os" + "strings" + "testing" + + "github.com/fatih/color" +) + +func TestMain(m *testing.M) { + color.NoColor = true + os.Exit(m.Run()) +} + +func serveFixture(t *testing.T) *httptest.Server { + t.Helper() + data, err := os.ReadFile("testdata/summary.json") + if err != nil { + t.Fatal(err) + } + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + _, _ = w.Write(data) + })) + t.Cleanup(srv.Close) + return srv +} + +func TestFetch(t *testing.T) { + srv := serveFixture(t) + + s, err := fetch(srv.URL) + if err != nil { + t.Fatalf("fetch: %v", err) + } + if got := len(s.Components); got != 3 { + t.Fatalf("got %d components, want 3", got) + } + if got := s.Status.Description; got != "Partial System Outage" { + t.Errorf("status description = %q", got) + } +} + +func TestFetchErrors(t *testing.T) { + tests := []struct { + name string + handler http.HandlerFunc + want string + }{ + { + name: "server error", + handler: func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusInternalServerError) + }, + want: "unexpected HTTP status: 500", + }, + { + name: "malformed JSON", + handler: func(w http.ResponseWriter, _ *http.Request) { + _, _ = fmt.Fprint(w, "not json") + }, + want: "decoding summary", + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + srv := httptest.NewServer(tt.handler) + defer srv.Close() + + _, err := fetch(srv.URL) + if err == nil || !strings.Contains(err.Error(), tt.want) { + t.Fatalf("got error %v, want one containing %q", err, tt.want) + } + }) + } +} + +func TestRender(t *testing.T) { + s, err := fetch(serveFixture(t).URL) + if err != nil { + t.Fatal(err) + } + + var buf bytes.Buffer + if err := render(&buf, s); err != nil { + t.Fatal(err) + } + + want := fmt.Sprintf("%-60s%s\n%-60s%s\n%-60s%s\n", + "Git Operations", "operational", + "Actions", "major_outage", + "Pages", "degraded_performance") + if got := buf.String(); got != want { + t.Errorf("render output mismatch\ngot:\n%s\nwant:\n%s", got, want) + } +} diff --git a/testdata/summary.json b/testdata/summary.json new file mode 100644 index 0000000..56746c6 --- /dev/null +++ b/testdata/summary.json @@ -0,0 +1,17 @@ +{ + "page": { + "id": "kctbh9vrtdwd", + "name": "GitHub", + "url": "https://www.githubstatus.com", + "time_zone": "Etc/UTC", + "updated_at": "2026-11-14T12:00:00.000Z" + }, + "components": [ + {"id": "1", "name": "Git Operations", "status": "operational", "group": false, "position": 1}, + {"id": "2", "name": "Actions", "status": "major_outage", "group": false, "position": 2}, + {"id": "3", "name": "Pages", "status": "degraded_performance", "group": false, "position": 3} + ], + "incidents": [], + "scheduled_maintenances": [], + "status": {"indicator": "major", "description": "Partial System Outage"} +}