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 +}