diff --git a/CLAUDE.md b/CLAUDE.md index b697b8d..37d9c34 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -196,6 +196,7 @@ All API calls go to `/api/v1/*`. Key route groups: - `GET /api/v1/favorites/check?entity_type=&entity_id=` — `favorite check` prints yes or no (exit 0). - `PUT /api/v1/clusters/:id/shared-values/:valueId` replaces the full record (no GET for one entry): `shared-values update` reads the list, merges and sends name, description, values and priority. `--set` re-marshals the stored YAML (comments and key order are lost; a stderr note says so). - `/api/v1/admin/notification-channels` (devops or admin) — `notification channel ...`. The responses carry the full `webhook_url` (it can hold a token): every output mode masks it to scheme and host (`maskWebhookURL`) unless `--show-url`. The server never returns the secret; the CLI reads it from `--secret-env NAME` or `--secret-stdin` only. The URL can also come from `--url-env NAME`; error texts never contain the URL. `PUT /:id` changes only the fields sent (`secret: ""` removes the secret; `filters` replaces all filters, `{}` removes them). `PUT /:id/subscriptions` replaces all event types (400 for an unknown one). `POST /:id/test` answers 200 with `success`; the command exits 1 when it is false. Create and update return `warnings` (unknown filter IDs) → stderr. +- `/api/v1/notifications` and `/count` — the server caps the counts (k8s-stack-manager #654): `total` is exact up to offset + 1000 (`total_capped`), `unread_count` up to 1000 (`unread_count_capped`); an older server sends neither flag (false). `notification list` prints "1000+" in the footer; `notification count` keeps stdout a bare integer and writes a note to stderr when capped; `-o json`/`-o yaml` carry the flags. - `/api/v1/notifications/preferences` — `notification prefs set` warns for an event type not in `notificationEventTypes` (`notifier.AllEventTypes()` of k8s-stack-manager); v0.8.0+ servers return 400 - `/api/v1/stack-definitions` — CRUD + export/import - `POST /:id/charts` and `DELETE /:id/charts/:chartId` (devops or admin; `definition add-chart`, `remove-chart`). A linked definition cannot lose a chart that the template requires (409 "Cannot delete required chart: X"). diff --git a/cli/cmd/notification.go b/cli/cmd/notification.go index 98b028c..8124dbd 100644 --- a/cli/cmd/notification.go +++ b/cli/cmd/notification.go @@ -110,7 +110,8 @@ Examples: if err := printer.PrintTable(headers, rows); err != nil { return err } - printer.PrintMessage("(%d unread of %d total)", page.UnreadCount, page.Total) + printer.PrintMessage("(%s unread of %s total)", + cappedCount(page.UnreadCount, page.UnreadCountCapped), cappedCount(page.Total, page.TotalCapped)) return nil } }, @@ -122,29 +123,38 @@ var notificationCountCmd = &cobra.Command{ Long: `Print the unread notification count for the authenticated user. In table mode (default) the output is a single integer suitable for shell -scripting; -o json wraps it in {"unread_count": N} matching the server -response shape. +scripting; -o json prints the server response, +{"unread_count": N, "unread_count_capped": false}. + +The server counts at most 1000 unread notifications. Above that, the +count is 1000, unread_count_capped is true and table mode also writes a +note to stderr. Examples: stackctl notification count # 7 - stackctl notification count -o json # {"unread_count":7}`, + stackctl notification count -o json # {"unread_count":7,"unread_count_capped":false}`, SilenceUsage: true, RunE: func(cmd *cobra.Command, args []string) error { c, err := newClient() if err != nil { return err } - count, err := c.CountUnreadNotifications() + resp, err := c.UnreadNotificationCount() if err != nil { return err } switch printer.Format { case output.FormatJSON: - return printer.PrintJSON(types.UnreadCountResponse{UnreadCount: count}) + return printer.PrintJSON(resp) case output.FormatYAML: - return printer.PrintYAML(types.UnreadCountResponse{UnreadCount: count}) + return printer.PrintYAML(resp) default: - fmt.Fprintln(printer.Writer, count) + // stdout stays a bare integer for scripts; the cap goes to stderr. + fmt.Fprintln(printer.Writer, resp.UnreadCount) + if resp.UnreadCountCapped { + fmt.Fprintf(cmd.ErrOrStderr(), "Note: more than %d notifications are unread; the server stops counting at %d.\n", + resp.UnreadCount, resp.UnreadCount) + } return nil } }, @@ -462,3 +472,12 @@ func ResetNotificationFlagsForTest() { } func resetNotificationFlagsForTest() { ResetNotificationFlagsForTest() } + +// cappedCount formats a notification count of the server: "1000+" when the +// server stopped counting (capped), else the number. +func cappedCount(n int64, capped bool) string { + if capped { + return strconv.FormatInt(n, 10) + "+" + } + return strconv.FormatInt(n, 10) +} diff --git a/cli/cmd/notification_test.go b/cli/cmd/notification_test.go index 005c81b..45d32eb 100644 --- a/cli/cmd/notification_test.go +++ b/cli/cmd/notification_test.go @@ -221,6 +221,141 @@ func TestNotificationCountCmd_QuietOutput(t *testing.T) { assert.Equal(t, "7", strings.TrimSpace(buf.String())) } +// TestNotificationListCmd_CappedFooter checks the table footer with the +// capped counts of the server (k8s-stack-manager #654). +func TestNotificationListCmd_CappedFooter(t *testing.T) { + tests := []struct { + name string + page types.PaginatedNotifications + want string + }{ + {name: "exact", page: types.PaginatedNotifications{Total: 2, UnreadCount: 1}, want: "(1 unread of 2 total)"}, + {name: "total capped", page: types.PaginatedNotifications{Total: 1000, TotalCapped: true, UnreadCount: 3}, want: "(3 unread of 1000+ total)"}, + {name: "both capped", page: types.PaginatedNotifications{Total: 1000, TotalCapped: true, UnreadCount: 1000, UnreadCountCapped: true}, want: "(1000+ unread of 1000+ total)"}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + page := tt.page + page.Notifications = sampleNotifications() + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(page) + })) + defer server.Close() + + buf := setupStackTestCmd(t, server.URL) + resetNotificationFlagsForTest() + defer resetNotificationFlagsForTest() + require.NoError(t, notificationListCmd.RunE(notificationListCmd, []string{})) + assert.Contains(t, buf.String(), tt.want) + }) + } +} + +// TestNotificationCountCmd_Capped checks that a capped count keeps stdout a +// bare integer and writes a note to stderr; -o json keeps the flag. +func TestNotificationCountCmd_Capped(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"unread_count":1000,"unread_count_capped":true}`)) + })) + defer server.Close() + + t.Run("table", func(t *testing.T) { + buf := setupStackTestCmd(t, server.URL) + var stderr bytes.Buffer + notificationCountCmd.SetErr(&stderr) + t.Cleanup(func() { notificationCountCmd.SetErr(nil) }) + + require.NoError(t, notificationCountCmd.RunE(notificationCountCmd, []string{})) + assert.Equal(t, "1000", strings.TrimSpace(buf.String())) + assert.Contains(t, stderr.String(), "more than 1000 notifications are unread") + }) + + t.Run("json", func(t *testing.T) { + buf := setupStackTestCmd(t, server.URL) + printer.Format = output.FormatJSON + require.NoError(t, notificationCountCmd.RunE(notificationCountCmd, []string{})) + + var got types.UnreadCountResponse + require.NoError(t, json.Unmarshal(buf.Bytes(), &got)) + assert.Equal(t, types.UnreadCountResponse{UnreadCount: 1000, UnreadCountCapped: true}, got) + }) + + t.Run("yaml", func(t *testing.T) { + buf := setupStackTestCmd(t, server.URL) + printer.Format = output.FormatYAML + require.NoError(t, notificationCountCmd.RunE(notificationCountCmd, []string{})) + assert.Contains(t, buf.String(), "unread_count: 1000") + assert.Contains(t, buf.String(), "unread_count_capped: true") + }) + + t.Run("quiet", func(t *testing.T) { + buf := setupStackTestCmd(t, server.URL) + printer.Quiet = true + var stderr bytes.Buffer + notificationCountCmd.SetErr(&stderr) + t.Cleanup(func() { notificationCountCmd.SetErr(nil) }) + + require.NoError(t, notificationCountCmd.RunE(notificationCountCmd, []string{})) + assert.Equal(t, "1000", strings.TrimSpace(buf.String()), "stdout stays a bare integer") + assert.Contains(t, stderr.String(), "more than 1000") + }) +} + +// TestNotificationListCmd_JSONCarriesCapFlags checks that -o json passes the +// cap flags through, and gives false for an older server without them. +func TestNotificationListCmd_JSONCarriesCapFlags(t *testing.T) { + tests := []struct { + name string + body string + wantTotal bool + wantUnread bool + }{ + {name: "capped", body: `{"notifications":[],"total":1000,"total_capped":true,"unread_count":1000,"unread_count_capped":true}`, wantTotal: true, wantUnread: true}, + {name: "older server", body: `{"notifications":[],"total":2,"unread_count":1}`}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(tt.body)) + })) + defer server.Close() + + buf := setupStackTestCmd(t, server.URL) + resetNotificationFlagsForTest() + defer resetNotificationFlagsForTest() + printer.Format = output.FormatJSON + require.NoError(t, notificationListCmd.RunE(notificationListCmd, []string{})) + + var got map[string]any + require.NoError(t, json.Unmarshal(buf.Bytes(), &got)) + assert.Equal(t, tt.wantTotal, got["total_capped"]) + assert.Equal(t, tt.wantUnread, got["unread_count_capped"]) + }) + } +} + +// TestNotificationCountCmd_NotCappedNoNote checks that an exact count writes +// nothing to stderr. +func TestNotificationCountCmd_NotCappedNoNote(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"unread_count":7}`)) + })) + defer server.Close() + + buf := setupStackTestCmd(t, server.URL) + var stderr bytes.Buffer + notificationCountCmd.SetErr(&stderr) + t.Cleanup(func() { notificationCountCmd.SetErr(nil) }) + + require.NoError(t, notificationCountCmd.RunE(notificationCountCmd, []string{})) + assert.Equal(t, "7", strings.TrimSpace(buf.String())) + assert.Empty(t, stderr.String()) +} + // ---------- notification read ---------- func TestNotificationReadCmd_Success(t *testing.T) { diff --git a/cli/pkg/client/client.go b/cli/pkg/client/client.go index 640654c..c11d5c1 100644 --- a/cli/pkg/client/client.go +++ b/cli/pkg/client/client.go @@ -2208,17 +2208,30 @@ func (c *Client) ListNotifications(p NotificationListParams) (*types.PaginatedNo return &resp, nil } -// CountUnreadNotifications returns the unread count for badge display. +// CountUnreadNotifications returns the unread count for badge display. The +// server caps it at 1000; use UnreadNotificationCount to see whether it did. // // @see GET /api/v1/notifications/count func (c *Client) CountUnreadNotifications() (int64, error) { - var resp types.UnreadCountResponse - if err := c.Get("/api/v1/notifications/count", &resp); err != nil { + resp, err := c.UnreadNotificationCount() + if err != nil { return 0, err } return resp.UnreadCount, nil } +// UnreadNotificationCount returns the unread count and whether the server +// capped it (UnreadCountCapped: more than UnreadCount are unread). +// +// @see GET /api/v1/notifications/count +func (c *Client) UnreadNotificationCount() (*types.UnreadCountResponse, error) { + var resp types.UnreadCountResponse + if err := c.Get("/api/v1/notifications/count", &resp); err != nil { + return nil, err + } + return &resp, nil +} + // MarkNotificationAsRead marks a single notification as read. The backend // verifies ownership and returns 404 if the notification belongs to another // user (or does not exist). diff --git a/cli/pkg/client/client_test.go b/cli/pkg/client/client_test.go index c0f5789..b284f33 100644 --- a/cli/pkg/client/client_test.go +++ b/cli/pkg/client/client_test.go @@ -3426,6 +3426,35 @@ func TestCountUnreadNotifications_Success(t *testing.T) { assert.Equal(t, int64(42), got) } +func TestUnreadNotificationCount(t *testing.T) { + t.Parallel() + tests := []struct { + name string + body string + want types.UnreadCountResponse + }{ + {name: "exact", body: `{"unread_count":42,"unread_count_capped":false}`, want: types.UnreadCountResponse{UnreadCount: 42}}, + {name: "capped", body: `{"unread_count":1000,"unread_count_capped":true}`, want: types.UnreadCountResponse{UnreadCount: 1000, UnreadCountCapped: true}}, + {name: "older server without the flag", body: `{"unread_count":5}`, want: types.UnreadCountResponse{UnreadCount: 5}}, + } + for _, tt := range tests { + tt := tt + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, "/api/v1/notifications/count", r.URL.Path) + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(tt.body)) + })) + defer server.Close() + + got, err := New(server.URL).UnreadNotificationCount() + require.NoError(t, err) + assert.Equal(t, tt.want, *got) + }) + } +} + func TestMarkNotificationAsRead_Success(t *testing.T) { t.Parallel() server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { diff --git a/cli/pkg/types/types.go b/cli/pkg/types/types.go index 85cd130..a8f1b44 100644 --- a/cli/pkg/types/types.go +++ b/cli/pkg/types/types.go @@ -662,10 +662,17 @@ type Notification struct { // Mirrors backend models.PaginatedNotifications. // // Field declaration order is alphabetical by json tag (see Notification). +// +// The server caps both counts (k8s-stack-manager #654): Total is exact up to +// offset + 1000 and UnreadCount up to 1000. Above that, the count is the cap +// and TotalCapped or UnreadCountCapped is true (at least one more exists). +// An older server sends neither flag (false). type PaginatedNotifications struct { - Notifications []Notification `json:"notifications" yaml:"notifications"` - Total int64 `json:"total" yaml:"total"` - UnreadCount int64 `json:"unread_count" yaml:"unread_count"` + Notifications []Notification `json:"notifications" yaml:"notifications"` + Total int64 `json:"total" yaml:"total"` + TotalCapped bool `json:"total_capped" yaml:"total_capped"` + UnreadCount int64 `json:"unread_count" yaml:"unread_count"` + UnreadCountCapped bool `json:"unread_count_capped" yaml:"unread_count_capped"` } // NotificationPreference is one row in the notification-preference payload @@ -694,11 +701,13 @@ type NotificationPreference struct { UserID string `json:"user_id,omitempty" yaml:"user_id,omitempty"` } -// UnreadCountResponse is the success-path response of GET /api/v1/notifications/count. -// The endpoint returns a single key {"unread_count": N} rather than a wrapped -// object — modelled as a struct so JSON/YAML decode cleanly. +// UnreadCountResponse is the success-path response of GET /api/v1/notifications/count: +// {"unread_count": N, "unread_count_capped": false}. The server counts at most +// 1000 unread notifications (k8s-stack-manager #654); above that UnreadCount +// is 1000 and UnreadCountCapped is true. An older server sends no flag (false). type UnreadCountResponse struct { - UnreadCount int64 `json:"unread_count" yaml:"unread_count"` + UnreadCount int64 `json:"unread_count" yaml:"unread_count"` + UnreadCountCapped bool `json:"unread_count_capped" yaml:"unread_count_capped"` } // Favorite is one row in the authenticated user's favorites collection.