diff --git a/backend/pkg/httpserver/get_subscription.go b/backend/pkg/httpserver/get_subscription.go new file mode 100644 index 000000000..8d0cc4176 --- /dev/null +++ b/backend/pkg/httpserver/get_subscription.go @@ -0,0 +1,69 @@ +// 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" + "errors" + "log/slog" + "net/http" + + "github.com/GoogleChrome/webstatus.dev/lib/backendtypes" + "github.com/GoogleChrome/webstatus.dev/lib/gen/openapi/backend" +) + +// nolint:ireturn, revive // Expected ireturn for openapi generation. +func (s *Server) GetSubscription( + ctx context.Context, + request backend.GetSubscriptionRequestObject, +) (backend.GetSubscriptionResponseObject, error) { + userCheck := CheckAuthenticatedUser[backend.GetSubscriptionResponseObject](ctx, "GetSubscription", + func(code int, message string) backend.GetSubscriptionResponseObject { + return backend.GetSubscription500JSONResponse(backend.BasicErrorModel{Code: code, Message: message}) + }) + if userCheck.User == nil { + return userCheck.Response, nil + } + + resp, err := s.wptMetricsStorer.GetSavedSearchSubscription(ctx, userCheck.User.ID, request.SubscriptionId) + if err != nil { + if errors.Is(err, backendtypes.ErrEntityDoesNotExist) { + return backend.GetSubscription404JSONResponse( + backend.BasicErrorModel{ + Code: http.StatusNotFound, + Message: "subscription not found", + }, + ), nil + } else if errors.Is(err, backendtypes.ErrUserNotAuthorizedForAction) { + return backend.GetSubscription403JSONResponse( + backend.BasicErrorModel{ + Code: http.StatusForbidden, + Message: "user not authorized to access this subscription", + }, + ), nil + } + + slog.ErrorContext(ctx, "failed to get subscription", "error", err) + + return backend.GetSubscription500JSONResponse( + backend.BasicErrorModel{ + Code: http.StatusInternalServerError, + Message: "could not get subscription", + }, + ), nil + } + + return backend.GetSubscription200JSONResponse(*resp), nil +} diff --git a/backend/pkg/httpserver/get_subscription_test.go b/backend/pkg/httpserver/get_subscription_test.go new file mode 100644 index 000000000..68c44b182 --- /dev/null +++ b/backend/pkg/httpserver/get_subscription_test.go @@ -0,0 +1,157 @@ +// 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 TestGetSubscription(t *testing.T) { + now := time.Now() + testUser := &auth.User{ + ID: "test-user", + GitHubUserID: nil, + } + + testCases := []struct { + name string + cfg *MockGetSavedSearchSubscriptionConfig + expectedCallCount int + authMiddlewareOption testServerOption + request *http.Request + expectedResponse *http.Response + }{ + { + name: "success", + cfg: &MockGetSavedSearchSubscriptionConfig{ + expectedUserID: "test-user", + expectedSubscriptionID: "sub-id", + output: &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, + }, + err: nil, + }, + expectedCallCount: 1, + authMiddlewareOption: withAuthMiddleware(mockAuthMiddleware(testUser)), + request: httptest.NewRequest( + http.MethodGet, + "/v1/users/me/subscriptions/sub-id", + nil, + ), + expectedResponse: testJSONResponse(http.StatusOK, + `{ + "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)+`"}`), + }, + { + name: "not found", + cfg: &MockGetSavedSearchSubscriptionConfig{ + expectedUserID: "test-user", + expectedSubscriptionID: "sub-id", + output: nil, + err: backendtypes.ErrEntityDoesNotExist, + }, + expectedCallCount: 1, + authMiddlewareOption: withAuthMiddleware(mockAuthMiddleware(testUser)), + request: httptest.NewRequest( + http.MethodGet, + "/v1/users/me/subscriptions/sub-id", + nil, + ), + expectedResponse: testJSONResponse(http.StatusNotFound, `{"code":404,"message":"subscription not found"}`), + }, + { + name: "forbidden - user cannot access subscription", + cfg: &MockGetSavedSearchSubscriptionConfig{ + expectedUserID: "test-user", + expectedSubscriptionID: "sub-id", + output: nil, + err: backendtypes.ErrUserNotAuthorizedForAction, + }, + expectedCallCount: 1, + authMiddlewareOption: withAuthMiddleware(mockAuthMiddleware(testUser)), + request: httptest.NewRequest( + http.MethodGet, + "/v1/users/me/subscriptions/sub-id", + nil, + ), + expectedResponse: testJSONResponse(http.StatusForbidden, + `{"code":403,"message":"user not authorized to access this subscription"}`), + }, + { + name: "internal server error", + cfg: &MockGetSavedSearchSubscriptionConfig{ + expectedUserID: "test-user", + expectedSubscriptionID: "sub-id", + output: nil, + err: errTest, + }, + expectedCallCount: 1, + authMiddlewareOption: withAuthMiddleware(mockAuthMiddleware(testUser)), + request: httptest.NewRequest( + http.MethodGet, + "/v1/users/me/subscriptions/sub-id", + nil, + ), + expectedResponse: testJSONResponse(http.StatusInternalServerError, + `{"code":500,"message":"could not get subscription"}`), + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + //nolint:exhaustruct + mockStorer := &MockWPTMetricsStorer{ + getSavedSearchSubscriptionCfg: 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.callCountGetSavedSearchSubscription, + "GetSavedSearchSubscription", + nil) + }) + } +} diff --git a/backend/pkg/httpserver/server_test.go b/backend/pkg/httpserver/server_test.go index e43ed61cf..e736646a9 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") } +// GetSubscription implements backend.StrictServerInterface. +// nolint: ireturn // WONTFIX - generated method signature +func (m *mockServerInterface) GetSubscription(ctx context.Context, + _ backend.GetSubscriptionRequestObject) ( + backend.GetSubscriptionResponseObject, 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..5ce13ab17 100644 --- a/openapi/backend/openapi.yaml +++ b/openapi/backend/openapi.yaml @@ -1076,7 +1076,43 @@ paths: required: true schema: type: string - # GET operation to retrieve a specific subscription (to be added in a future PR) + # GET operation to retrieve a specific subscription + get: + summary: Get a subscription for a saved search + operationId: getSubscription + security: + - bearerAuth: [] + responses: + '200': + description: OK + content: + application/json: + schema: + $ref: '#/components/schemas/SubscriptionResponse' + '401': + description: Unauthorized + content: + application/json: + schema: + $ref: '#/components/schemas/BasicErrorModel' + '403': + description: Forbidden + content: + application/json: + schema: + $ref: '#/components/schemas/BasicErrorModel' + '404': + description: Not Found + content: + application/json: + schema: + $ref: '#/components/schemas/BasicErrorModel' + '500': + description: Internal Service Error + content: + application/json: + schema: + $ref: '#/components/schemas/BasicErrorModel' # PATCH operation to update a specific subscription (to be added in a future PR) # DELETE operation to delete a specific subscription (to be added in a future PR) components: @@ -1649,7 +1685,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: