27008086b0
terdut-server v0.12 made everything team-scoped and v0.20 is what this
client now targets. Against it the old client was wrong in three ways:
the schedule moved to /api/teams/{id}/schedule, GET /api/schedule/current
became a list with one entry per team, and users, incidents, alerts and
schedule entries all grew fields the client ignored.
T steps through all teams and then each of yours. The header names what
is showing, and incident and alert rows gain a Team column when more than
one team can appear. team: in config.yaml picks the team to start on, by
name or id; an unknown one is reported and falls back to all teams.
The schedule is one team's rota, so it shows the active team, or with
all teams showing the first one you own. Writes need an owner or an
administrator, and the picker offers only the team's members, since the
server answers 404 for anybody else. Both are checked up front and the
reason goes in the status bar, rather than surfacing as a 403 after the
user has picked somebody. Stats are not team-scoped by the server and
stay that way here.
Users shows an admin/disabled Flags column. Creating and deleting users
is administrators only, and topic, keys and password work on your own
row or on anyone's for an administrator; the server enforces the same
rule, this only explains it before the round trip.
The server has no version endpoint, so an older one is recognised by
GET /api/teams answering 404, and the TUI says it needs v0.20 or later.
Connecting now also loads /api/teams and /api/me with the key, which
means a wrong key fails on start instead of on the first list; /healthz
does not check it. There is no fallback to the pre-team paths.
Rebuilding a table whose column count changes under loaded rows panicked
inside bubbles, because it re-renders the old rows on SetColumns. The
rows are now cleared first and the cursor put back, so a refresh still
does not jump to the top.
Escalation ladders, invites, integrations and the admin settings are
left to the server's web UI. Checked against a real v0.20.1 server with
two teams, an administrator and a plain member.
Breaking: requires terdut-server v0.20.0 or later. Use terdut-tui v0.9.x
with servers before v0.12.
976 lines
30 KiB
Go
976 lines
30 KiB
Go
package tui
|
|
|
|
import (
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
"git.ryuvia.com/niklas/terdut-tui/internal/api"
|
|
"git.ryuvia.com/niklas/terdut-tui/internal/theme"
|
|
tea "github.com/charmbracelet/bubbletea"
|
|
)
|
|
|
|
// 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, theme.GruvboxDark)
|
|
m.width, m.height = 120, 40
|
|
m.connected = true
|
|
m.isAdmin = true // most handlers are being tested for what they do, not who may
|
|
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)
|
|
}
|
|
}
|
|
|
|
// ── Schedule reassignment ─────────────────────────────────────────────────
|
|
|
|
// pickingOnCall opens the user picker for the schedule day at dayIndex, which
|
|
// is where a reassignment actually starts.
|
|
func pickingOnCall(entries []api.ScheduleEntry, dayIndex int, week bool) Model {
|
|
m := scheduledWeek(entries)
|
|
m.users = []api.User{
|
|
{ID: 1, Username: "niklas", Email: "n@example.com"},
|
|
{ID: 2, Username: "alex", Email: "a@example.com"},
|
|
}
|
|
m.pickerTarget = pickerSchedule
|
|
m.pickerMembers = map[int64]bool{1: true, 2: true}
|
|
m.rebuildUserPickerTable()
|
|
m.scheduleTable.SetCursor(dayIndex)
|
|
m.pickerAssignWeek = week
|
|
m.pickerTarget = pickerSchedule
|
|
m.mode = modeUserPicker
|
|
m.userPickerTable.SetCursor(1) // alex
|
|
return m
|
|
}
|
|
|
|
// The bug: a day somebody already holds could not be handed to anybody else.
|
|
// The server refuses it, so the TUI has to ask first and then say so.
|
|
func TestSchedule_ReassigningATakenDayAsksFirst(t *testing.T) {
|
|
m := pickingOnCall([]api.ScheduleEntry{
|
|
{ID: 1, Date: "2026-07-27", UserID: 1, Username: "niklas"},
|
|
}, 0, false)
|
|
|
|
m, cmd := press(t, m, "enter")
|
|
|
|
if m.mode != modeConfirm || m.confirmTarget != confirmReassignSchedule {
|
|
t.Fatalf("expected a reassignment confirmation, got mode %v target %v",
|
|
m.mode, m.confirmTarget)
|
|
}
|
|
if cmd != nil {
|
|
t.Error("expected nothing sent to the server before confirming")
|
|
}
|
|
mustContain(t, m.confirmPrompt(), "This day is assigned to niklas", "Reassign to alex?")
|
|
}
|
|
|
|
func TestSchedule_ReassignConfirmedSends(t *testing.T) {
|
|
m := pickingOnCall([]api.ScheduleEntry{
|
|
{ID: 1, Date: "2026-07-27", UserID: 1, Username: "niklas"},
|
|
}, 0, false)
|
|
m, _ = press(t, m, "enter")
|
|
|
|
m, cmd := press(t, m, "y")
|
|
if cmd == nil {
|
|
t.Fatal("expected the confirmed reassignment to be sent")
|
|
}
|
|
if m.mode != modeDashboard {
|
|
t.Errorf("expected a return to the dashboard, got mode %v", m.mode)
|
|
}
|
|
if m.pendingAssign != nil {
|
|
t.Error("expected the pending assignment cleared")
|
|
}
|
|
}
|
|
|
|
// Declining must leave the rota alone — that is the whole point of the guard.
|
|
func TestSchedule_ReassignDeclinedSendsNothing(t *testing.T) {
|
|
m := pickingOnCall([]api.ScheduleEntry{
|
|
{ID: 1, Date: "2026-07-27", UserID: 1, Username: "niklas"},
|
|
}, 0, false)
|
|
m, _ = press(t, m, "enter")
|
|
|
|
m, cmd := press(t, m, "n")
|
|
if cmd != nil {
|
|
t.Error("expected nothing sent when the reassignment is declined")
|
|
}
|
|
if m.pendingAssign != nil {
|
|
t.Error("expected the pending assignment discarded")
|
|
}
|
|
}
|
|
|
|
// A free day is the path that always worked, and must not grow a prompt.
|
|
func TestSchedule_AssigningAFreeDayDoesNotAsk(t *testing.T) {
|
|
m := pickingOnCall(nil, 0, false)
|
|
|
|
m, cmd := press(t, m, "enter")
|
|
if m.mode != modeDashboard {
|
|
t.Errorf("expected no prompt for a free day, got mode %v", m.mode)
|
|
}
|
|
if cmd == nil {
|
|
t.Error("expected the assignment to be sent straight away")
|
|
}
|
|
}
|
|
|
|
// The week case is the one that was worst: a single taken day rejected all
|
|
// seven. One prompt now covers the lot, and it says how much is being taken.
|
|
func TestSchedule_ReassigningAPartlyTakenWeekAsksOnce(t *testing.T) {
|
|
m := pickingOnCall([]api.ScheduleEntry{
|
|
{ID: 1, Date: "2026-07-28", UserID: 1, Username: "niklas"},
|
|
{ID: 2, Date: "2026-07-30", UserID: 3, Username: "sam"},
|
|
}, 0, true)
|
|
|
|
m, _ = press(t, m, "enter")
|
|
if m.confirmTarget != confirmReassignSchedule {
|
|
t.Fatalf("expected one confirmation for the week, got target %v", m.confirmTarget)
|
|
}
|
|
if got := len(m.pendingAssign.dates); got != 7 {
|
|
t.Errorf("expected all 7 days in the assignment, got %d", got)
|
|
}
|
|
mustContain(t, m.confirmPrompt(), "2 of 7 days are assigned to niklas and sam")
|
|
}
|
|
|
|
// ── Ntfy topic ────────────────────────────────────────────────────────────
|
|
|
|
// onUsers puts the model in the Users section with a loaded table.
|
|
func onUsers(users []api.User) Model {
|
|
m := sized()
|
|
m.activeSection = sectionUsers
|
|
m.users = users
|
|
m.rebuildUserManageTable()
|
|
return m
|
|
}
|
|
|
|
func userFixtures() []api.User {
|
|
topic := "terdut-niklas"
|
|
return []api.User{
|
|
{ID: 1, Username: "niklas", Email: "niklas@example.com", NtfyTopic: &topic},
|
|
{ID: 2, Username: "alex", Email: "alex@example.com"},
|
|
}
|
|
}
|
|
|
|
func TestNotifyTopic_EditPrefillsTheCurrentTopic(t *testing.T) {
|
|
m, _ := press(t, onUsers(userFixtures()), "t")
|
|
|
|
if m.mode != modeUserNotifyEdit {
|
|
t.Fatalf("expected the topic editor, got mode %v", m.mode)
|
|
}
|
|
if m.selectedUser.ID != 1 {
|
|
t.Errorf("expected the user under the cursor, got %d", m.selectedUser.ID)
|
|
}
|
|
// Prefilled, so editing a topic does not mean retyping it from scratch.
|
|
if got := m.ntfyTopicInput.Value(); got != "terdut-niklas" {
|
|
t.Errorf("expected the current topic prefilled, got %q", got)
|
|
}
|
|
}
|
|
|
|
// A user with no topic opens an empty field rather than the previous user's.
|
|
func TestNotifyTopic_EditStartsEmptyWhenUnset(t *testing.T) {
|
|
m := onUsers(userFixtures())
|
|
m, _ = press(t, m, "t")
|
|
m, _ = press(t, m, "esc")
|
|
m.userManageTable.SetCursor(1)
|
|
|
|
m, _ = press(t, m, "t")
|
|
if got := m.ntfyTopicInput.Value(); got != "" {
|
|
t.Errorf("expected an empty field for a user with no topic, got %q", got)
|
|
}
|
|
}
|
|
|
|
func TestNotifyTopic_EscapeAbandonsWithoutSaving(t *testing.T) {
|
|
m, _ := press(t, onUsers(userFixtures()), "t")
|
|
m, cmd := press(t, m, "esc")
|
|
|
|
if cmd != nil {
|
|
t.Error("expected escape to save nothing")
|
|
}
|
|
if m.mode != modeDashboard {
|
|
t.Errorf("expected a return to the dashboard, got mode %v", m.mode)
|
|
}
|
|
}
|
|
|
|
// Clearing a topic is a real action, not a no-op: it is how a user is taken off
|
|
// their own topic and back onto the shared fallback. Contrast the snooze prompt,
|
|
// where an empty value means "I changed my mind".
|
|
func TestNotifyTopic_EmptyInputStillSubmits(t *testing.T) {
|
|
m, _ := press(t, onUsers(userFixtures()), "t")
|
|
m.ntfyTopicInput.SetValue("")
|
|
|
|
m, cmd := press(t, m, "enter")
|
|
if cmd == nil {
|
|
t.Fatal("expected clearing the topic to call the server")
|
|
}
|
|
if m.mode != modeDashboard {
|
|
t.Errorf("expected a return to the dashboard, got mode %v", m.mode)
|
|
}
|
|
}
|
|
|
|
func TestNotifyTopic_IsUsersSectionOnly(t *testing.T) {
|
|
m := sized()
|
|
m.activeSection = sectionIncidents
|
|
if next, cmd := press(t, m, "t"); cmd != nil || next.mode != modeDashboard {
|
|
t.Error("expected t to do nothing outside the Users section")
|
|
}
|
|
}
|
|
|
|
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, sectionStats, 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 is a section like any other: no key of its own, no mode of its own, and
|
|
// it loads once on first visit rather than on every tab-in — the three empty
|
|
// slices a quiet server returns are a real answer, not a missing one.
|
|
func TestStats_IsAnOrdinarySection(t *testing.T) {
|
|
m := sized()
|
|
m.activeSection = sectionAlerts
|
|
|
|
m, cmd := press(t, m, "tab")
|
|
if m.activeSection != sectionStats {
|
|
t.Fatalf("expected the stats section, got %v", m.activeSection)
|
|
}
|
|
if m.mode != modeDashboard {
|
|
t.Errorf("stats is a section, not a mode: got mode %v", m.mode)
|
|
}
|
|
if cmd == nil {
|
|
t.Error("the first visit should fetch")
|
|
}
|
|
|
|
m.statsLoaded = true
|
|
m.statsLoading = false
|
|
if cmd := m.loadSectionIfEmpty(); cmd != nil {
|
|
t.Error("a second visit should reuse what was already fetched")
|
|
}
|
|
}
|
|
|
|
// S used to open the stats overlay from anywhere. It is gone, and must not
|
|
// disturb the view it is pressed in.
|
|
func TestStats_KeyIsGone(t *testing.T) {
|
|
m, _ := press(t, sized(), "S")
|
|
if m.activeSection != sectionIncidents || m.mode != modeDashboard {
|
|
t.Errorf("S should do nothing on the queue, got section %v mode %v",
|
|
m.activeSection, m.mode)
|
|
}
|
|
|
|
m, _ = press(t, onIncident(openIncidentFixture(), nil), "S")
|
|
if m.mode != modeIncidentDetail {
|
|
t.Errorf("S should leave the incident open, got mode %v", m.mode)
|
|
}
|
|
}
|
|
|
|
// The overlay never auto-refreshed, because the tick skipped every non-dashboard
|
|
// mode. As a section it rides the tick like the rest.
|
|
func TestStats_RefreshesOnTick(t *testing.T) {
|
|
m := sized()
|
|
m.activeSection = sectionStats
|
|
if m.refreshActiveSection() == nil {
|
|
t.Error("the stats section should refresh on the tick")
|
|
}
|
|
}
|
|
|
|
// 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
|
|
}
|
|
|
|
// The bug: assigning an on-call week panicked with "index out of range [-1]"
|
|
// on a perfectly normal schedule, as long as nobody had moved the cursor first.
|
|
//
|
|
// The cause is not in this package. bubbles' SetRows clamps the cursor down but
|
|
// never up, so the empty rebuild every table gets from the first WindowSizeMsg
|
|
// -- which arrives before any fetch returns -- pins the cursor at -1, and
|
|
// loading real rows afterwards leaves it there. Pressing up or down hid it,
|
|
// which is why every existing test missed it: they all call SetCursor, and
|
|
// SetCursor clamps.
|
|
//
|
|
// So this test must NOT touch the cursor. It reproduces the real order of
|
|
// events: size first, data second, keys third.
|
|
func TestSchedule_AssignWeekAfterStartupSizingDoesNotPanic(t *testing.T) {
|
|
m := NewModel(nil, "http://test", time.Minute, theme.GruvboxDark)
|
|
m.connected = true
|
|
m.isAdmin = true
|
|
m.teams = []api.Team{{ID: 1, Name: "Ops", Role: api.RoleOwner}}
|
|
m.activeSection = sectionSchedule
|
|
m.scheduleWindow = time.Date(2026, 7, 27, 0, 0, 0, 0, time.UTC)
|
|
|
|
// 1. Terminal size arrives while every table is still empty.
|
|
next, _ := m.Update(tea.WindowSizeMsg{Width: 120, Height: 40})
|
|
m = next.(Model)
|
|
|
|
// 2. The schedule and the user list land.
|
|
next, _ = m.Update(scheduleFetchedMsg{entries: []api.ScheduleEntry{}})
|
|
m = next.(Model)
|
|
next, _ = m.Update(usersFetchedMsg{users: []api.User{
|
|
{ID: 1, Username: "niklas", Email: "n@example.com"},
|
|
}})
|
|
m = next.(Model)
|
|
|
|
if got := m.scheduleTable.Cursor(); got < 0 {
|
|
t.Fatalf("schedule cursor is %d after loading %d days; a populated table must have a usable cursor",
|
|
got, len(m.scheduleDays))
|
|
}
|
|
|
|
// 3. Assign the week to the first user, without ever moving a cursor.
|
|
m, _ = press(t, m, "W")
|
|
if m.mode != modeUserPicker {
|
|
t.Fatalf("W did not open the user picker, got mode %v", m.mode)
|
|
}
|
|
// The picker waits for the team's members before offering anybody.
|
|
next, _ = m.Update(pickerReadyMsg{
|
|
users: []api.User{{ID: 1, Username: "niklas", Email: "n@example.com"}},
|
|
members: map[int64]bool{1: true},
|
|
})
|
|
m = next.(Model)
|
|
m, _ = press(t, m, "enter") // panicked here
|
|
|
|
if m.mode == modeUserPicker {
|
|
t.Fatal("enter left the picker open; the assignment never went anywhere")
|
|
}
|
|
}
|
|
|
|
// ── Teams ─────────────────────────────────────────────────────────────────
|
|
|
|
func twoTeams() []api.Team {
|
|
return []api.Team{
|
|
{ID: 1, Name: "Ops", Role: api.RoleOwner},
|
|
{ID: 2, Name: "Dev", Role: api.RoleMember},
|
|
}
|
|
}
|
|
|
|
func TestConnected_LoadsTeamsAndWhoIAm(t *testing.T) {
|
|
m := NewModel(nil, "http://test", time.Minute, theme.GruvboxDark)
|
|
m.width, m.height = 120, 40
|
|
next, _ := m.Update(connectedMsg{
|
|
teams: twoTeams(),
|
|
me: api.Me{User: api.User{ID: 7, IsAdmin: true}},
|
|
})
|
|
m = next.(Model)
|
|
if len(m.teams) != 2 || m.meID != 7 || !m.isAdmin {
|
|
t.Errorf("expected teams, id and admin flag to be kept, got %+v %d %v", m.teams, m.meID, m.isAdmin)
|
|
}
|
|
if m.activeTeamID != 0 {
|
|
t.Errorf("with no default team every team shows, got active %d", m.activeTeamID)
|
|
}
|
|
}
|
|
|
|
func TestConnected_DefaultTeamFromConfig(t *testing.T) {
|
|
for _, want := range []string{"dev", "2"} { // by name, any case, or by id
|
|
m := NewModel(nil, "http://test", time.Minute, theme.GruvboxDark).WithDefaultTeam(want)
|
|
next, _ := m.Update(connectedMsg{teams: twoTeams()})
|
|
if got := next.(Model).activeTeamID; got != 2 {
|
|
t.Errorf("default team %q: expected team 2, got %d", want, got)
|
|
}
|
|
}
|
|
|
|
m := NewModel(nil, "http://test", time.Minute, theme.GruvboxDark).WithDefaultTeam("nope")
|
|
next, cmd := m.Update(connectedMsg{teams: twoTeams()})
|
|
m = next.(Model)
|
|
if m.activeTeamID != 0 || !strings.Contains(m.statusMsg, "nope") {
|
|
t.Errorf("an unknown default should fall back to all teams and say so, got %d %q",
|
|
m.activeTeamID, m.statusMsg)
|
|
}
|
|
if cmd == nil {
|
|
t.Error("expected the initial fetches to still be issued")
|
|
}
|
|
}
|
|
|
|
func TestSwitchTeam_CyclesAllThenEachTeam(t *testing.T) {
|
|
m := sized()
|
|
m.teams = twoTeams()
|
|
var seen []int64
|
|
for i := 0; i < 4; i++ {
|
|
var cmd tea.Cmd
|
|
m, cmd = press(t, m, "T")
|
|
if cmd == nil {
|
|
t.Fatal("switching team should reload")
|
|
}
|
|
seen = append(seen, m.activeTeamID)
|
|
}
|
|
want := []int64{1, 2, 0, 1}
|
|
for i := range want {
|
|
if seen[i] != want[i] {
|
|
t.Fatalf("expected the cycle %v, got %v", want, seen)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestSwitchTeam_ClearsRowsFromTheOtherTeam(t *testing.T) {
|
|
m := sized()
|
|
m.teams = twoTeams()
|
|
m.incidents = []api.Incident{{ID: 1, Title: "old", TeamName: "Ops"}}
|
|
m.rebuildIncidentTable()
|
|
m, _ = press(t, m, "T")
|
|
if len(m.incidents) != 0 {
|
|
t.Errorf("the previous team's incidents must not linger, got %d", len(m.incidents))
|
|
}
|
|
if !strings.Contains(m.statusMsg, "Ops") {
|
|
t.Errorf("expected the status bar to name the team, got %q", m.statusMsg)
|
|
}
|
|
}
|
|
|
|
func TestSwitchTeam_NoTeamsDoesNothing(t *testing.T) {
|
|
m, cmd := press(t, sized(), "T")
|
|
if cmd != nil || m.activeTeamID != 0 {
|
|
t.Errorf("without teams T has nothing to switch to")
|
|
}
|
|
}
|
|
|
|
func TestScheduleTeam(t *testing.T) {
|
|
m := sized()
|
|
if _, ok := m.scheduleTeam(); ok {
|
|
t.Error("no teams means no schedule")
|
|
}
|
|
m.teams = []api.Team{{ID: 2, Name: "Dev", Role: api.RoleMember}, {ID: 1, Name: "Ops", Role: api.RoleOwner}}
|
|
if tm, _ := m.scheduleTeam(); tm.ID != 1 {
|
|
t.Errorf("with all teams showing the one the caller owns is used, got %d", tm.ID)
|
|
}
|
|
m.activeTeamID = 2
|
|
if tm, _ := m.scheduleTeam(); tm.ID != 2 {
|
|
t.Errorf("the active team wins, got %d", tm.ID)
|
|
}
|
|
}
|
|
|
|
func TestSchedule_OnlyOwnersAndAdminsEdit(t *testing.T) {
|
|
m := scheduledWeek(nil)
|
|
m.isAdmin = false
|
|
m.teams = []api.Team{{ID: 1, Name: "Ops", Role: api.RoleMember}}
|
|
m, cmd := press(t, m, "+")
|
|
if m.mode == modeUserPicker {
|
|
t.Fatal("a plain member must not get as far as the picker")
|
|
}
|
|
if !strings.Contains(m.statusMsg, "owners of Ops") {
|
|
t.Errorf("expected the reason in the status bar, got %q", m.statusMsg)
|
|
}
|
|
if cmd == nil {
|
|
t.Error("the message should clear itself")
|
|
}
|
|
|
|
m.isAdmin = true // administrators may edit any team's rota
|
|
m.statusMsg = ""
|
|
m, _ = press(t, m, "+")
|
|
if m.mode != modeUserPicker {
|
|
t.Errorf("an administrator should reach the picker, got mode %v", m.mode)
|
|
}
|
|
}
|
|
|
|
// The picker fetches the team's members, and offers nobody until they arrive:
|
|
// the server answers 404 for anyone else.
|
|
func TestSchedulePicker_OffersOnlyTeamMembers(t *testing.T) {
|
|
m := scheduledWeek(nil)
|
|
m, cmd := press(t, m, "+")
|
|
if cmd == nil || !m.usersLoading {
|
|
t.Fatal("opening the picker should start loading the members")
|
|
}
|
|
if got := len(m.pickerUsers()); got != 0 {
|
|
t.Errorf("nobody should be offered before the members are known, got %d", got)
|
|
}
|
|
|
|
disabled := time.Now()
|
|
next, _ := m.Update(pickerReadyMsg{
|
|
users: []api.User{
|
|
{ID: 1, Username: "niklas"},
|
|
{ID: 2, Username: "outsider"},
|
|
{ID: 3, Username: "gone", DisabledAt: &disabled},
|
|
},
|
|
members: map[int64]bool{1: true, 3: true},
|
|
})
|
|
m = next.(Model)
|
|
got := m.pickerUsers()
|
|
if len(got) != 1 || got[0].Username != "niklas" {
|
|
t.Errorf("expected only the enabled member, got %+v", got)
|
|
}
|
|
if rows := m.userPickerTable.Rows(); len(rows) != 1 {
|
|
t.Errorf("the table should match, got %d rows", len(rows))
|
|
}
|
|
}
|
|
|
|
func TestUsers_NonAdminsCannotCreateOrDelete(t *testing.T) {
|
|
m := threeUsers()
|
|
m.isAdmin = false
|
|
m.meID = 1
|
|
m, _ = press(t, m, "n")
|
|
if m.mode != modeDashboard || !strings.Contains(m.statusMsg, "administrators") {
|
|
t.Errorf("n should be refused with a reason, got mode %v %q", m.mode, m.statusMsg)
|
|
}
|
|
m, _ = press(t, m, "d")
|
|
if m.mode != modeDashboard {
|
|
t.Errorf("d should not ask to delete for a non-admin, got mode %v", m.mode)
|
|
}
|
|
}
|
|
|
|
func TestUsers_NonAdminManagesOnlyThemselves(t *testing.T) {
|
|
m := threeUsers() // cursor on erik, id 3
|
|
m.isAdmin = false
|
|
m.meID = 1
|
|
for _, key := range []string{"t", "k", "p"} {
|
|
next, _ := press(t, m, key)
|
|
if next.mode != modeDashboard {
|
|
t.Errorf("%s on somebody else's row should be refused, got mode %v", key, next.mode)
|
|
}
|
|
if !strings.Contains(next.statusMsg, "another user's") {
|
|
t.Errorf("%s: expected the reason, got %q", key, next.statusMsg)
|
|
}
|
|
}
|
|
|
|
m.userManageTable.SetCursor(0) // niklas, id 1: themselves
|
|
if next, _ := press(t, m, "k"); next.mode != modeAPIKeyMenu {
|
|
t.Errorf("a user may manage their own keys, got mode %v", next.mode)
|
|
}
|
|
}
|
|
|
|
func TestUserFlags(t *testing.T) {
|
|
now := time.Now()
|
|
cases := []struct {
|
|
u api.User
|
|
want string
|
|
}{
|
|
{api.User{}, "—"},
|
|
{api.User{IsAdmin: true}, "admin"},
|
|
{api.User{DisabledAt: &now}, "disabled"},
|
|
{api.User{IsAdmin: true, DisabledAt: &now}, "admin,disabled"},
|
|
}
|
|
for _, c := range cases {
|
|
if got := userFlags(c.u); got != c.want {
|
|
t.Errorf("userFlags(%+v) = %q, want %q", c.u, got, c.want)
|
|
}
|
|
}
|
|
}
|
|
|
|
// Rebuilding a list on every refresh must not send the cursor back to the top.
|
|
func TestRefresh_KeepsTheCursor(t *testing.T) {
|
|
m := sized()
|
|
m.incidents = []api.Incident{{ID: 1}, {ID: 2}, {ID: 3}}
|
|
m.rebuildIncidentTable()
|
|
m.incidentTable.SetCursor(2)
|
|
next, _ := m.Update(incidentsFetchedMsg{incidents: m.incidents})
|
|
if got := next.(Model).incidentTable.Cursor(); got != 2 {
|
|
t.Errorf("expected the cursor to stay on row 2, got %d", got)
|
|
}
|
|
}
|
|
|
|
// The Team column comes and goes as the team switches, and the table must
|
|
// survive its column count changing under rows that are already loaded.
|
|
func TestTeamColumn_AppearsWithoutPanicking(t *testing.T) {
|
|
m := sized()
|
|
m.incidents = []api.Incident{{ID: 1, Title: "a", TeamName: "Ops"}}
|
|
m.rebuildIncidentTable()
|
|
m.teams = twoTeams()
|
|
m.rebuildIncidentTable() // five columns become six over a five-cell row
|
|
if got := len(m.incidentTable.Columns()); got != 6 {
|
|
t.Errorf("expected a Team column across two teams, got %d columns", got)
|
|
}
|
|
m.activeTeamID = 1
|
|
m.rebuildIncidentTable()
|
|
if got := len(m.incidentTable.Columns()); got != 5 {
|
|
t.Errorf("expected the Team column to go when one team is chosen, got %d", got)
|
|
}
|
|
}
|