e336aeea97
Release / test (push) Failing after 4s
Release / build (amd64, darwin) (push) Has been skipped
Release / build (amd64, linux) (push) Has been skipped
Release / build (arm64, darwin) (push) Has been skipped
Release / build (arm64, linux) (push) Has been skipped
Release / release (push) Has been skipped
Assigning over a day somebody else held did nothing but flash a 409 for three seconds. The server holds one person per date and refused any that was taken, all-or-nothing, so pressing W on a week where a single day was already assigned placed none of the other six either. The only way through was d on each day first — seven delete-and-confirm cycles to move one week. The clash is already on screen, so it is found before the request rather than read back out of an error: the picker hands off to a confirmation naming who loses the days and how many there are, and accepting sends the whole selection with replace, which terdut-server v0.8.0 added. One question to move a week, and nobody's shift moves without somebody being asked. A day nobody holds still assigns with no prompt at all. Reassigning somebody to a day they already hold raises no prompt, since it takes nothing from anyone, but it does send replace: the server rejects any date that exists, so without it a harmless no-op would fail.
366 lines
12 KiB
Go
366 lines
12 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", ""},
|
|
{"set notify target", func(c *Client) error { _, err := c.SetUserNotifyTarget(7, "t"); return err },
|
|
http.MethodPut, "/api/users/7/notify", ""},
|
|
}
|
|
|
|
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)
|
|
}
|
|
})
|
|
|
|
// replace is what takes a day off its current holder, so it has to reach the
|
|
// wire when asked for — and stay off it when not.
|
|
t.Run("assign schedule", func(t *testing.T) {
|
|
c, got := stub(t, http.StatusCreated, `[]`)
|
|
if _, err := c.AssignSchedule(3, []string{"2026-07-27"}, false); err != nil {
|
|
t.Fatalf("assign: %v", err)
|
|
}
|
|
if got.body != `{"user_id":3,"dates":["2026-07-27"]}` {
|
|
t.Errorf("unexpected body %q", got.body)
|
|
}
|
|
})
|
|
|
|
t.Run("assign schedule with replace", func(t *testing.T) {
|
|
c, got := stub(t, http.StatusCreated, `[]`)
|
|
if _, err := c.AssignSchedule(3, []string{"2026-07-27"}, true); err != nil {
|
|
t.Fatalf("assign: %v", err)
|
|
}
|
|
if got.body != `{"user_id":3,"dates":["2026-07-27"],"replace":true}` {
|
|
t.Errorf("unexpected body %q", got.body)
|
|
}
|
|
})
|
|
|
|
t.Run("set notify target", func(t *testing.T) {
|
|
c, got := stub(t, http.StatusOK, `{}`)
|
|
if _, err := c.SetUserNotifyTarget(3, "terdut-niklas"); err != nil {
|
|
t.Fatalf("set notify target: %v", err)
|
|
}
|
|
if got.body != `{"ntfy_topic":"terdut-niklas"}` {
|
|
t.Errorf("unexpected body %q", got.body)
|
|
}
|
|
})
|
|
|
|
// Clearing has to put an explicit empty string on the wire: omitting the
|
|
// field would leave the topic untouched instead of removing it.
|
|
t.Run("clear notify target", func(t *testing.T) {
|
|
c, got := stub(t, http.StatusOK, `{}`)
|
|
if _, err := c.SetUserNotifyTarget(3, ""); err != nil {
|
|
t.Fatalf("clear notify target: %v", err)
|
|
}
|
|
if got.body != `{"ntfy_topic":""}` {
|
|
t.Errorf("expected an explicit empty topic, got %q", got.body)
|
|
}
|
|
})
|
|
}
|
|
|
|
func TestUser_TopicFlattensNilAndEmpty(t *testing.T) {
|
|
var users []User
|
|
if err := json.Unmarshal([]byte(
|
|
`[{"id":1,"username":"a"},{"id":2,"username":"b","ntfy_topic":""},
|
|
{"id":3,"username":"c","ntfy_topic":"terdut-c"}]`), &users); err != nil {
|
|
t.Fatalf("decode: %v", err)
|
|
}
|
|
want := []string{"", "", "terdut-c"}
|
|
for i, u := range users {
|
|
if got := u.Topic(); got != want[i] {
|
|
t.Errorf("user %d: expected topic %q, got %q", u.ID, want[i], got)
|
|
}
|
|
}
|
|
}
|
|
|
|
// 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)
|
|
}
|
|
}
|