diff --git a/backend/pkg/httpserver/list_subscriptions.go b/backend/pkg/httpserver/list_subscriptions.go new file mode 100644 index 000000000..13be68f72 --- /dev/null +++ b/backend/pkg/httpserver/list_subscriptions.go @@ -0,0 +1,49 @@ +// Copyright 2025 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package httpserver + +import ( + "context" + "log/slog" + + "github.com/GoogleChrome/webstatus.dev/lib/gen/openapi/backend" +) + +// nolint:ireturn, revive // Expected ireturn for openapi generation. +func (s *Server) ListSubscriptions( + ctx context.Context, + request backend.ListSubscriptionsRequestObject, +) (backend.ListSubscriptionsResponseObject, error) { + userCheck := CheckAuthenticatedUser[backend.ListSubscriptionsResponseObject](ctx, "ListSubscriptions", + func(code int, message string) backend.ListSubscriptionsResponseObject { + return backend.ListSubscriptions500JSONResponse(backend.BasicErrorModel{Code: code, Message: message}) + }) + if userCheck.User == nil { + return userCheck.Response, nil + } + + resp, err := s.wptMetricsStorer.ListSavedSearchSubscriptions( + ctx, userCheck.User.ID, getPageSizeOrDefault(request.Params.PageSize), request.Params.PageToken) + if err != nil { + slog.ErrorContext(ctx, "unable to get page of subscriptions", "error", err) + + return backend.ListSubscriptions500JSONResponse{ + Code: 500, + Message: "could not list subscriptions", + }, nil + } + + return backend.ListSubscriptions200JSONResponse(*resp), nil +} diff --git a/backend/pkg/httpserver/list_subscriptions_test.go b/backend/pkg/httpserver/list_subscriptions_test.go new file mode 100644 index 000000000..c534e9fc1 --- /dev/null +++ b/backend/pkg/httpserver/list_subscriptions_test.go @@ -0,0 +1,194 @@ +// Copyright 2025 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package httpserver + +import ( + "net/http" + "net/http/httptest" + "testing" + "time" + + "github.com/GoogleChrome/webstatus.dev/lib/auth" + "github.com/GoogleChrome/webstatus.dev/lib/backendtypes" + "github.com/GoogleChrome/webstatus.dev/lib/gen/openapi/backend" +) + +func TestListSubscriptions(t *testing.T) { + now := time.Now() + testUser := &auth.User{ + ID: "test-user", + GitHubUserID: nil, + } + testCases := []struct { + name string + cfg *MockListSavedSearchSubscriptionsConfig + expectedCallCount int + authMiddlewareOption testServerOption + request *http.Request + expectedResponse *http.Response + }{ + { + name: "success", + cfg: &MockListSavedSearchSubscriptionsConfig{ + expectedUserID: "test-user", + expectedPageSize: 100, + expectedPageToken: nil, + output: &backend.SubscriptionPage{ + Data: &[]backend.SubscriptionResponse{ + { + Id: "sub-id", + ChannelId: "channel-id", + SavedSearchId: "search-id", + Triggers: []backend.SubscriptionTriggerResponseItem{ + { + Value: backendtypes.AttemptToStoreSubscriptionTrigger("trigger"), + RawValue: nil, + }, + }, + Frequency: "daily", + CreatedAt: now, + UpdatedAt: now, + }, + }, + Metadata: &backend.PageMetadata{ + NextPageToken: nil, + }, + }, + err: nil, + }, + expectedCallCount: 1, + request: httptest.NewRequest( + http.MethodGet, + "/v1/users/me/subscriptions", + nil, + ), + expectedResponse: testJSONResponse(http.StatusOK, + `{ + "data":[ + { + "id":"sub-id", + "channel_id":"channel-id", + "saved_search_id":"search-id", + "triggers":[{"value":"trigger"}], + "frequency":"daily", + "created_at":"`+now.Format(time.RFC3339Nano)+`", + "updated_at":"`+now.Format(time.RFC3339Nano)+`" + } + ], + "metadata":{}}`), + authMiddlewareOption: withAuthMiddleware(mockAuthMiddleware(testUser)), + }, + { + name: "internal server error", + cfg: &MockListSavedSearchSubscriptionsConfig{ + expectedUserID: "test-user", + expectedPageSize: 100, + expectedPageToken: nil, + output: nil, + err: errTest, + }, + expectedCallCount: 1, + request: httptest.NewRequest( + http.MethodGet, + "/v1/users/me/subscriptions", + nil, + ), + expectedResponse: testJSONResponse(http.StatusInternalServerError, + `{ + "code":500, + "message":"could not list subscriptions" + }`), + authMiddlewareOption: withAuthMiddleware(mockAuthMiddleware(testUser)), + }, + { + name: "success with page token and size", + cfg: &MockListSavedSearchSubscriptionsConfig{ + expectedUserID: "test-user", + expectedPageSize: 50, + expectedPageToken: valuePtr("page-token"), + err: nil, + output: &backend.SubscriptionPage{ + Data: &[]backend.SubscriptionResponse{ + { + Id: "sub-id-2", + ChannelId: "channel-id-2", + SavedSearchId: "search-id-2", + Triggers: []backend.SubscriptionTriggerResponseItem{ + { + Value: backendtypes.AttemptToStoreSubscriptionTrigger("trigger-2"), + RawValue: nil, + }, + }, + Frequency: "weekly", + CreatedAt: now, + UpdatedAt: now, + }, + }, + Metadata: &backend.PageMetadata{ + NextPageToken: valuePtr("next-page-token"), + }, + }, + }, + expectedCallCount: 1, + request: httptest.NewRequest( + http.MethodGet, + "/v1/users/me/subscriptions?page_size=50&page_token=page-token", + nil, + ), + expectedResponse: testJSONResponse(http.StatusOK, + `{ + "data":[ + { + "id":"sub-id-2", + "channel_id":"channel-id-2", + "saved_search_id":"search-id-2", + "triggers":[{"value":"trigger-2"}], + "frequency":"weekly", + "created_at":"`+now.Format(time.RFC3339Nano)+`", + "updated_at":"`+now.Format(time.RFC3339Nano)+`" + } + ], + "metadata":{ + "next_page_token":"next-page-token" + } + }`), + authMiddlewareOption: withAuthMiddleware(mockAuthMiddleware(testUser)), + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + //nolint:exhaustruct + mockStorer := &MockWPTMetricsStorer{ + listSavedSearchSubscriptionsCfg: tc.cfg, + t: t, + } + myServer := Server{ + wptMetricsStorer: mockStorer, + metadataStorer: nil, + userGitHubClientFactory: nil, + operationResponseCaches: nil, + baseURL: getTestBaseURL(t), + } + assertTestServerRequest(t, &myServer, tc.request, tc.expectedResponse, tc.authMiddlewareOption) + assertMocksExpectations(t, + tc.expectedCallCount, + mockStorer.callCountListSavedSearchSubscriptions, + "ListSavedSearchSubscriptions", + nil) + }) + } + +} diff --git a/backend/pkg/httpserver/server_test.go b/backend/pkg/httpserver/server_test.go index e43ed61cf..b9a542865 100644 --- a/backend/pkg/httpserver/server_test.go +++ b/backend/pkg/httpserver/server_test.go @@ -1235,6 +1235,16 @@ func (m *mockServerInterface) ListMissingOneImplementationFeatures(ctx context.C panic("unimplemented") } +// ListSubscriptions implements backend.StrictServerInterface. +// nolint: ireturn // WONTFIX - generated method signature +func (m *mockServerInterface) ListSubscriptions(ctx context.Context, + _ backend.ListSubscriptionsRequestObject) ( + backend.ListSubscriptionsResponseObject, error) { + assertUserInCtx(ctx, m.t, m.expectedUserInCtx) + m.callCount++ + panic("unimplemented") +} + func (m *mockServerInterface) assertCallCount(expectedCallCount int) { if m.callCount != expectedCallCount { m.t.Errorf("expected mock server to be used %d times. only used %d times", expectedCallCount, m.callCount) diff --git a/openapi/backend/openapi.yaml b/openapi/backend/openapi.yaml index 2b051be05..cd654d44a 100644 --- a/openapi/backend/openapi.yaml +++ b/openapi/backend/openapi.yaml @@ -1066,7 +1066,46 @@ paths: /v1/users/me/subscriptions: description: Operations for managing user subscriptions to saved searches. # POST operation to create a new subscription (to be added in a future PR) - # GET operation to list user's subscriptions (to be added in a future PR) + # GET operation to list user's subscriptions + get: + summary: List subscriptions for a saved search + operationId: listSubscriptions + security: + - bearerAuth: [] + parameters: + - $ref: '#/components/parameters/paginationTokenParam' + - $ref: '#/components/parameters/paginationSizeParam' + responses: + '200': + description: OK + content: + application/json: + schema: + $ref: '#/components/schemas/SubscriptionPage' + '400': + description: Bad Input + content: + application/json: + schema: + $ref: '#/components/schemas/BasicErrorModel' + '401': + description: Unauthorized + content: + application/json: + schema: + $ref: '#/components/schemas/BasicErrorModel' + '403': + description: Forbidden + content: + application/json: + schema: + $ref: '#/components/schemas/BasicErrorModel' + '500': + description: Internal Service Error + content: + application/json: + schema: + $ref: '#/components/schemas/BasicErrorModel' /v1/users/me/subscriptions/{subscription_id}: description: Operations for managing a specific user subscription. parameters: @@ -1649,7 +1688,6 @@ components: description: > The original, raw value from the database. Only present if the 'value' is 'unknown', allowing clients to identify and manage deprecated triggers. - nullable: true required: - value SubscriptionBase: