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:
+99
-17
@@ -40,7 +40,7 @@ func stub(t *testing.T, status int, response string) (*Client, *call) {
|
||||
|
||||
func TestClient_SendsBearerToken(t *testing.T) {
|
||||
c, got := stub(t, http.StatusOK, `[]`)
|
||||
if _, err := c.ListIncidents("", false, false, 0); err != nil {
|
||||
if _, err := c.ListIncidents(0, "", false, false, 0); err != nil {
|
||||
t.Fatalf("list: %v", err)
|
||||
}
|
||||
if got.auth != "Bearer test-key" {
|
||||
@@ -108,23 +108,26 @@ func TestClient_IncidentEndpoints(t *testing.T) {
|
||||
func TestListIncidents_Filters(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
teamID int64
|
||||
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"},
|
||||
{"default is the open queue", 0, "", false, false, 0, ""},
|
||||
{"status", 0, "triggered", false, false, 0, "status=triggered"},
|
||||
{"archived", 0, "resolved", true, false, 0, "archived=true&status=resolved"},
|
||||
{"snoozed", 0, "", false, true, 0, "snoozed=true"},
|
||||
{"limit", 0, "", false, false, 500, "limit=500"},
|
||||
{"one team", 4, "", false, false, 0, "team_id=4"},
|
||||
{"no team means all of them", 0, "triggered", false, false, 0, "status=triggered"},
|
||||
}
|
||||
|
||||
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 {
|
||||
if _, err := c.ListIncidents(tt.teamID, tt.status, tt.archived, tt.snoozed, tt.limit); err != nil {
|
||||
t.Fatalf("list: %v", err)
|
||||
}
|
||||
if got.query != tt.want {
|
||||
@@ -171,9 +174,12 @@ func TestClient_RequestBodies(t *testing.T) {
|
||||
// wire when asked for — and stay off it when not.
|
||||
t.Run("assign schedule", func(t *testing.T) {
|
||||
c, got := stub(t, http.StatusCreated, `[]`)
|
||||
if _, err := c.AssignSchedule(3, []string{"2026-07-27"}, false); err != nil {
|
||||
if _, err := c.AssignSchedule(9, 3, []string{"2026-07-27"}, false); err != nil {
|
||||
t.Fatalf("assign: %v", err)
|
||||
}
|
||||
if got.method != "POST" || got.path != "/api/teams/9/schedule" {
|
||||
t.Errorf("expected POST /api/teams/9/schedule, got %s %s", got.method, got.path)
|
||||
}
|
||||
if got.body != `{"user_id":3,"dates":["2026-07-27"]}` {
|
||||
t.Errorf("unexpected body %q", got.body)
|
||||
}
|
||||
@@ -181,7 +187,7 @@ func TestClient_RequestBodies(t *testing.T) {
|
||||
|
||||
t.Run("assign schedule with replace", func(t *testing.T) {
|
||||
c, got := stub(t, http.StatusCreated, `[]`)
|
||||
if _, err := c.AssignSchedule(3, []string{"2026-07-27"}, true); err != nil {
|
||||
if _, err := c.AssignSchedule(9, 3, []string{"2026-07-27"}, true); err != nil {
|
||||
t.Fatalf("assign: %v", err)
|
||||
}
|
||||
if got.body != `{"user_id":3,"dates":["2026-07-27"],"replace":true}` {
|
||||
@@ -247,15 +253,91 @@ func TestClient_ErrorWithoutBody(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// 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()
|
||||
// Nobody on call is a normal state, not a failure: the server answers with an
|
||||
// empty list, one entry per team that has somebody scheduled.
|
||||
func TestGetCurrentOnCall_ListsOnePerTeam(t *testing.T) {
|
||||
c, got := stub(t, http.StatusOK, `[
|
||||
{"id":1,"team_id":1,"team_name":"Ops","user_id":5,"username":"alice","date":"2026-09-23"},
|
||||
{"id":2,"team_id":2,"team_name":"Dev","user_id":6,"username":"bob","date":"2026-09-23"}]`)
|
||||
entries, err := c.GetCurrentOnCall()
|
||||
if err != nil {
|
||||
t.Fatalf("expected no error, got %v", err)
|
||||
t.Fatalf("on call: %v", err)
|
||||
}
|
||||
if entry != nil {
|
||||
t.Errorf("expected nil entry, got %+v", entry)
|
||||
if got.path != "/api/schedule/current" {
|
||||
t.Errorf("unexpected path %q", got.path)
|
||||
}
|
||||
if len(entries) != 2 || entries[0].TeamName != "Ops" || entries[1].Username != "bob" {
|
||||
t.Errorf("unexpected entries %+v", entries)
|
||||
}
|
||||
|
||||
c, _ = stub(t, http.StatusOK, `[]`)
|
||||
if entries, err := c.GetCurrentOnCall(); err != nil || len(entries) != 0 {
|
||||
t.Errorf("expected no entries and no error, got %v, %v", entries, err)
|
||||
}
|
||||
}
|
||||
|
||||
// Schedules belong to a team, so every call for one has to say which.
|
||||
func TestSchedule_IsPerTeam(t *testing.T) {
|
||||
c, got := stub(t, http.StatusOK, `[]`)
|
||||
if _, err := c.GetSchedule(7, "2026-09-21", "2026-09-27"); err != nil {
|
||||
t.Fatalf("get schedule: %v", err)
|
||||
}
|
||||
if got.path != "/api/teams/7/schedule" || got.query != "from=2026-09-21&to=2026-09-27" {
|
||||
t.Errorf("unexpected request %s?%s", got.path, got.query)
|
||||
}
|
||||
|
||||
c, got = stub(t, http.StatusNoContent, ``)
|
||||
if err := c.DeleteScheduleEntry(7, 12); err != nil {
|
||||
t.Fatalf("delete: %v", err)
|
||||
}
|
||||
if got.method != "DELETE" || got.path != "/api/teams/7/schedule/12" {
|
||||
t.Errorf("unexpected request %s %s", got.method, got.path)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTeams(t *testing.T) {
|
||||
c, got := stub(t, http.StatusOK, `[{"id":3,"name":"Ops","created_at":"2026-09-20T10:00:00Z","role":"owner"}]`)
|
||||
teams, err := c.ListTeams()
|
||||
if err != nil {
|
||||
t.Fatalf("list teams: %v", err)
|
||||
}
|
||||
if got.path != "/api/teams" || len(teams) != 1 || teams[0].Role != RoleOwner || teams[0].Name != "Ops" {
|
||||
t.Errorf("unexpected %s %+v", got.path, teams)
|
||||
}
|
||||
|
||||
c, got = stub(t, http.StatusOK, `[{"team_id":3,"user_id":5,"username":"alice","role":"member"}]`)
|
||||
members, err := c.ListTeamMembers(3)
|
||||
if err != nil {
|
||||
t.Fatalf("list members: %v", err)
|
||||
}
|
||||
if got.path != "/api/teams/3/members" || len(members) != 1 || members[0].UserID != 5 {
|
||||
t.Errorf("unexpected %s %+v", got.path, members)
|
||||
}
|
||||
}
|
||||
|
||||
// A server that predates teams has no /api/teams, and the TUI recognises one by
|
||||
// that 404, so it must come back as a StatusError carrying the code.
|
||||
func TestListTeams_OldServerIs404(t *testing.T) {
|
||||
c, _ := stub(t, http.StatusNotFound, `{"error":"not found"}`)
|
||||
_, err := c.ListTeams()
|
||||
var se *StatusError
|
||||
if !errors.As(err, &se) || se.Code != http.StatusNotFound {
|
||||
t.Errorf("expected a 404 StatusError, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUser_DecodesAdminAndDisabled(t *testing.T) {
|
||||
var u User
|
||||
if err := json.Unmarshal([]byte(
|
||||
`{"id":1,"username":"a","is_admin":true,"disabled_at":"2026-09-22T08:00:00Z"}`), &u); err != nil {
|
||||
t.Fatalf("decode: %v", err)
|
||||
}
|
||||
if !u.IsAdmin || !u.IsDisabled() {
|
||||
t.Errorf("expected an admin who is disabled, got %+v", u)
|
||||
}
|
||||
var other User
|
||||
if err := json.Unmarshal([]byte(`{"id":2,"username":"b","is_admin":false}`), &other); err != nil || other.IsDisabled() {
|
||||
t.Errorf("a user with no disabled_at must not be disabled")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -340,7 +422,7 @@ func TestAlert_DecodesIncidentLink(t *testing.T) {
|
||||
|
||||
func TestListAlerts_ArchivedFilter(t *testing.T) {
|
||||
c, got := stub(t, http.StatusOK, `[]`)
|
||||
if _, err := c.ListAlerts("", true, 50); err != nil {
|
||||
if _, err := c.ListAlerts(0, "", true, 50); err != nil {
|
||||
t.Fatalf("list alerts: %v", err)
|
||||
}
|
||||
if got.path != "/api/alerts" || got.query != "archived=true&limit=50" {
|
||||
|
||||
Reference in New Issue
Block a user