Skip to content

Commit 08dbbc8

Browse files
committed
feat(httpserver): add list subscriptions endpoint
This change introduces a new HTTP endpoint to list user subscriptions. It includes the necessary request handling, response formatting, and unit tests to ensure correct functionality. The endpoint supports pagination.
1 parent 0904a62 commit 08dbbc8

File tree

4 files changed

+293
-2
lines changed

4 files changed

+293
-2
lines changed
Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
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+
"log/slog"
20+
21+
"github.com/GoogleChrome/webstatus.dev/lib/gen/openapi/backend"
22+
)
23+
24+
// nolint:ireturn, revive // Expected ireturn for openapi generation.
25+
func (s *Server) ListSubscriptions(
26+
ctx context.Context,
27+
request backend.ListSubscriptionsRequestObject,
28+
) (backend.ListSubscriptionsResponseObject, error) {
29+
userCheck := CheckAuthenticatedUser[backend.ListSubscriptionsResponseObject](ctx, "ListSubscriptions",
30+
func(code int, message string) backend.ListSubscriptionsResponseObject {
31+
return backend.ListSubscriptions500JSONResponse(backend.BasicErrorModel{Code: code, Message: message})
32+
})
33+
if userCheck.User == nil {
34+
return userCheck.Response, nil
35+
}
36+
37+
resp, err := s.wptMetricsStorer.ListSavedSearchSubscriptions(
38+
ctx, userCheck.User.ID, getPageSizeOrDefault(request.Params.PageSize), request.Params.PageToken)
39+
if err != nil {
40+
slog.ErrorContext(ctx, "unable to get page of subscriptions", "error", err)
41+
42+
return backend.ListSubscriptions500JSONResponse{
43+
Code: 500,
44+
Message: "could not list subscriptions",
45+
}, nil
46+
}
47+
48+
return backend.ListSubscriptions200JSONResponse(*resp), nil
49+
}
Lines changed: 194 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,194 @@
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 TestListSubscriptions(t *testing.T) {
29+
now := time.Now()
30+
testUser := &auth.User{
31+
ID: "test-user",
32+
GitHubUserID: nil,
33+
}
34+
testCases := []struct {
35+
name string
36+
cfg *MockListSavedSearchSubscriptionsConfig
37+
expectedCallCount int
38+
authMiddlewareOption testServerOption
39+
request *http.Request
40+
expectedResponse *http.Response
41+
}{
42+
{
43+
name: "success",
44+
cfg: &MockListSavedSearchSubscriptionsConfig{
45+
expectedUserID: "test-user",
46+
expectedPageSize: 100,
47+
expectedPageToken: nil,
48+
output: &backend.SubscriptionPage{
49+
Data: &[]backend.SubscriptionResponse{
50+
{
51+
Id: "sub-id",
52+
ChannelId: "channel-id",
53+
SavedSearchId: "search-id",
54+
Triggers: []backend.SubscriptionTriggerResponseItem{
55+
{
56+
Value: backendtypes.AttemptToStoreSubscriptionTrigger("trigger"),
57+
RawValue: nil,
58+
},
59+
},
60+
Frequency: "daily",
61+
CreatedAt: now,
62+
UpdatedAt: now,
63+
},
64+
},
65+
Metadata: &backend.PageMetadata{
66+
NextPageToken: nil,
67+
},
68+
},
69+
err: nil,
70+
},
71+
expectedCallCount: 1,
72+
request: httptest.NewRequest(
73+
http.MethodGet,
74+
"/v1/users/me/subscriptions",
75+
nil,
76+
),
77+
expectedResponse: testJSONResponse(http.StatusOK,
78+
`{
79+
"data":[
80+
{
81+
"id":"sub-id",
82+
"channel_id":"channel-id",
83+
"saved_search_id":"search-id",
84+
"triggers":[{"value":"trigger"}],
85+
"frequency":"daily",
86+
"created_at":"`+now.Format(time.RFC3339Nano)+`",
87+
"updated_at":"`+now.Format(time.RFC3339Nano)+`"
88+
}
89+
],
90+
"metadata":{}}`),
91+
authMiddlewareOption: withAuthMiddleware(mockAuthMiddleware(testUser)),
92+
},
93+
{
94+
name: "internal server error",
95+
cfg: &MockListSavedSearchSubscriptionsConfig{
96+
expectedUserID: "test-user",
97+
expectedPageSize: 100,
98+
expectedPageToken: nil,
99+
output: nil,
100+
err: errTest,
101+
},
102+
expectedCallCount: 1,
103+
request: httptest.NewRequest(
104+
http.MethodGet,
105+
"/v1/users/me/subscriptions",
106+
nil,
107+
),
108+
expectedResponse: testJSONResponse(http.StatusInternalServerError,
109+
`{
110+
"code":500,
111+
"message":"could not list subscriptions"
112+
}`),
113+
authMiddlewareOption: withAuthMiddleware(mockAuthMiddleware(testUser)),
114+
},
115+
{
116+
name: "success with page token and size",
117+
cfg: &MockListSavedSearchSubscriptionsConfig{
118+
expectedUserID: "test-user",
119+
expectedPageSize: 50,
120+
expectedPageToken: valuePtr("page-token"),
121+
err: nil,
122+
output: &backend.SubscriptionPage{
123+
Data: &[]backend.SubscriptionResponse{
124+
{
125+
Id: "sub-id-2",
126+
ChannelId: "channel-id-2",
127+
SavedSearchId: "search-id-2",
128+
Triggers: []backend.SubscriptionTriggerResponseItem{
129+
{
130+
Value: backendtypes.AttemptToStoreSubscriptionTrigger("trigger-2"),
131+
RawValue: nil,
132+
},
133+
},
134+
Frequency: "weekly",
135+
CreatedAt: now,
136+
UpdatedAt: now,
137+
},
138+
},
139+
Metadata: &backend.PageMetadata{
140+
NextPageToken: valuePtr("next-page-token"),
141+
},
142+
},
143+
},
144+
expectedCallCount: 1,
145+
request: httptest.NewRequest(
146+
http.MethodGet,
147+
"/v1/users/me/subscriptions?page_size=50&page_token=page-token",
148+
nil,
149+
),
150+
expectedResponse: testJSONResponse(http.StatusOK,
151+
`{
152+
"data":[
153+
{
154+
"id":"sub-id-2",
155+
"channel_id":"channel-id-2",
156+
"saved_search_id":"search-id-2",
157+
"triggers":[{"value":"trigger-2"}],
158+
"frequency":"weekly",
159+
"created_at":"`+now.Format(time.RFC3339Nano)+`",
160+
"updated_at":"`+now.Format(time.RFC3339Nano)+`"
161+
}
162+
],
163+
"metadata":{
164+
"next_page_token":"next-page-token"
165+
}
166+
}`),
167+
authMiddlewareOption: withAuthMiddleware(mockAuthMiddleware(testUser)),
168+
},
169+
}
170+
171+
for _, tc := range testCases {
172+
t.Run(tc.name, func(t *testing.T) {
173+
//nolint:exhaustruct
174+
mockStorer := &MockWPTMetricsStorer{
175+
listSavedSearchSubscriptionsCfg: tc.cfg,
176+
t: t,
177+
}
178+
myServer := Server{
179+
wptMetricsStorer: mockStorer,
180+
metadataStorer: nil,
181+
userGitHubClientFactory: nil,
182+
operationResponseCaches: nil,
183+
baseURL: getTestBaseURL(t),
184+
}
185+
assertTestServerRequest(t, &myServer, tc.request, tc.expectedResponse, tc.authMiddlewareOption)
186+
assertMocksExpectations(t,
187+
tc.expectedCallCount,
188+
mockStorer.callCountListSavedSearchSubscriptions,
189+
"ListSavedSearchSubscriptions",
190+
nil)
191+
})
192+
}
193+
194+
}

