8000 feat(coderd): add inbox notifications endpoints by defelmnq · Pull Request #16889 · coder/coder · GitHub
[go: up one dir, main page]

Skip to content

feat(coderd): add inbox notifications endpoints #16889

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 18 commits into from
Mar 17, 2025
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Prev Previous commit
Next Next commit
websocket testing wip
  • Loading branch information
defelmnq committed Mar 14, 2025
commit cb41d1a200ee47b0e4b6615f733886d3f1ff9f58
1 change: 1 addition & 0 deletions coderd/inboxnotifications.go
Original file line number Diff line number Diff line c 10000 hange
Expand Up @@ -153,6 +153,7 @@ func (api *API) watchInboxNotifications(rw http.ResponseWriter, r *http.Request)
select {
case notificationCh <- payload.InboxNotification:
default:
api.Logger.Error(ctx, "unable to push notification in channel")
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Whenever you log a message, I want you to think to yourself: "If I were an operator, how would I know what happened here, why I should care, and what to do next". This doesn't really satisfy any of those 3 clauses.

}
},
))
Expand Down
290 changes: 255 additions & 35 deletions coderd/inboxnotifications_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@
"context"
"encoding/json"
"fmt"
"net/http"
"net/http/httptest"
"runtime"
"testing"

Expand All @@ -13,16 +15,82 @@
"github.com/coder/coder/v2/coderd/coderdtest"
"github.com/coder/coder/v2/coderd/database"
"github.com/coder/coder/v2/coderd/database/dbgen"
"github.com/coder/coder/v2/coderd/database/dbtestutil"
"github.com/coder/coder/v2/coderd/database/dbtime"
"github.com/coder/coder/v2/coderd/notifications"
"github.com/coder/coder/v2/coderd/notifications/dispatch"
"github.com/coder/coder/v2/coderd/notifications/types"
"github.com/coder/coder/v2/coderd/rbac"
"github.com/coder/coder/v2/codersdk"
"github.com/coder/coder/v2/testutil"
"github.com/coder/websocket"
)

const (
inboxNotificationsPageSize = 25
)

var (
failingPaginationUUID = uuid.MustParse("fba6966a-9061-4111-8e1a-f6a9fbea4b16")
)

func TestInboxNotification_Watch(t *testing.T) {
t.Parallel()

t.Run("OK", func(t *testing.T) {
t.Parallel()

logger := testutil.Logger(t)
ctx := testutil.Context(t, testutil.WaitLong)

db, ps := dbtestutil.NewDB(t)
db.DisableForeignKeysAndTriggers(ctx)

firstClient, _, _ := coderdtest.NewWithAPI(t, &coderdtest.Options{})
firstUser := coderdtest.CreateFirstUser(t, firstClient)
member, memberClient := coderdtest.CreateAnotherUser(t, firstClient, firstUser.OrganizationID, rbac.RoleTemplateAdmin())

srv := httptest.NewServer(http.HandlerFunc(func(rw http.ResponseWriter, r *http.Request) {
member.WatchInboxNotificationx(ctx)
}))
defer srv.Close()

u, err := member.URL.Parse("/api/v2/notifications/inbox/watch")
require.NoError(t, err)

// nolint: bodyclose
wsConn, resp, err := websocket.Dial(ctx, u.String(), &websocket.DialOptions{
HTTPHeader: http.Header{
"Coder-Session-Token": []string{member.SessionToken()},
},
})
if err != nil {
if resp.StatusCode != http.StatusSwitchingProtocols {
err = codersdk.ReadBodyAsError(resp)
}
require.NoError(t, err)
}
defer wsConn.Close(websocket.StatusNormalClosure, "done")
_, cnc := codersdk.WebsocketNetConn(ctx, wsConn, websocket.MessageBinary)

inboxHandler := dispatch.NewInboxHandler(logger, db, ps)
dispatchFunc, err := inboxHandler.Dispatcher(types.MessagePayload{
UserID: memberClient.ID.String(),
NotificationTemplateID: notifications.TemplateWorkspaceOutOfMemory.String(),
}, "notification title", "notification content", nil)
require.NoError(t, err)

msgID := uuid.New()
_, err = dispatchFunc(ctx, msgID)
require.NoError(t, err)

op := make([]byte, 1024)
mt, err := cnc.Read(op)
require.NoError(t, err)
require.Equal(t, websocket.MessageText, mt)
})
}

func TestInboxNotifications_List(t *testing.T) {
t.Parallel()

Expand All @@ -33,6 +101,65 @@
t.Skip("our runners are randomly taking too long to insert entries")
}

