Skip to content

Commit 352cf2b

Browse files
authored
feat(api): Add endpoints for notification channels (#2030)
This commit introduces API endpoints for managing user notification channels, addressing the initial scope outlined in issue #1844. The following endpoints have been added under the `/v1/users/me/` prefix, requiring user authentication: - `GET /notification-channels`: Lists all notification channels for the authenticated user. - `GET /notification-channels/{channel_id}`: Retrieves a single notification channel by its ID. - `DELETE /notification-channels/{channel_id}`: Deletes a specific notification channel. Key changes include: - Updating the OpenAPI specification to define the new endpoints. - Implementing the corresponding HTTP handlers in `backend/pkg/httpserver`. - Adding the necessary methods to the `WPTMetricsStorer` interface and its mock implementation. - Including unit tests for the new handlers to ensure correctness and error handling. This implementation lays the groundwork for future enhancements, such as creating and updating notification channels. Fixes #1844
1 parent 090910c commit 352cf2b

File tree

9 files changed

+815
-0
lines changed

9 files changed

+815
-0
lines changed
Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
// Copyright 2025 Google LLC
2+
//
3+
// Licensed under the Apache License, Version 2.0 (the "License");
4+
// you may not use this file except in compliance with the License.
5+
// You may obtain a copy of the License at
6+
//
7+
// http://www.apache.org/licenses/LICENSE-2.0
8+
//
9+
// Unless required by applicable law or agreed to in writing, software
10+
// distributed under the License is distributed on an "AS IS" BASIS,
11+
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
// See the License for the specific language governing permissions and
13+
// limitations under the License.
14+
15+
package httpserver
16+
17+
import (
18+
"context"
19+
"errors"
20+
"net/http"
21+
22+
"github.com/GoogleChrome/webstatus.dev/lib/backendtypes"
23+
"github.com/GoogleChrome/webstatus.dev/lib/gen/openapi/backend"
24+
)
25+
26+
// DeleteNotificationChannel handles the DELETE request to /v1/users/me/notification-channels/{channel_id}.
27+
// nolint:ireturn, revive // Expected ireturn for openapi generation.
28+
func (s *Server) DeleteNotificationChannel(
29+
ctx context.Context,
30+
req backend.DeleteNotificationChannelRequestObject,
31+
) (backend.DeleteNotificationChannelResponseObject, error) {
32+
userCheckResult := CheckAuthenticatedUser(ctx, "DeleteNotificationChannel",
33+
func(code int, message string) backend.DeleteNotificationChannel500JSONResponse {
34+
return backend.DeleteNotificationChannel500JSONResponse{
35+
Code: code,
36+
Message: message,
37+
}
38+
})
39+
if userCheckResult.User == nil {
40+
return userCheckResult.Response, nil
41+
}
42+
43+
err := s.wptMetricsStorer.DeleteNotificationChannel(ctx, userCheckResult.User.ID, req.ChannelId)
44+
if err != nil {
45+
if errors.Is(err, backendtypes.ErrEntityDoesNotExist) || errors.Is(err, backendtypes.ErrUserNotAuthorizedForAction) {
46+
return backend.DeleteNotificationChannel404JSONResponse{
47+
Code: http.StatusNotFound,
48+
Message: "Notification channel not found or not owned by user",
49+
}, nil
50+
}
51+
52+
return backend.DeleteNotificationChannel500JSONResponse{
53+
Code: http.StatusInternalServerError,
54+
Message: "Could not delete notification channel",
55+
}, nil
56+
}
57+
58+
return backend.DeleteNotificationChannel204Response{}, nil
59+
}
Lines changed: 105 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,105 @@
1+
// Copyright 2025 Google LLC
2+
//
3+
// Licensed under the Apache License, Version 2.0 (the "License");
4+
// you may not use this file except in compliance with the License.
5+
// You may obtain a copy of the License at
6+
//
7+
// http://www.apache.org/licenses/LICENSE-2.0
8+
//
9+
// Unless required by applicable law or agreed to in writing, software
10+
// distributed under the License is distributed on an "AS IS" BASIS,
11+
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
// See the License for the specific language governing permissions and
13+
// limitations under the License.
14+
15+
package httpserver
16+
17+
import (
18+
"net/http"
19+
"net/http/httptest"
20+
"testing"
21+
22+
"github.com/GoogleChrome/webstatus.dev/lib/auth"
23+
"github.com/GoogleChrome/webstatus.dev/lib/backendtypes"
24+
)
25+
26+
func TestDeleteNotificationChannel(t *testing.T) {
27+
testUser := &auth.User{
28+
ID: "listUserID1",
29+
GitHubUserID: nil,
30+
}
31+
testCases := []struct {
32+
name string
33+
cfg *MockDeleteNotificationChannelConfig
34+
expectedCallCount int
35+
authMiddlewareOption testServerOption
36+
request *http.Request
37+
expectedResponse *http.Response
38+
}{
39+
{
40+
name: "success",
41+
cfg: &MockDeleteNotificationChannelConfig{
42+
expectedUserID: "listUserID1",
43+
expectedChannelID: "channel1",
44+
err: nil,
45+
},
46+
expectedCallCount: 1,
47+
authMiddlewareOption: withAuthMiddleware(mockAuthMiddleware(testUser)),
48+
request: httptest.NewRequest(http.MethodDelete, "/v1/users/me/notification-channels/channel1", nil),
49+
expectedResponse: createEmptyBodyResponse(http.StatusNoContent),
50+
},
51+
{
52+
name: "not found",
53+
cfg: &MockDeleteNotificationChannelConfig{
54+
expectedUserID: "listUserID1",
55+
expectedChannelID: "channel1",
56+
err: backendtypes.ErrEntityDoesNotExist,
57+
},
58+
expectedCallCount: 1,
59+
authMiddlewareOption: withAuthMiddleware(mockAuthMiddleware(testUser)),
60+
request: httptest.NewRequest(http.MethodDelete, "/v1/users/me/notification-channels/channel1", nil),
61+
expectedResponse: testJSONResponse(404, `
62+
{
63+
"code":404,
64+
"message":"Notification channel not found or not owned by user"
65+
}`),
66+
},
67+
{
68+
name: "500 error",
69+
cfg: &MockDeleteNotificationChannelConfig{
70+
expectedUserID: "listUserID1",
71+
expectedChannelID: "channel1",
72+
err: errTest,
73+
},
74+
expectedCallCount: 1,
75+
authMiddlewareOption: withAuthMiddleware(mockAuthMiddleware(testUser)),
76+
request: httptest.NewRequest(http.MethodDelete, "/v1/users/me/notification-channels/channel1", nil),
77+
expectedResponse: testJSONResponse(500, `
78+
{
79+
"code":500,
80+
"message":"Could not delete notification channel"
81+
}`),
82+
},
83+
}
84+
85+
for _, tc := range testCases {
86+
t.Run(tc.name, func(t *testing.T) {
87+
//nolint:exhaustruct
88+
mockStorer := &MockWPTMetricsStorer{
89+
deleteNotificationChannelCfg: tc.cfg,
90+
t: t,
91+
}
92+
myServer := Server{
93+
wptMetricsStorer: mockStorer,
94+
baseURL: getTestBaseURL(t),
95+
metadataStorer: nil,
96+
operationResponseCaches: nil,
97+
userGitHubClientFactory: nil,
98+
}
99+
assertTestServerRequest(t, &myServer, tc.request, tc.expectedResponse,
100+
[]testServerOption{tc.authMiddlewareOption}...)
101+
assertMocksExpectations(t, tc.expectedCallCount, mockStorer.callCountDeleteNotificationChannel,
102+
"DeleteNotificationChannel", nil)
103+
})
104+
}
105+
}
Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
// Copyright 2025 Google LLC
2+
//
3+
// Licensed under the Apache License, Version 2.0 (the "License");
4+
// you may not use this file except in compliance with the License.
5+
// You may obtain a copy of the License at
6+
//
7+
// http://www.apache.org/licenses/LICENSE-2.0
8+
//
9+
// Unless required by applicable law or agreed to in writing, software
10+
// distributed under the License is distributed on an "AS IS" BASIS,
11+
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
// See the License for the specific language governing permissions and
13+
// limitations under the License.
14+
15+
package httpserver
16+
17+
import (
18+
"context"
19+
"errors"
20+
"net/http"
21+
22+
"github.com/GoogleChrome/webstatus.dev/lib/backendtypes"
23+
"github.com/GoogleChrome/webstatus.dev/lib/gen/openapi/backend"
24+
)
25+
26+
// GetNotificationChannel handles the GET request to /v1/users/me/notification-channels/{channel_id}.
27+
// nolint:ireturn, revive // Expected ireturn for openapi generation.
28+
func (s *Server) GetNotificationChannel(
29+
ctx context.Context,
30+
req backend.GetNotificationChannelRequestObject,
31+
) (backend.GetNotificationChannelResponseObject, error) {
32+
userCheckResult := CheckAuthenticatedUser(ctx, "GetNotificationChannel",
33+
func(code int, message string) backend.GetNotificationChannel500JSONResponse {
34+
return backend.GetNotificationChannel500JSONResponse{
35+
Code: code,
36+
Message: message,
37+
}
38+
})
39+
if userCheckResult.User == nil {
40+
return userCheckResult.Response, nil
41+
}
42+
43+
channel, err := s.wptMetricsStorer.GetNotificationChannel(ctx, userCheckResult.User.ID, req.ChannelId)
44+
if err != nil {
45+
if errors.Is(err, backendtypes.ErrEntityDoesNotExist) || errors.Is(err, backendtypes.ErrUserNotAuthorizedForAction) {
46+
return backend.GetNotificationChannel404JSONResponse{
47+
Code: http.StatusNotFound,
48+
Message: "Notification channel not found or not owned by user",
49+
}, nil
50+
}
51+
52+
return backend.GetNotificationChannel500JSONResponse{
53+
Code: http.StatusInternalServerError,
54+
Message: "Could not retrieve notification channel",
55+
}, nil
56+
}
57+
58+
return backend.GetNotificationChannel200JSONResponse(*channel), nil
59+
}
Lines changed: 128 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,128 @@
1+
// Copyright 2025 Google LLC
2+
//
3+
// Licensed under the Apache License, Version 2.0 (the "License");
4+
// you may not use this file except in compliance with the License.
5+
// You may obtain a copy of the License at
6+
//
7+
// http://www.apache.org/licenses/LICENSE-2.0
8+
//
9+
// Unless required by applicable law or agreed to in writing, software
10+
// distributed under the License is distributed on an "AS IS" BASIS,
11+
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
// See the License for the specific language governing permissions and
13+
// limitations under the License.
14+
15+
package httpserver
16+
17+
import (
18+
"net/http"
19+
"net/http/httptest"
20+
"testing"
21+
"time"
22+
23+
"github.com/GoogleChrome/webstatus.dev/lib/auth"
24+
"github.com/GoogleChrome/webstatus.dev/lib/backendtypes"
25+
"github.com/GoogleChrome/webstatus.dev/lib/gen/openapi/backend"
26+
)
27+
28+
func TestGetNotificationChannel(t *testing.T) {
29+
testUser := &auth.User{
30+
ID: "listUserID1",
31+
GitHubUserID: nil,
32+
}
33+
testCases := []struct {
34+
name string
35+
cfg *MockGetNotificationChannelConfig
36+
expectedCallCount int
37+
authMiddlewareOption testServerOption
38+
request *http.Request
39+
expectedResponse *http.Response
40+
}{
41+
{
42+
name: "success",
43+
cfg: &MockGetNotificationChannelConfig{
44+
expectedUserID: "listUserID1",
45+
expectedChannelID: "channel1",
46+
output: &backend.NotificationChannelResponse{
47+
48+
Id: "channel1",
49+
Name: "My Email",
50+
Type: backend.NotificationChannelResponseTypeEmail,
51+
52+
Status: backend.NotificationChannelStatusEnabled,
53+
CreatedAt: time.Date(2000, time.January, 1, 0, 0, 0, 0, time.UTC),
54+
UpdatedAt: time.Date(2000, time.January, 1, 0, 0, 0, 0, time.UTC),
55+
},
56+
err: nil,
57+
},
58+
expectedCallCount: 1,
59+
authMiddlewareOption: withAuthMiddleware(mockAuthMiddleware(testUser)),
60+
request: httptest.NewRequest(http.MethodGet, "/v1/users/me/notification-channels/channel1", nil),
61+
expectedResponse: testJSONResponse(200, `
62+
{
63+
"id": "channel1",
64+
"name": "My Email",
65+
"type": "email",
66+
"value": "[email protected]",
67+
"status": "enabled",
68+
"created_at":"2000-01-01T00:00:00Z",
69+
"updated_at":"2000-01-01T00:00:00Z"
70+
}`),
71+
},
72+
{
73+
name: "not found",
74+
cfg: &MockGetNotificationChannelConfig{
75+
expectedUserID: "listUserID1",
76+
expectedChannelID: "channel1",
77+
output: nil,
78+
err: backendtypes.ErrEntityDoesNotExist,
79+
},
80+
expectedCallCount: 1,
81+
authMiddlewareOption: withAuthMiddleware(mockAuthMiddleware(testUser)),
82+
request: httptest.NewRequest(http.MethodGet, "/v1/users/me/notification-channels/channel1", nil),
83+
expectedResponse: testJSONResponse(404, `
84+
{
85+
"code":404,
86+
"message":"Notification channel not found or not owned by user"
87+
}`),
88+
},
89+
{
90+
name: "500 error",
91+
cfg: &MockGetNotificationChannelConfig{
92+
expectedUserID: "listUserID1",
93+
expectedChannelID: "channel1",
94+
output: nil,
95+
err: errTest,
96+
},
97+
expectedCallCount: 1,
98+
authMiddlewareOption: withAuthMiddleware(mockAuthMiddleware(testUser)),
99+
request: httptest.NewRequest(http.MethodGet, "/v1/users/me/notification-channels/channel1", nil),
100+
expectedResponse: testJSONResponse(500, `
101+
{
102+
"code":500,
103+
"message":"Could not retrieve notification channel"
104+
}`),
105+
},
106+
}
107+
108+
for _, tc := range testCases {
109+
t.Run(tc.name, func(t *testing.T) {
110+
//nolint:exhaustruct
111+
mockStorer := &MockWPTMetricsStorer{
112+
getNotificationChannelCfg: tc.cfg,
113+
t: t,
114+
}
115+
myServer := Server{
116+
wptMetricsStorer: mockStorer,
117+
baseURL: getTestBaseURL(t),
118+
metadataStorer: nil,
119+
operationResponseCaches: nil,
120+
userGitHubClientFactory: nil,
121+
}
122+
assertTestServerRequest(t, &myServer, tc.request, tc.expectedResponse,
123+
[]testServerOption{tc.authMiddlewareOption}...)
124+
assertMocksExpectations(t, tc.expectedCallCount, mockStorer.callCountGetNotificationChannel,
125+
"GetNotificationChannel", nil)
126+
})
127+
}
128+
}

0 commit comments

Comments
 (0)