057302cb39
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
587 lines
20 KiB
Go
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())
|
|
}
|
|
}
|