// create table-based tests for errors and repeting use cases
tests := []struct {
name string
expectedError string
listTemplate string
listTarget string
listReadStatus string
listStartingBefore string
}{
{"nok - wrong targets", `Query param "targets" has invalid values`, "", "wrong_target", "", ""},
{"nok - wrong templates", `Query param "templates" has invalid values`, "wrong_template", "", "", ""},
{"nok - wrong read status", "starting_before query parameter should be any of 'all', 'read', 'unread'", "", "", "erroneous", ""},
{"nok - wrong starting before", `Query param "starting_before" must be a valid uuid`, "", "", "", "xxx-xxx-xxx"},
{"nok - not found starting before", `Failed to get notification by id`, "", "", "", failingPaginationUUID.String()},
}

for _, tt := range tests {
tt := tt
t.Run(tt.name, func(t *testing.T) {
t.Parallel()

client, _, api := coderdtest.NewWithAPI(t, &coderdtest.Options{})
firstUser := coderdtest.CreateFirstUser(t, client)
client, member := coderdtest.CreateAnotherUser(t, client, firstUser.OrganizationID)

ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong)
defer cancel()

notifs, err := client.ListInboxNotifications(ctx, codersdk.ListInboxNotificationsRequest{})
require.NoError(t, err)
require.NotNil(t, notifs)
require.Equal(t, 0, notifs.UnreadCount)
require.Empty(t, notifs.Notifications)

// create a new notifications to fill the database with data
for i := range 20 {
dbgen.NotificationInbox(t, api.Database, database.InsertInboxNotificationParams{
ID: uuid.New(),
UserID: member.ID,
TemplateID: notifications.TemplateWorkspaceOutOfMemory,
Title: fmt.Sprintf("Notification %d", i),
Actions: json.RawMessage("[]"),
Content: fmt.Sprintf("Content of the notif %d", i),
CreatedAt: dbtime.Now(),
})
}

notifs, err = client.ListInboxNotifications(ctx, codersdk.ListInboxNotificationsRequest{
Templates: tt.listTemplate,
Targets: tt.listTarget,
ReadStatus: tt.listReadStatus,
StartingBefore: tt.listStartingBefore,
})
require.ErrorContains(t, err, tt.expectedError)
require.Empty( 10000 t, notifs.Notifications)
require.Zero(t, notifs.UnreadCount)
})
}

t.Run("OK empty", func(t *testing.T) {
t.Parallel()

Expand Down Expand Up @@ -88,7 +215,7 @@
require.Equal(t, "Notification 39", notifs.Notifications[0].Title)

notifs, err = client.ListInboxNotifications(ctx, codersdk.ListInboxNotificationsRequest{
StartingBefore: notifs.Notifications[inboxNotificationsPageSize-1].ID,
StartingBefore: notifs.Notifications[inboxNotificationsPageSize-1].ID.String(),
})
require.NoError(t, err)
require.NotNil(t, notifs)
Expand Down Expand Up @@ -257,42 +384,135 @@
t.Skip("our runners are randomly taking too long to insert entries")
}

client, _, api := coderdtest.NewWithAPI(t, &coderdtest.Options{})
firstUser := coderdtest.CreateFirstUser(t, client)
client, member := coderdtest.CreateAnotherUser(t, client, firstUser.OrganizationID)

ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong)
defer cancel()

notifs, err := client.ListInboxNotifications(ctx, codersdk.ListInboxNotificationsRequest{})
require.NoError(t, err)
require.NotNil(t, notifs)
require.Equal(t, 0, notifs.UnreadCount)
require.Empty(t, notifs.Notifications)

