Follow terdut-server into teams: switch team, per-team schedule
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.
This commit is contained in:
@@ -35,6 +35,7 @@ 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
|
||||
}
|
||||
|
||||
@@ -320,6 +321,8 @@ func pickingOnCall(entries []api.ScheduleEntry, dayIndex int, week bool) Model {
|
||||
{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
|
||||
@@ -693,6 +696,8 @@ func containsAll(s string, subs ...string) bool {
|
||||
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)
|
||||
|
||||
@@ -718,9 +723,253 @@ func TestSchedule_AssignWeekAfterStartupSizingDoesNotPanic(t *testing.T) {
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user