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, modeTeamPicker, 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 TestTeamPicker_Opens(t *testing.T) { m := sized() m.teams = twoTeams() m, cmd := press(t, m, "T") if m.mode != modeTeamPicker { t.Fatalf("T should open the team picker, got mode %v", m.mode) } if cmd != nil { t.Error("opening the picker should not itself trigger a reload") } } // Row 0 of the picker table is always "All teams"; row i (i>=1) is // m.teams[i-1] — see rebuildTeamPickerTable. func TestTeamPicker_SelectTeamClearsRowsFromTheOtherTeam(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") m.teamPickerTable.SetCursor(1) // twoTeams()[0] is Ops m, cmd := press(t, m, "enter") if cmd == nil { t.Fatal("selecting a team should reload") } if m.mode != modeDashboard { t.Fatalf("enter should close the picker, got mode %v", m.mode) } if m.activeTeamID != 1 { t.Fatalf("expected team 1 (Ops) active, got %d", m.activeTeamID) } 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 TestTeamPicker_SelectAllTeams(t *testing.T) { m := sized() m.teams = twoTeams() m.activeTeamID = 1 m, _ = press(t, m, "T") m.teamPickerTable.SetCursor(0) // "All teams" m, cmd := press(t, m, "enter") if cmd == nil { t.Fatal("selecting all teams should reload") } if m.activeTeamID != 0 { t.Fatalf("expected all teams (0), got %d", m.activeTeamID) } } func TestTeamPicker_EscCancelsWithoutChangingTeam(t *testing.T) { m := sized() m.teams = twoTeams() m.activeTeamID = 1 m, _ = press(t, m, "T") m, cmd := press(t, m, "esc") if cmd != nil { t.Error("cancelling should not reload") } if m.mode != modeDashboard { t.Fatalf("esc should close the picker, got mode %v", m.mode) } if m.activeTeamID != 1 { t.Fatalf("cancelling must not change the active team, got %d", m.activeTeamID) } } func TestTeamPicker_NoTeamsDoesNothing(t *testing.T) { m, cmd := press(t, sized(), "T") if cmd != nil || m.mode == modeTeamPicker { t.Errorf("without teams T has nothing to open") } } 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) } }