for i := range 20 {
dbgen.NotificationInbox(t, api.Database, database.InsertInboxNotificationParams{
ID: uuid.New(),
UserID: member.ID,
TemplateID: notifications.TemplateWorkspaceOutOfMemory,
Title: fmt.Sprintf("Notification %d", i),
Actions: json.RawMessage("[]"),
Content: fmt.Sprintf("Content of the notif %d", i),
CreatedAt: dbtime.Now(),
t.Run("ok", func(t *testing.T) {

Check failure on line 387 in coderd/inboxnotifications_test.go

View workflow job for this annotation

GitHub Actions / lint

empty-lines: extra empty line at the end of a block (revive)
t.Parallel()
client, _, api := coderdtest.NewWithAPI(t, &coderdtest.Options{})
firstUser := coderdtest.CreateFirstUser(t, client)
client, member := coderdtest.CreateAnotherUser(t, client, firstUser.OrganizationID)

ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong)
defer cancel()

notifs, err := client.ListInboxNotifications(ctx, codersdk.ListInboxNotificationsRequest{})
require.NoError(t, err)
require.NotNil(t, notifs)
require.Equal(t, 0, notifs.UnreadCount)
require.Empty(t, notifs.Notifications)

for i := range 20 {
dbgen.NotificationInbox(t, api.Database, database.InsertInboxNotificationParams{
ID: uuid.New(),
UserID: member.ID,
TemplateID: notifications.TemplateWorkspaceOutOfMemory,
Title: fmt.Sprintf("Notification %d", i),
Actions: json.RawMessage("[]"),
Content: fmt.Sprintf("Content of the notif %d", i),
CreatedAt: dbtime.Now(),
})
}

notifs, err = client.ListInboxNotifications(ctx, codersdk.ListInboxNotificationsRequest{})
require.NoError(t, err)
require.NotNil(t, notifs)
require.Equal(t, 20, notifs.UnreadCount)
require.Len(t, notifs.Notifications, 20)

updatedNotif, err := client.UpdateInboxNotificationReadStatus(ctx, notifs.Notifications[19].ID.String(), codersdk.UpdateInboxNotificationReadStatusRequest{
IsRead: true,
})
}
require.NoError(t, err)
require.NotNil(t, updatedNotif)
require.NotZero(t, updatedNotif.Notification.ReadAt)
require.Equal(t, 19, updatedNotif.UnreadCount)

updatedNotif, err = client.UpdateInboxNotificationReadStatus(ctx, notifs.Notifications[19].ID.String(), codersdk.UpdateInboxNotificationReadStatusRequest{
IsRead: false,
})
require.NoError(t, err)
require.NotNil(t, updatedNotif)
require.Nil(t, updatedNotif.Notification.ReadAt)
require.Equal(t, 20, updatedNotif.UnreadCount)

notifs, err = client.ListInboxNotifications(ctx, codersdk.ListInboxNotificationsRequest{})
require.NoError(t, err)
require.NotNil(t, notifs)
require.Equal(t, 20, notifs.UnreadCount)
require.Len(t, notifs.Notifications, 20)
})

t.Run("NOK - wrong id", func(t *testing.T) {
t.Parallel()
client, _, api := coderdtest.NewWithAPI(t, &coderdtest.Options{})
firstUser := coderdtest.CreateFirstUser(t, client)
client, member := coderdtest.CreateAnotherUser(t, client, firstUser.OrganizationID)

ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong)
defer cancel()

notifs, err := client.ListInboxNotifications(ctx, codersdk.ListInboxNotificationsRequest{})
require.NoError(t, err)
require.NotNil(t, notifs)
require.Equal(t, 0, notifs.UnreadCount)
require.Empty(t, notifs.Notifications)

for i := range 20 {
dbgen.NotificationInbox(t, api.Database, database.InsertInboxNotificationParams{
ID: uuid.New(),
UserID: member.ID,
TemplateID: notifications.TemplateWorkspaceOutOfMemory,
Title: fmt.Sprintf("Notification %d", i),
Actions: json.RawMessage("[]"),
Content: fmt.Sprintf("Content of the notif %d", i),
CreatedAt: dbtime.Now(),
})
}

notifs, err = client.ListInboxNotifications(ctx, codersdk.ListInboxNotificationsRequest{})
require.NoError(t, err)
require.NotNil(t, notifs)
require.Equal(t, 20, notifs.UnreadCount)
require.Len(t, notifs.Notifications, 20)

updatedNotif, err := client.UpdateInboxNotificationReadStatus(ctx, "xxx-xxx-xxx", codersdk.UpdateInboxNotificationReadStatusRequest{
IsRead: true,
})
require.ErrorContains(t, err, `Invalid UUID "xxx-xxx-xxx"`)
require.Equal(t, 0, updatedNotif.UnreadCount)
require.Empty(t, updatedNotif.Notification)
})
t.Run("NOK - unknown id", func(t *testing.T) {
t.Parallel()
client, _, api := coderdtest.NewWithAPI(t, &coderdtest.Options{})
firstUser := coderdtest.CreateFirstUser(t, client)
client, member := coderdtest.CreateAnotherUser(t, client, firstUser.OrganizationID)

ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong)
defer cancel()

notifs, err := client.ListInboxNotifications(ctx, codersdk.ListInboxNotificationsRequest{})
require.NoError(t, err)
require.NotNil(t, notifs)
require.Equal(t, 0, notifs.UnreadCount)
require.Empty(t, notifs.Notifications)

updatedNotif, err := client.UpdateInboxNotificationReadStatus(ctx, notifs.Notifications[19].ID, codersdk.UpdateInboxNotificationReadStatusRequest{
IsRead: true,
for i := range 20 {
dbgen.NotificationInbox(t, api.Database, database.InsertInboxNotificationParams{
ID: uuid.New(),
UserID: member.ID,
TemplateID: notifications.TemplateWorkspaceOutOfMemory,
Title: fmt.Sprintf("Notification %d", i),
Actions: json.RawMessage("[]"),
Content: fmt.Sprintf("Content of the notif %d", i),
CreatedAt: dbtime.Now(),
})
}

notifs, err = client.ListInboxNotifications(ctx, codersdk.ListInboxNotificationsRequest{})
require.NoError(t, err)
require.NotNil(t, notifs)
require.Equal(t, 20, notifs.UnreadCount)
require.Len(t, notifs.Notifications, 20)

updatedNotif, err := client.UpdateInboxNotificationReadStatus(ctx, failingPaginationUUID.String(), codersdk.UpdateInboxNotificationReadStatusRequest{
IsRead: true,
})
require.ErrorContains(t, err, `Failed to update inbox notification read status`)
require.Equal(t, 0, updatedNotif.UnreadCount)
require.Empty(t, updatedNotif.Notification)
})
require.NoError(t, err)
require.NotNil(t, updatedNotif)
require.NotZero(t, updatedNotif.Notification.ReadAt)
require.Equal(t, 19, updatedNotif.UnreadCount)
}
29 changes: 22 additions & 7 deletions codersdk/inboxnotification.go
Original file line number Diff line number Diff line change
Expand Up @@ -34,10 +34,10 @@ type GetInboxNotificationResponse struct {
}