backend/pkg/httpserver/server_test.go

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1235,6 +1235,16 @@ func (m *mockServerInterface) ListMissingOneImplementationFeatures(ctx context.C
12351235
panic("unimplemented")
12361236
}
12371237

1238+
// ListSubscriptions implements backend.StrictServerInterface.
1239+
// nolint: ireturn // WONTFIX - generated method signature
1240+
func (m *mockServerInterface) ListSubscriptions(ctx context.Context,
1241+
_ backend.ListSubscriptionsRequestObject) (
1242+
backend.ListSubscriptionsResponseObject, error) {
1243+
assertUserInCtx(ctx, m.t, m.expectedUserInCtx)
1244+
m.callCount++
1245+
panic("unimplemented")
1246+
}
1247+
12381248
func (m *mockServerInterface) assertCallCount(expectedCallCount int) {
12391249
if m.callCount != expectedCallCount {
12401250
m.t.Errorf("expected mock server to be used %d times. only used %d times", expectedCallCount, m.callCount)

openapi/backend/openapi.yaml

Lines changed: 40 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1066,7 +1066,46 @@ paths:
10661066
/v1/users/me/subscriptions:
10671067
description: Operations for managing user subscriptions to saved searches.
10681068
# POST operation to create a new subscription (to be added in a future PR)
1069-
# GET operation to list user's subscriptions (to be added in a future PR)
1069+
# GET operation to list user's subscriptions
1070+
get:
1071+
summary: List subscriptions for a saved search
1072+
operationId: listSubscriptions
1073+
security:
1074+
- bearerAuth: []
1075+
parameters:
1076+
- $ref: '#/components/parameters/paginationTokenParam'
1077+
- $ref: '#/components/parameters/paginationSizeParam'
1078+
responses:
1079+
'200':
1080+
description: OK
1081+
content:
1082+
application/json:
1083+
schema:
1084+
$ref: '#/components/schemas/SubscriptionPage'
1085+
'400':
1086+
description: Bad Input
1087+
content:
1088+
application/json:
1089+
schema:
1090+
$ref: '#/components/schemas/BasicErrorModel'
1091+
'401':
1092+
description: Unauthorized
1093+
content:
1094+
application/json:
1095+
schema:
1096+
$ref: '#/components/schemas/BasicErrorModel'
1097+
'403':
1098+
description: Forbidden
1099+
content:
1100+
application/json:
1101+
schema:
1102+
$ref: '#/components/schemas/BasicErrorModel'
1103+
'500':
1104+
description: Internal Service Error
1105+
content:
1106+
application/json:
1107+
schema:
1108+
$ref: '#/components/schemas/BasicErrorModel'
10701109
/v1/users/me/subscriptions/{subscription_id}:
10711110
description: Operations for managing a specific user subscription.
10721111
parameters:
@@ -1649,7 +1688,6 @@ components:
16491688
description: >
16501689
The original, raw value from the database. Only present if the 'value'
16511690
is 'unknown', allowing clients to identify and manage deprecated triggers.
1652-
nullable: true
16531691
required:
16541692
- value
16551693
SubscriptionBase:

0 commit comments

Comments
 (0)