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.
This commit is contained in:
Niklas Ye
2026-07-31 07:23:43 +02:00
parent 9582543c1d
commit 8482315651
3 changed files with 1011 additions and 0 deletions
+240
View File
@@ -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")
}
}