type ListInboxNotificationsRequest struct {
Targets string `json:"targets,omitempty"`
Templates string `json:"templates,omitempty"`
ReadStatus string `json:"read_status,omitempty" validate:"omitempty,oneof=read unread all"`
StartingBefore uuid.UUID `json:"starting_before,omitempty" validate:"omitempty" format:"uuid"`
Targets string `json:"targets,omitempty"`
Templates string `json:"templates,omitempty"`
ReadStatus string `json:"read_status,omitempty"`
StartingBefore string `json:"starting_before,omitempty"`
}

type ListInboxNotificationsResponse struct {
Expand All @@ -56,13 +56,28 @@ func ListInboxNotificationsRequestToQueryParams(req ListInboxNotificationsReques
if req.ReadStatus != "" {
opts = append(opts, WithQueryParam("read_status", req.ReadStatus))
}
if req.StartingBefore != uuid.Nil {
opts = append(opts, WithQueryParam("starting_before", req.StartingBefore.String()))
if req.StartingBefore != "" {
opts = append(opts, WithQueryParam("starting_before", req.StartingBefore))
}

return opts
}

func (c *Client) WatchInboxNotificationx(ctx context.Context) error {
res, err := c.Request(
ctx, http.MethodGet,
"/api/v2/notifications/watch",
nil, nil,
)
if err != nil {
return err
}

defer res.Body.Close()

return nil
}

func (c *Client) ListInboxNotifications(ctx context.Context, req ListInboxNotificationsRequest) (ListInboxNotificationsResponse, error) {
res, err := c.Request(
ctx, http.MethodGet,
Expand Down Expand Up @@ -91,7 +106,7 @@ type UpdateInboxNotificationReadStatusResponse struct {
UnreadCount int `json:"unread_count"`
}

func (c *Client) UpdateInboxNotificationReadStatus(ctx context.Context, notifID uuid.UUID, req UpdateInboxNotificationReadStatusRequest) (UpdateInboxNotificationReadStatusResponse, error) {
func (c *Client) UpdateInboxNotificationReadStatus(ctx context.Context, notifID string, req UpdateInboxNotificationReadStatusRequest) (UpdateInboxNotificationReadStatusResponse, error) {
res, err := c.Request(
ctx, http.MethodPut,
fmt.Sprintf("/api/v2/notifications/inbox/%v/read-status", notifID),
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

%v in the URL suggests, that it should be possible to access a single notification via REST endpoint /api/v2/notifications/inbox/%v which is not true.

Alternatively, we can change this to /api/v2/notifications/inbox/update: { "notification_ids": [ ... ], "action": "MARK_READ" }.

PS. Ignore this if it is too late to change the API.

Expand Down
Loading
0