From 8482315651e2f2fe25d34edfa78e45c11e54cf5f Mon Sep 17 00:00:00 2001 From: Niklas Ye Date: Fri, 31 Jul 2026 07:23:43 +0200 Subject: [PATCH] test: cover the API client and the update loop 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. --- internal/api/client_test.go | 304 +++++++++++++++++++++++ internal/tui/model_test.go | 240 ++++++++++++++++++ internal/tui/update_test.go | 467 ++++++++++++++++++++++++++++++++++++ 3 files changed, 1011 insertions(+) create mode 100644 internal/api/client_test.go create mode 100644 internal/tui/model_test.go create mode 100644 internal/tui/update_test.go diff --git a/internal/api/client_test.go b/internal/api/client_test.go new file mode 100644 index 0000000..54e0dbb --- /dev/null +++ b/internal/api/client_test.go @@ -0,0 +1,304 @@ +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) + } +} diff --git a/internal/tui/model_test.go b/internal/tui/model_test.go new file mode 100644 index 0000000..72df7a8 --- /dev/null +++ b/internal/tui/model_test.go @@ -0,0 +1,240 @@ +package tui + +import ( + "testing" + "time" + + "github.com/charmbracelet/bubbles/table" + "github.com/yeniklas/terdut-tui/internal/api" +) + +func TestNextFilter(t *testing.T) { + tests := []struct { + name string + cycle []string + current string + want string + }{ + {"advances", incidentFilters, "", api.StatusTriggered}, + {"advances again", incidentFilters, api.StatusTriggered, api.StatusAcknowledged}, + {"wraps back to the open queue", incidentFilters, "snoozed", ""}, + {"alerts advance", alertFilters, "firing", "resolved"}, + {"alerts wrap", alertFilters, "archived", "firing"}, + {"unknown current restarts the cycle", incidentFilters, "bogus", ""}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := nextFilter(tt.cycle, tt.current); got != tt.want { + t.Errorf("expected %q, got %q", tt.want, got) + } + }) + } +} + +// "snoozed" is a pseudo-status in the filter cycle: the server has no such +// status, it is a separate query axis. +func TestIncidentQuery(t *testing.T) { + tests := []struct { + filter string + wantStatus string + wantSnoozed bool + }{ + {"", "", false}, + {api.StatusTriggered, api.StatusTriggered, false}, + {api.StatusResolved, api.StatusResolved, false}, + {"snoozed", "", true}, + } + for _, tt := range tests { + t.Run(tt.filter, func(t *testing.T) { + status, snoozed := incidentQuery(tt.filter) + if status != tt.wantStatus || snoozed != tt.wantSnoozed { + t.Errorf("expected (%q, %v), got (%q, %v)", + tt.wantStatus, tt.wantSnoozed, status, snoozed) + } + }) + } +} + +func TestFilterLabel(t *testing.T) { + if got := filterLabel(""); got != "open" { + t.Errorf("the empty filter is the open queue, got %q", got) + } + if got := filterLabel("resolved"); got != "resolved" { + t.Errorf("expected resolved, got %q", got) + } +} + +func TestNoteEvents(t *testing.T) { + timeline := []api.IncidentEvent{ + {Type: api.EventTriggered}, + {Type: api.EventNote, Detail: "first"}, + {Type: api.EventAcknowledged}, + {Type: api.EventNote, Detail: "second"}, + } + notes := noteEvents(timeline) + if len(notes) != 2 { + t.Fatalf("expected 2 notes, got %d", len(notes)) + } + if notes[0].Detail != "first" || notes[1].Detail != "second" { + t.Errorf("notes out of order: %v", notes) + } + if len(noteEvents(nil)) != 0 { + t.Error("an empty timeline has no notes") + } +} + +func TestHumanDuration(t *testing.T) { + tests := []struct { + d time.Duration + want string + }{ + {5 * time.Second, "moments"}, + {90 * time.Second, "1m"}, + {45 * time.Minute, "45m"}, + {2 * time.Hour, "2h"}, + {150 * time.Minute, "2h 30m"}, + {48 * time.Hour, "2d"}, + {50 * time.Hour, "2d 2h"}, + } + for _, tt := range tests { + if got := humanDuration(tt.d); got != tt.want { + t.Errorf("humanDuration(%v) = %q, want %q", tt.d, got, tt.want) + } + } +} + +func TestHumanAgo_ClampsFutureToNow(t *testing.T) { + now := time.Now() + // Server and client clocks disagree often enough that this must not render + // as a negative age. + if got := humanAgo(now, now.Add(time.Hour)); got != "moments ago" { + t.Errorf("expected a future timestamp to clamp, got %q", got) + } +} + +func TestHumanUntil(t *testing.T) { + now := time.Now() + if got := humanUntil(now, now.Add(2*time.Hour)); got != "in 2h" { + t.Errorf("expected 'in 2h', got %q", got) + } + if got := humanUntil(now, now.Add(-time.Minute)); got != "expired" { + t.Errorf("a deadline in the past has expired, got %q", got) + } +} + +// MTTA and MTTR are nil until something has been acknowledged or resolved, and +// that has to read as "no data" rather than an instant response. +func TestHumanSeconds(t *testing.T) { + if got := humanSeconds(nil); got != "—" { + t.Errorf("expected an em dash for no data, got %q", got) + } + secs := 150.0 + if got := humanSeconds(&secs); got != "2m" { + t.Errorf("expected 2m, got %q", got) + } +} + +func TestIncidentRows(t *testing.T) { + future := time.Now().Add(time.Hour) + rows := incidentRows([]api.Incident{ + {Title: "DiskFull", Status: api.StatusTriggered, Severity: "critical", + AssignedTo: "admin", TriggeredAt: time.Now()}, + {Title: "Unowned", Status: api.StatusTriggered, TriggeredAt: time.Now()}, + {Title: "Quiet", Status: api.StatusTriggered, Severity: "info", + AssignedTo: "alice", SnoozedUntil: &future, TriggeredAt: time.Now()}, + }) + if len(rows) != 3 { + t.Fatalf("expected 3 rows, got %d", len(rows)) + } + if rows[0][0] != "critical" || rows[0][3] != "admin" { + t.Errorf("unexpected first row %v", rows[0]) + } + if rows[1][0] != "—" || rows[1][3] != "—" { + t.Errorf("missing severity and assignee should show an em dash, got %v", rows[1]) + } + // bubbles' table renders plain strings, so snooze has to be marked in text. + if rows[2][2] != "triggered (zzz)" { + t.Errorf("expected a snooze marker in the status cell, got %q", rows[2][2]) + } +} + +func TestAlertRows_ShowIncidentLink(t *testing.T) { + id := int64(7) + rows := alertRows([]api.Alert{ + {Name: "DiskFull", Status: "firing", IncidentID: &id}, + {Name: "Orphan", Status: "resolved"}, + }) + if rows[0][4] != "#7" { + t.Errorf("expected #7, got %q", rows[0][4]) + } + if rows[1][4] != "—" { + t.Errorf("an alert with no incident shows an em dash, got %q", rows[1][4]) + } +} + +// A previous release overflowed the terminal by two columns because the padding +// budget was wrong. Columns plus bubbles' per-cell padding must land exactly on +// the window width. +func TestColumnWidthsFitTheTerminal(t *testing.T) { + for _, width := range []int{100, 110, 140, 200} { + for name, cols := range map[string][]int{ + "incident": widths(incidentColumns(width)), + "alert": widths(alertColumns(width)), + } { + sum := 0 + for _, w := range cols { + sum += w + } + const padding = 10 // bubbles applies Padding(0, 1) to each of five cells + if sum+padding != width { + t.Errorf("%s columns at width %d sum to %d+%d = %d", + name, width, sum, padding, sum+padding) + } + } + } +} + +// Narrow terminals fall back to minimum widths, which legitimately overflow; +// what must not happen is a negative or zero column. +func TestColumnWidthsStayPositiveWhenNarrow(t *testing.T) { + for _, width := range []int{20, 40, 60} { + for _, w := range append(widths(incidentColumns(width)), widths(alertColumns(width))...) { + if w < 1 { + t.Errorf("width %d produced a non-positive column %d", width, w) + } + } + } +} + +func widths(cols []table.Column) []int { + out := make([]int, len(cols)) + for i, c := range cols { + out[i] = c.Width + } + return out +} + +func TestTableHeight_NeverGoesBelowOne(t *testing.T) { + if got := tableHeight(3, 10); got != 1 { + t.Errorf("expected a floor of 1, got %d", got) + } + if got := tableHeight(40, 8); got != 32 { + t.Errorf("expected 32, got %d", got) + } +} + +func TestBuildScheduleDays(t *testing.T) { + monday := time.Date(2026, 7, 27, 0, 0, 0, 0, time.UTC) + days := buildScheduleDays(monday, []api.ScheduleEntry{ + {Date: "2026-07-29", Username: "alice"}, + }) + if len(days) != 7 { + t.Fatalf("expected a 7-day window, got %d", len(days)) + } + if days[2].entry == nil || days[2].entry.Username != "alice" { + t.Errorf("expected alice on the third day, got %+v", days[2].entry) + } + if days[0].entry != nil { + t.Error("expected unassigned days to have no entry") + } +} diff --git a/internal/tui/update_test.go b/internal/tui/update_test.go new file mode 100644 index 0000000..646a64c --- /dev/null +++ b/internal/tui/update_test.go @@ -0,0 +1,467 @@ +package tui + +import ( + "strings" + "testing" + "time" + + tea "github.com/charmbracelet/bubbletea" + "github.com/yeniklas/terdut-tui/internal/api" +) + +// press sends one key and returns the resulting model and command. A nil command +// means the model decided to do nothing, which is what most of these tests are +// really asserting. +func press(t *testing.T, m Model, key string) (Model, tea.Cmd) { + t.Helper() + var msg tea.KeyMsg + switch key { + case "esc": + msg = tea.KeyMsg{Type: tea.KeyEsc} + case "enter": + msg = tea.KeyMsg{Type: tea.KeyEnter} + case "tab": + msg = tea.KeyMsg{Type: tea.KeyTab} + default: + msg = tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune(key)} + } + next, cmd := m.Update(msg) + return next.(Model), cmd +} + +// sized returns a connected model with a usable window, which most handlers need. +func sized() Model { + m := NewModel(nil, "http://test", time.Minute) + m.width, m.height = 120, 40 + m.connected = true + return m +} + +// onIncident opens the incident detail view directly, skipping the fetch. +func onIncident(inc api.Incident, timeline []api.IncidentEvent) Model { + m := sized() + m.mode = modeIncidentDetail + m.selectedIncident = inc + m.timeline = timeline + m.noteCursor = -1 + return m +} + +func openIncidentFixture() api.Incident { + return api.Incident{ID: 1, Title: "DiskFull", Status: api.StatusTriggered, + Severity: "critical", TriggeredAt: time.Now()} +} + +func resolvedIncidentFixture() api.Incident { + now := time.Now() + source := "manual" + inc := openIncidentFixture() + inc.Status = api.StatusResolved + inc.ResolvedAt = &now + inc.ResolutionSource = &source + return inc +} + +// Resolving is terminal on the server: a later occurrence opens a new incident +// rather than reopening this one. A stray keypress must not be able to do that. +func TestResolve_AsksBeforeDoingIt(t *testing.T) { + m := onIncident(openIncidentFixture(), nil) + + m, cmd := press(t, m, "R") + if m.mode != modeConfirm { + t.Fatalf("expected a confirmation prompt, got mode %v", m.mode) + } + if m.confirmTarget != confirmResolveIncident { + t.Errorf("expected the resolve target, got %v", m.confirmTarget) + } + if cmd != nil { + t.Error("nothing should be sent to the server before confirming") + } + if !containsAll(m.confirmPrompt(), "final", "new incident") { + t.Errorf("the prompt should say resolving is final, got %q", m.confirmPrompt()) + } +} + +func TestResolve_CancelReturnsToDetailWithoutActing(t *testing.T) { + m := onIncident(openIncidentFixture(), nil) + m, _ = press(t, m, "R") + + m, cmd := press(t, m, "n") + if m.mode != modeIncidentDetail { + t.Errorf("expected to land back on the incident, got mode %v", m.mode) + } + if cmd != nil { + t.Error("cancelling must not act") + } +} + +func TestResolve_ConfirmActs(t *testing.T) { + m := onIncident(openIncidentFixture(), nil) + m, _ = press(t, m, "R") + + m, cmd := press(t, m, "y") + if cmd == nil { + t.Error("confirming should issue the resolve") + } + if m.mode != modeIncidentDetail { + t.Errorf("expected to return to the incident, got mode %v", m.mode) + } +} + +// The server answers 409 on all of these; saying so up front beats a round trip. +func TestResolvedIncident_RejectsWorkflowActions(t *testing.T) { + for _, key := range []string{"a", "A", "R", "s", "z", "Z"} { + t.Run(key, func(t *testing.T) { + m := onIncident(resolvedIncidentFixture(), nil) + m, cmd := press(t, m, key) + if cmd == nil { + t.Error("expected a status message command") + } + if m.statusMsg == "" { + t.Error("expected an explanation in the status line") + } + if m.mode != modeIncidentDetail { + t.Errorf("expected to stay on the incident, got mode %v", m.mode) + } + }) + } +} + +func TestOpenIncident_AcknowledgeTwiceIsRejected(t *testing.T) { + inc := openIncidentFixture() + id := int64(2) + at := time.Now() + inc.Status = api.StatusAcknowledged + inc.AcknowledgedByID = &id + inc.AcknowledgedBy = "alice" + inc.AcknowledgedAt = &at + + m, _ := press(t, onIncident(inc, nil), "a") + if !containsAll(m.statusMsg, "already acknowledged", "alice") { + t.Errorf("expected to be told who holds it, got %q", m.statusMsg) + } +} + +func TestOpenIncident_UnacknowledgeRequiresAnAcknowledgement(t *testing.T) { + m, _ := press(t, onIncident(openIncidentFixture(), nil), "A") + if m.statusMsg != "not acknowledged" { + t.Errorf("expected 'not acknowledged', got %q", m.statusMsg) + } +} + +func TestOpenIncident_UnsnoozeRequiresASnooze(t *testing.T) { + m, _ := press(t, onIncident(openIncidentFixture(), nil), "Z") + if m.statusMsg != "not snoozed" { + t.Errorf("expected 'not snoozed', got %q", m.statusMsg) + } +} + +// Archiving unresolved work only hides it, so the client refuses rather than +// letting the queue be cleared by pressing x. +func TestArchive_RefusesOpenIncident(t *testing.T) { + t.Run("from the detail view", func(t *testing.T) { + m, cmd := press(t, onIncident(openIncidentFixture(), nil), "x") + if cmd == nil || m.statusMsg == "" { + t.Error("expected a refusal message") + } + if m.mode != modeIncidentDetail { + t.Errorf("expected to stay put, got mode %v", m.mode) + } + }) + + t.Run("from the queue", func(t *testing.T) { + m := sized() + m.incidents = []api.Incident{openIncidentFixture()} + m.rebuildIncidentTable() + + m, _ = press(t, m, "x") + if m.statusMsg == "" { + t.Error("expected a refusal message") + } + }) +} + +func TestArchive_AllowedOnResolvedIncident(t *testing.T) { + m := sized() + m.incidents = []api.Incident{resolvedIncidentFixture()} + m.rebuildIncidentTable() + + m, cmd := press(t, m, "x") + if cmd == nil { + t.Error("archiving a resolved incident should act") + } + if m.statusMsg != "" { + t.Errorf("expected no refusal, got %q", m.statusMsg) + } +} + +func TestSnooze_PromptThenSubmit(t *testing.T) { + m := onIncident(openIncidentFixture(), nil) + + m, _ = press(t, m, "z") + if m.mode != modeSnooze { + t.Fatalf("expected the snooze prompt, got mode %v", m.mode) + } + + // Typing goes to the input, not the key handler. + for _, r := range "2h" { + m, _ = press(t, m, string(r)) + } + if m.snoozeInput.Value() != "2h" { + t.Fatalf("expected the typed duration, got %q", m.snoozeInput.Value()) + } + + m, cmd := press(t, m, "enter") + if cmd == nil { + t.Error("expected the snooze to be sent") + } + if m.mode != modeIncidentDetail { + t.Errorf("expected to return to the incident, got mode %v", m.mode) + } +} + +func TestSnooze_EmptyInputDoesNothing(t *testing.T) { + m := onIncident(openIncidentFixture(), nil) + m, _ = press(t, m, "z") + + m, cmd := press(t, m, "enter") + if cmd != nil { + t.Error("an empty duration should not be sent") + } + if m.mode != modeSnooze { + t.Errorf("expected to stay on the prompt, got mode %v", m.mode) + } +} + +func TestNote_EscapeAbandonsWithoutPosting(t *testing.T) { + m := onIncident(openIncidentFixture(), nil) + m, _ = press(t, m, "c") + if m.mode != modeNote { + t.Fatalf("expected the note prompt, got mode %v", m.mode) + } + + m, cmd := press(t, m, "esc") + if cmd != nil { + t.Error("escaping must not post the note") + } + if m.mode != modeIncidentDetail { + t.Errorf("expected to return to the incident, got mode %v", m.mode) + } +} + +func TestNoteCursor_WrapsOverNotesOnly(t *testing.T) { + timeline := []api.IncidentEvent{ + {ID: 1, Type: api.EventTriggered}, + {ID: 2, Type: api.EventNote, Detail: "first"}, + {ID: 3, Type: api.EventAcknowledged}, + {ID: 4, Type: api.EventNote, Detail: "second"}, + } + m := onIncident(openIncidentFixture(), timeline) + + m, _ = press(t, m, "]") + if m.noteCursor != 0 { + t.Fatalf("expected the first note, got %d", m.noteCursor) + } + m, _ = press(t, m, "]") + if m.noteCursor != 1 { + t.Fatalf("expected the second note, got %d", m.noteCursor) + } + m, _ = press(t, m, "]") + if m.noteCursor != 0 { + t.Errorf("expected to wrap to the first note, got %d", m.noteCursor) + } + m, _ = press(t, m, "[") + if m.noteCursor != 1 { + t.Errorf("expected to wrap backwards to the last note, got %d", m.noteCursor) + } +} + +func TestDeleteNote_RequiresASelection(t *testing.T) { + m := onIncident(openIncidentFixture(), []api.IncidentEvent{{Type: api.EventTriggered}}) + m, _ = press(t, m, "d") + if m.mode == modeConfirm { + t.Error("nothing is selected, so there is nothing to confirm") + } + if !containsAll(m.statusMsg, "select a note") { + t.Errorf("expected guidance, got %q", m.statusMsg) + } +} + +func TestDeleteNote_ConfirmsThenActs(t *testing.T) { + timeline := []api.IncidentEvent{{ID: 9, Type: api.EventNote, Detail: "hi"}} + m := onIncident(openIncidentFixture(), timeline) + + m, _ = press(t, m, "]") + m, _ = press(t, m, "d") + if m.mode != modeConfirm || m.confirmTarget != confirmDeleteNote { + t.Fatalf("expected a delete confirmation, got mode %v target %v", m.mode, m.confirmTarget) + } + if m.pendingDeleteID != 9 { + t.Errorf("expected the selected note's id, got %d", m.pendingDeleteID) + } + + m, cmd := press(t, m, "y") + if cmd == nil { + t.Error("confirming should issue the delete") + } + if m.mode != modeIncidentDetail { + t.Errorf("expected to return to the incident, got mode %v", m.mode) + } +} + +func TestTab_CyclesEverySection(t *testing.T) { + m := sized() + if m.activeSection != sectionIncidents { + t.Fatal("incidents is the section the client opens on") + } + + want := []section{sectionAlerts, sectionArchived, sectionSchedule, sectionUsers, sectionIncidents} + for i, expected := range want { + m, _ = press(t, m, "tab") + if m.activeSection != expected { + t.Fatalf("after %d tabs expected section %v, got %v", i+1, expected, m.activeSection) + } + } +} + +func TestFilter_CyclesPerSection(t *testing.T) { + m := sized() + m, _ = press(t, m, "f") + if m.incidentFilter != api.StatusTriggered { + t.Errorf("expected the incident filter to advance, got %q", m.incidentFilter) + } + + m.activeSection = sectionAlerts + m, _ = press(t, m, "f") + if m.alertFilter != "resolved" { + t.Errorf("expected the alert filter to advance, got %q", m.alertFilter) + } + if m.incidentFilter != api.StatusTriggered { + t.Error("the two filters are independent") + } +} + +// Stats opens from both the queue and an incident, and esc has to go back to +// wherever it was opened from. +func TestStats_ReturnsWhereItWasOpenedFrom(t *testing.T) { + t.Run("from the queue", func(t *testing.T) { + m, _ := press(t, sized(), "S") + if m.mode != modeStats { + t.Fatalf("expected stats, got mode %v", m.mode) + } + m, _ = press(t, m, "esc") + if m.mode != modeDashboard { + t.Errorf("expected the dashboard, got mode %v", m.mode) + } + }) + + t.Run("from an incident", func(t *testing.T) { + m, _ := press(t, onIncident(openIncidentFixture(), nil), "S") + if m.mode != modeStats { + t.Fatalf("expected stats, got mode %v", m.mode) + } + m, _ = press(t, m, "esc") + if m.mode != modeIncidentDetail { + t.Errorf("expected the incident, got mode %v", m.mode) + } + }) +} + +// Alerts carry no workflow state, so the detail view offers nothing but a way +// through to the incident. +func TestAlertDetail_IsReadOnly(t *testing.T) { + m := sized() + m.mode = modeAlertDetail + m.selectedAlert = api.Alert{ID: 3, Name: "DiskFull", Status: "firing"} + + for _, key := range []string{"a", "A", "R", "c", "x", "z"} { + next, cmd := press(t, m, key) + if cmd != nil { + t.Errorf("key %q should do nothing on an alert", key) + } + if next.mode != modeAlertDetail { + t.Errorf("key %q changed mode to %v", key, next.mode) + } + } +} + +func TestAlertDetail_JumpToIncident(t *testing.T) { + m := sized() + m.mode = modeAlertDetail + + t.Run("without an incident", func(t *testing.T) { + m.selectedAlert = api.Alert{ID: 3, Name: "Orphan"} + next, _ := press(t, m, "i") + if next.mode != modeAlertDetail || next.statusMsg == "" { + t.Errorf("expected a refusal, got mode %v msg %q", next.mode, next.statusMsg) + } + }) + + t.Run("with an incident", func(t *testing.T) { + id := int64(7) + m.selectedAlert = api.Alert{ID: 3, Name: "DiskFull", IncidentID: &id} + next, cmd := press(t, m, "i") + if next.mode != modeIncidentDetail { + t.Fatalf("expected the incident view, got mode %v", next.mode) + } + if next.selectedIncident.ID != 7 { + t.Errorf("expected incident 7, got %d", next.selectedIncident.ID) + } + if cmd == nil { + t.Error("expected the incident to be fetched") + } + }) +} + +// A refresh underneath a prompt would move the ground under the user. +func TestRefreshTick_SkipsModalStates(t *testing.T) { + modal := []mode{modeNote, modeSnooze, modeConfirm, modeUserPicker, modeUserCreate} + for _, md := range modal { + m := sized() + m.mode = md + if cmd := m.refreshActiveSection(); cmd != nil { + t.Errorf("mode %v should not auto-refresh", md) + } + } + + m := sized() + if cmd := m.refreshActiveSection(); cmd == nil { + t.Error("the dashboard should auto-refresh") + } +} + +func TestIncidentsFetched_ClearsLoading(t *testing.T) { + m := sized() + m.loading = true + next, _ := m.Update(incidentsFetchedMsg{incidents: []api.Incident{openIncidentFixture()}}) + got := next.(Model) + if got.loading { + t.Error("expected loading to clear") + } + if len(got.incidents) != 1 { + t.Errorf("expected the incidents stored, got %d", len(got.incidents)) + } +} + +// A note deleted elsewhere must not leave the cursor pointing past the end. +func TestIncidentDetailFetched_ClampsNoteCursor(t *testing.T) { + m := onIncident(openIncidentFixture(), nil) + m.noteCursor = 3 + + next, _ := m.Update(incidentDetailFetchedMsg{ + incident: openIncidentFixture(), + timeline: []api.IncidentEvent{{Type: api.EventTriggered}}, + }) + if got := next.(Model).noteCursor; got != -1 { + t.Errorf("expected the cursor reset, got %d", got) + } +} + +func containsAll(s string, subs ...string) bool { + for _, sub := range subs { + if !strings.Contains(s, sub) { + return false + } + } + return true +}