8482315651
The repo had no tests at all, which the v0.4.0 rewrite made
uncomfortable: this client speaks terdut-server's REST API directly, and
a wrong path or method is invisible until somebody runs the binary
against a live server. That is exactly how it broke when the server split
alerts from incidents.
The Elm architecture makes most of this cheap to check without a
terminal. Update is (Model, Msg) -> (Model, Cmd), so keypresses can be
synthesised and the resulting model inspected; a nil command is a
readable assertion that the model decided to do nothing.
Three suites:
- client_test.go drives every incident endpoint against an httptest
stub that records method, path, query and body. Also covers the
filter query params, that a server error message survives into the
error the UI shows, that a 404 from the on-call endpoint is not an
error, and that omitted optional fields decode to zero rather than
failing.
- model_test.go covers the pure helpers: filter cycling, the snoozed
pseudo-status, duration formatting, row builders, and the column
width arithmetic that overflowed the terminal once already.
- update_test.go covers the rules worth protecting rather than
coverage for its own sake. Resolve prompts first and cancelling does
not act, since resolution is terminal server-side. A resolved
incident rejects all six workflow keys. Archiving refuses while an
incident is open. The note cursor walks notes only and wraps. Stats
returns to whichever view opened it. Modal states do not auto-refresh
underneath the user.
129 tests, running in about 40ms.
305 lines
9.8 KiB
Go
305 lines
9.8 KiB
Go
package api
|
|
|
|
import (
|
|
"encoding/json"
|
|
"io"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
)
|
|
|
|
// call records what the client actually put on the wire. The paths and methods
|
|
// are the contract with terdut-server, and getting one wrong is exactly how this
|
|
// client broke when the server split alerts from incidents.
|
|
type call struct {
|
|
method string
|
|
path string
|
|
query string
|
|
body string
|
|
auth string
|
|
}
|
|
|
|
// stub serves one canned response and records the request that fetched it.
|
|
func stub(t *testing.T, status int, response string) (*Client, *call) {
|
|
t.Helper()
|
|
got := &call{}
|
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
body, _ := io.ReadAll(r.Body)
|
|
got.method, got.path, got.query = r.Method, r.URL.Path, r.URL.RawQuery
|
|
got.body, got.auth = string(body), r.Header.Get("Authorization")
|
|
w.Header().Set("Content-Type", "application/json")
|
|
w.WriteHeader(status)
|
|
io.WriteString(w, response)
|
|
}))
|
|
t.Cleanup(srv.Close)
|
|
return NewClient(srv.URL, "test-key"), got
|
|
}
|
|
|
|
func TestClient_SendsBearerToken(t *testing.T) {
|
|
c, got := stub(t, http.StatusOK, `[]`)
|
|
if _, err := c.ListIncidents("", false, false, 0); err != nil {
|
|
t.Fatalf("list: %v", err)
|
|
}
|
|
if got.auth != "Bearer test-key" {
|
|
t.Errorf("expected bearer token, got %q", got.auth)
|
|
}
|
|
}
|
|
|
|
// Every incident action, with the method and path terdut-server exposes.
|
|
func TestClient_IncidentEndpoints(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
invoke func(*Client) error
|
|
method string
|
|
path string
|
|
// resp defaults to a JSON object; endpoints returning a list need an array.
|
|
resp string
|
|
}{
|
|
{"get", func(c *Client) error { _, err := c.GetIncident(7); return err },
|
|
http.MethodGet, "/api/incidents/7", ""},
|
|
{"timeline", func(c *Client) error { _, err := c.GetIncidentTimeline(7); return err },
|
|
http.MethodGet, "/api/incidents/7/timeline", `[]`},
|
|
{"acknowledge", func(c *Client) error { _, err := c.AcknowledgeIncident(7); return err },
|
|
http.MethodPost, "/api/incidents/7/acknowledge", ""},
|
|
{"unacknowledge", func(c *Client) error { return c.UnacknowledgeIncident(7) },
|
|
http.MethodDelete, "/api/incidents/7/acknowledge", ""},
|
|
{"resolve", func(c *Client) error { _, err := c.ResolveIncident(7); return err },
|
|
http.MethodPost, "/api/incidents/7/resolve", ""},
|
|
{"assign", func(c *Client) error { _, err := c.AssignIncident(7, 3); return err },
|
|
http.MethodPost, "/api/incidents/7/assign", ""},
|
|
{"snooze", func(c *Client) error { _, err := c.SnoozeIncident(7, "2h"); return err },
|
|
http.MethodPost, "/api/incidents/7/snooze", ""},
|
|
{"unsnooze", func(c *Client) error { return c.UnsnoozeIncident(7) },
|
|
http.MethodDelete, "/api/incidents/7/snooze", ""},
|
|
{"archive", func(c *Client) error { _, err := c.ArchiveIncident(7); return err },
|
|
http.MethodPost, "/api/incidents/7/archive", ""},
|
|
{"unarchive", func(c *Client) error { return c.UnarchiveIncident(7) },
|
|
http.MethodDelete, "/api/incidents/7/archive", ""},
|
|
{"add note", func(c *Client) error { _, err := c.AddNote(7, "hi"); return err },
|
|
http.MethodPost, "/api/incidents/7/notes", ""},
|
|
{"delete note", func(c *Client) error { return c.DeleteNote(7, 12) },
|
|
http.MethodDelete, "/api/incidents/7/notes/12", ""},
|
|
{"stats", func(c *Client) error { _, err := c.GetIncidentStats(); return err },
|
|
http.MethodGet, "/api/stats/incidents", ""},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
resp := tt.resp
|
|
if resp == "" {
|
|
resp = `{}`
|
|
}
|
|
c, got := stub(t, http.StatusOK, resp)
|
|
if err := tt.invoke(c); err != nil {
|
|
t.Fatalf("%s: %v", tt.name, err)
|
|
}
|
|
if got.method != tt.method || got.path != tt.path {
|
|
t.Errorf("expected %s %s, got %s %s", tt.method, tt.path, got.method, got.path)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestListIncidents_Filters(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
status string
|
|
archived bool
|
|
snoozed bool
|
|
limit int
|
|
want string
|
|
}{
|
|
{"default is the open queue", "", false, false, 0, ""},
|
|
{"status", "triggered", false, false, 0, "status=triggered"},
|
|
{"archived", "resolved", true, false, 0, "archived=true&status=resolved"},
|
|
{"snoozed", "", false, true, 0, "snoozed=true"},
|
|
{"limit", "", false, false, 500, "limit=500"},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
c, got := stub(t, http.StatusOK, `[]`)
|
|
if _, err := c.ListIncidents(tt.status, tt.archived, tt.snoozed, tt.limit); err != nil {
|
|
t.Fatalf("list: %v", err)
|
|
}
|
|
if got.query != tt.want {
|
|
t.Errorf("expected query %q, got %q", tt.want, got.query)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestClient_RequestBodies(t *testing.T) {
|
|
t.Run("assign", func(t *testing.T) {
|
|
c, got := stub(t, http.StatusOK, `{}`)
|
|
if _, err := c.AssignIncident(1, 42); err != nil {
|
|
t.Fatalf("assign: %v", err)
|
|
}
|
|
var body struct {
|
|
UserID int64 `json:"user_id"`
|
|
}
|
|
if err := json.Unmarshal([]byte(got.body), &body); err != nil {
|
|
t.Fatalf("decode body %q: %v", got.body, err)
|
|
}
|
|
if body.UserID != 42 {
|
|
t.Errorf("expected user_id 42, got %d", body.UserID)
|
|
}
|
|
})
|
|
|
|
t.Run("snooze", func(t *testing.T) {
|
|
c, got := stub(t, http.StatusOK, `{}`)
|
|
if _, err := c.SnoozeIncident(1, "90m"); err != nil {
|
|
t.Fatalf("snooze: %v", err)
|
|
}
|
|
var body struct {
|
|
Duration string `json:"duration"`
|
|
}
|
|
if err := json.Unmarshal([]byte(got.body), &body); err != nil {
|
|
t.Fatalf("decode body %q: %v", got.body, err)
|
|
}
|
|
if body.Duration != "90m" {
|
|
t.Errorf("expected duration 90m, got %q", body.Duration)
|
|
}
|
|
})
|
|
}
|
|
|
|
// The 409 on re-resolving is the server telling the user why nothing happened,
|
|
// so the message has to survive into the error the TUI displays.
|
|
func TestClient_SurfacesServerErrorMessage(t *testing.T) {
|
|
c, _ := stub(t, http.StatusConflict, `{"error":"incident is resolved"}`)
|
|
_, err := c.ResolveIncident(1)
|
|
if err == nil {
|
|
t.Fatal("expected an error on 409")
|
|
}
|
|
if !strings.Contains(err.Error(), "incident is resolved") || !strings.Contains(err.Error(), "409") {
|
|
t.Errorf("expected status and server message in %q", err.Error())
|
|
}
|
|
}
|
|
|
|
func TestClient_ErrorWithoutBody(t *testing.T) {
|
|
c, _ := stub(t, http.StatusInternalServerError, ``)
|
|
if _, err := c.GetIncident(1); err == nil || !strings.Contains(err.Error(), "500") {
|
|
t.Errorf("expected a 500 error, got %v", err)
|
|
}
|
|
}
|
|
|
|
// Nobody on call is a normal state, not a failure.
|
|
func TestGetCurrentOnCall_404IsNotAnError(t *testing.T) {
|
|
c, _ := stub(t, http.StatusNotFound, `{"error":"no one is on call today"}`)
|
|
entry, err := c.GetCurrentOnCall()
|
|
if err != nil {
|
|
t.Fatalf("expected no error, got %v", err)
|
|
}
|
|
if entry != nil {
|
|
t.Errorf("expected nil entry, got %+v", entry)
|
|
}
|
|
}
|
|
|
|
// Optional fields are omitted by the server rather than sent null, so decoding
|
|
// has to leave them zero instead of failing.
|
|
func TestIncident_DecodesSparseServerShape(t *testing.T) {
|
|
c, _ := stub(t, http.StatusOK, `{
|
|
"id": 1,
|
|
"group_key": "{}:{alertname=\"DiskFull\"}",
|
|
"title": "DiskFull (namespace=prod)",
|
|
"group_labels": {"alertname": "DiskFull", "namespace": "prod"},
|
|
"status": "triggered",
|
|
"severity": "critical",
|
|
"triggered_at": "2026-07-30T10:00:00Z"
|
|
}`)
|
|
|
|
inc, err := c.GetIncident(1)
|
|
if err != nil {
|
|
t.Fatalf("get: %v", err)
|
|
}
|
|
if inc.Title != "DiskFull (namespace=prod)" || inc.Severity != "critical" {
|
|
t.Errorf("unexpected incident %+v", inc)
|
|
}
|
|
if inc.GroupLabels["namespace"] != "prod" {
|
|
t.Errorf("expected group labels decoded, got %v", inc.GroupLabels)
|
|
}
|
|
if !inc.IsOpen() {
|
|
t.Error("an incident with no resolved_at is open")
|
|
}
|
|
if inc.IsSnoozed() {
|
|
t.Error("an incident with no snoozed_until is not snoozed")
|
|
}
|
|
if inc.AcknowledgedByID != nil || inc.AssignedToID != nil {
|
|
t.Error("expected acknowledgement and assignment to be absent")
|
|
}
|
|
}
|
|
|
|
// A snooze expires by falling into the past; the server sweeps nothing, so the
|
|
// client is what decides a stale snooze no longer counts.
|
|
func TestIncident_IsSnoozed(t *testing.T) {
|
|
past := time.Now().Add(-time.Hour)
|
|
future := time.Now().Add(time.Hour)
|
|
|
|
tests := []struct {
|
|
name string
|
|
until *time.Time
|
|
want bool
|
|
}{
|
|
{"never snoozed", nil, false},
|
|
{"snooze in the past has expired", &past, false},
|
|
{"snooze in the future holds", &future, true},
|
|
}
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
if got := (Incident{SnoozedUntil: tt.until}).IsSnoozed(); got != tt.want {
|
|
t.Errorf("expected %v, got %v", tt.want, got)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestIncident_IsOpen(t *testing.T) {
|
|
now := time.Now()
|
|
if !(Incident{}).IsOpen() {
|
|
t.Error("no resolved_at means open")
|
|
}
|
|
if (Incident{ResolvedAt: &now}).IsOpen() {
|
|
t.Error("resolved_at means closed")
|
|
}
|
|
}
|
|
|
|
func TestAlert_DecodesIncidentLink(t *testing.T) {
|
|
c, _ := stub(t, http.StatusOK, `{"id":3,"name":"DiskFull","status":"firing","incident_id":7}`)
|
|
a, err := c.GetAlert(3)
|
|
if err != nil {
|
|
t.Fatalf("get alert: %v", err)
|
|
}
|
|
if a.IncidentID == nil || *a.IncidentID != 7 {
|
|
t.Errorf("expected incident_id 7, got %v", a.IncidentID)
|
|
}
|
|
}
|
|
|
|
func TestListAlerts_ArchivedFilter(t *testing.T) {
|
|
c, got := stub(t, http.StatusOK, `[]`)
|
|
if _, err := c.ListAlerts("", true, 50); err != nil {
|
|
t.Fatalf("list alerts: %v", err)
|
|
}
|
|
if got.path != "/api/alerts" || got.query != "archived=true&limit=50" {
|
|
t.Errorf("unexpected request %s?%s", got.path, got.query)
|
|
}
|
|
}
|
|
|
|
// MTTA and MTTR are null until something has been acknowledged or resolved. That
|
|
// is "no data", and it must not decode to a confident zero.
|
|
func TestIncidentStats_NullAveragesStayNil(t *testing.T) {
|
|
c, _ := stub(t, http.StatusOK,
|
|
`{"total":2,"triggered":2,"acknowledged":0,"resolved":0,"mtta_seconds":null,"mttr_seconds":null}`)
|
|
stats, err := c.GetIncidentStats()
|
|
if err != nil {
|
|
t.Fatalf("stats: %v", err)
|
|
}
|
|
if stats.Total != 2 || stats.Triggered != 2 {
|
|
t.Errorf("unexpected counts %+v", stats)
|
|
}
|
|
if stats.MTTASeconds != nil || stats.MTTRSeconds != nil {
|
|
t.Errorf("expected nil averages, got %v / %v", stats.MTTASeconds, stats.MTTRSeconds)
|
|
}
|
|
}
|