Files
terdut-tui/internal/api/client_test.go
T
Niklas Ye 057302cb39 Show similar earlier incidents and let notes be marked as the fix
The incident view gets a "Seen before" section from the server's new
/similar endpoint; an older server without it just shows nothing. C adds a
note as the resolution note, alongside c for a plain note. Needs the
server release that adds /similar.

Claude-Session: https://claude.ai/code/session_01MMados3BD1oSjevHxbmVqU
2026-09-25 15:42:25 +02:00

587 lines
20 KiB
Go

package api
import (
"encoding/json"
"errors"
"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
cookie string
// authz is the Authorization header, which the client no longer sends at all.
authz 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.authz = string(body), r.Header.Get("Authorization")
if ck, err := r.Cookie(SessionCookie); err == nil {
got.cookie = ck.Value
}
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
io.WriteString(w, response)
}))
t.Cleanup(srv.Close)
c := NewClient(srv.URL)
c.SetSession("test-session")
return c, got
}
func TestClient_SendsTheSessionCookie(t *testing.T) {
c, got := stub(t, http.StatusOK, `[]`)
if _, err := c.ListIncidents(0, "", false, false, 0); err != nil {
t.Fatalf("list: %v", err)
}
if got.cookie != "test-session" {
t.Errorf("expected the session cookie, got %q", got.cookie)
}
// The server judges a request with an Authorization header on that alone and
// never falls back to the cookie, so sending one would defeat the session.
if got.authz != "" {
t.Errorf("expected no Authorization header, got %q", got.authz)
}
}
// Login has to work over plain http, where a cookie jar would discard the
// Secure cookie a server behind https sets.
func TestLogin_KeepsTheSessionFromTheCookie(t *testing.T) {
var body string
var sentCookie bool
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
b, _ := io.ReadAll(r.Body)
body = string(b)
_, err := r.Cookie(SessionCookie)
sentCookie = err == nil
http.SetCookie(w, &http.Cookie{Name: SessionCookie, Value: "fresh", Path: "/", HttpOnly: true, Secure: true})
w.WriteHeader(http.StatusOK)
io.WriteString(w, `{"user":{"id":1},"has_password":true}`)
}))
t.Cleanup(srv.Close)
c := NewClient(srv.URL)
c.SetSession("stale")
token, err := c.Login("niklas", "correct horse")
if err != nil {
t.Fatalf("login: %v", err)
}
if token != "fresh" || !c.HasSession() {
t.Errorf("expected the new token to be kept, got %q", token)
}
if body != `{"username":"niklas","password":"correct horse"}` {
t.Errorf("unexpected body %q", body)
}
if sentCookie {
t.Error("a stale session must not ride along on the login that replaces it")
}
}
func TestLogin_RefusalCarriesTheServersWords(t *testing.T) {
c, _ := stub(t, http.StatusUnauthorized, `{"error":"invalid username or password"}`)
_, err := c.Login("niklas", "wrong")
if !IsUnauthorized(err) || !strings.Contains(err.Error(), "invalid username or password") {
t.Errorf("expected the server's 401 message, got %v", err)
}
c, _ = stub(t, http.StatusTooManyRequests, `{"error":"too many attempts"}`)
if _, err := c.Login("niklas", "wrong"); err == nil || IsUnauthorized(err) {
t.Errorf("a rate limit is not an authentication failure, got %v", err)
}
}
func TestLogin_NoCookieIsAnError(t *testing.T) {
c, _ := stub(t, http.StatusOK, `{}`)
if _, err := c.Login("niklas", "pw"); err == nil {
t.Error("a 200 without a session cookie is not a sign-in")
}
}
func TestLogout_ForgetsTheSession(t *testing.T) {
c, got := stub(t, http.StatusNoContent, ``)
if err := c.Logout(); err != nil {
t.Fatalf("logout: %v", err)
}
if got.method != "POST" || got.path != "/api/logout" || got.cookie != "test-session" {
t.Errorf("unexpected request %s %s cookie=%q", got.method, got.path, got.cookie)
}
if c.HasSession() {
t.Error("the session should be gone locally")
}
}
func TestIsUnauthorized(t *testing.T) {
if !IsUnauthorized(&StatusError{Code: 401}) {
t.Error("a 401 is unauthorized")
}
if IsUnauthorized(&StatusError{Code: 403}) || IsUnauthorized(errors.New("x")) || IsUnauthorized(nil) {
t.Error("only a 401 means the session is refused; a 403 is a permission")
}
}
// 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", false); return err },
http.MethodPost, "/api/incidents/7/notes", ""},
{"similar", func(c *Client) error { _, err := c.GetSimilarIncidents(7); return err },
http.MethodGet, "/api/incidents/7/similar", `[]`},
{"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
teamID int64
status string
archived bool
snoozed bool
limit int
want string
}{
{"default is the open queue", 0, "", false, false, 0, ""},
{"status", 0, "triggered", false, false, 0, "status=triggered"},
{"archived", 0, "resolved", true, false, 0, "archived=true&status=resolved"},
{"snoozed", 0, "", false, true, 0, "snoozed=true"},
{"limit", 0, "", false, false, 500, "limit=500"},
{"one team", 4, "", false, false, 0, "team_id=4"},
{"no team means all of them", 0, "triggered", false, false, 0, "status=triggered"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
c, got := stub(t, http.StatusOK, `[]`)
if _, err := c.ListIncidents(tt.teamID, 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(9, 3, []string{"2026-07-27"}, false); err != nil {
t.Fatalf("assign: %v", err)
}
if got.method != "POST" || got.path != "/api/teams/9/schedule" {
t.Errorf("expected POST /api/teams/9/schedule, got %s %s", got.method, got.path)
}
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(9, 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: the server answers with an
// empty list, one entry per team that has somebody scheduled.
func TestGetCurrentOnCall_ListsOnePerTeam(t *testing.T) {
c, got := stub(t, http.StatusOK, `[
{"id":1,"team_id":1,"team_name":"Ops","user_id":5,"username":"alice","date":"2026-09-23"},
{"id":2,"team_id":2,"team_name":"Dev","user_id":6,"username":"bob","date":"2026-09-23"}]`)
entries, err := c.GetCurrentOnCall()
if err != nil {
t.Fatalf("on call: %v", err)
}
if got.path != "/api/schedule/current" {
t.Errorf("unexpected path %q", got.path)
}
if len(entries) != 2 || entries[0].TeamName != "Ops" || entries[1].Username != "bob" {
t.Errorf("unexpected entries %+v", entries)
}
c, _ = stub(t, http.StatusOK, `[]`)
if entries, err := c.GetCurrentOnCall(); err != nil || len(entries) != 0 {
t.Errorf("expected no entries and no error, got %v, %v", entries, err)
}
}
// Schedules belong to a team, so every call for one has to say which.
func TestSchedule_IsPerTeam(t *testing.T) {
c, got := stub(t, http.StatusOK, `[]`)
if _, err := c.GetSchedule(7, "2026-09-21", "2026-09-27"); err != nil {
t.Fatalf("get schedule: %v", err)
}
if got.path != "/api/teams/7/schedule" || got.query != "from=2026-09-21&to=2026-09-27" {
t.Errorf("unexpected request %s?%s", got.path, got.query)
}
c, got = stub(t, http.StatusNoContent, ``)
if err := c.DeleteScheduleEntry(7, 12); err != nil {
t.Fatalf("delete: %v", err)
}
if got.method != "DELETE" || got.path != "/api/teams/7/schedule/12" {
t.Errorf("unexpected request %s %s", got.method, got.path)
}
}
func TestTeams(t *testing.T) {
c, got := stub(t, http.StatusOK, `[{"id":3,"name":"Ops","created_at":"2026-09-20T10:00:00Z","role":"owner"}]`)
teams, err := c.ListTeams()
if err != nil {
t.Fatalf("list teams: %v", err)
}
if got.path != "/api/teams" || len(teams) != 1 || teams[0].Role != RoleOwner || teams[0].Name != "Ops" {
t.Errorf("unexpected %s %+v", got.path, teams)
}
c, got = stub(t, http.StatusOK, `[{"team_id":3,"user_id":5,"username":"alice","role":"member"}]`)
members, err := c.ListTeamMembers(3)
if err != nil {
t.Fatalf("list members: %v", err)
}
if got.path != "/api/teams/3/members" || len(members) != 1 || members[0].UserID != 5 {
t.Errorf("unexpected %s %+v", got.path, members)
}
}
// A server that predates teams has no /api/teams, and the TUI recognises one by
// that 404, so it must come back as a StatusError carrying the code.
func TestListTeams_OldServerIs404(t *testing.T) {
c, _ := stub(t, http.StatusNotFound, `{"error":"not found"}`)
_, err := c.ListTeams()
var se *StatusError
if !errors.As(err, &se) || se.Code != http.StatusNotFound {
t.Errorf("expected a 404 StatusError, got %v", err)
}
}
func TestUser_DecodesAdminAndDisabled(t *testing.T) {
var u User
if err := json.Unmarshal([]byte(
`{"id":1,"username":"a","is_admin":true,"disabled_at":"2026-09-22T08:00:00Z"}`), &u); err != nil {
t.Fatalf("decode: %v", err)
}
if !u.IsAdmin || !u.IsDisabled() {
t.Errorf("expected an admin who is disabled, got %+v", u)
}
var other User
if err := json.Unmarshal([]byte(`{"id":2,"username":"b","is_admin":false}`), &other); err != nil || other.IsDisabled() {
t.Errorf("a user with no disabled_at must not be disabled")
}
}
// 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(0, "", 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)
}
}
func TestClient_Me(t *testing.T) {
c, got := stub(t, http.StatusOK, `{"user":{"id":3,"username":"erik"},"has_password":true}`)
me, err := c.Me()
if err != nil {
t.Fatalf("me: %v", err)
}
if got.method != http.MethodGet || got.path != "/api/me" {
t.Errorf("expected GET /api/me, got %s %s", got.method, got.path)
}
if me.User.ID != 3 || !me.HasPassword {
t.Errorf("unexpected decode %+v", me)
}
}
func TestClient_SetPassword(t *testing.T) {
c, got := stub(t, http.StatusNoContent, ``)
if err := c.SetPassword(2, "a brand new secret", ""); err != nil {
t.Fatalf("set password: %v", err)
}
if got.method != http.MethodPut || got.path != "/api/users/2/password" {
t.Errorf("expected PUT /api/users/2/password, got %s %s", got.method, got.path)
}
// Setting someone else's password carries no current_password at all,
// rather than an empty one.
if got.body != `{"password":"a brand new secret"}` {
t.Errorf("unexpected body %s", got.body)
}
c, got = stub(t, http.StatusNoContent, ``)
c.SetPassword(1, "a brand new secret", "the old one")
if !strings.Contains(got.body, `"current_password":"the old one"`) {
t.Errorf("current password missing from %s", got.body)
}
}
// Older servers have no /api/me; the caller tells that apart by the status
// code, so the typed error has to carry it.
func TestClient_StatusErrorKeepsCodeAndMessage(t *testing.T) {
c, _ := stub(t, http.StatusNotFound, `404 page not found`)
_, err := c.Me()
var se *StatusError
if !errors.As(err, &se) || se.Code != http.StatusNotFound {
t.Fatalf("expected a 404 StatusError, got %v", err)
}
if err.Error() != "server returned 404" {
t.Errorf("message changed: %q", err.Error())
}
}