diff --git a/.dev/mailpit/Dockerfile b/.dev/mailpit/Dockerfile new file mode 100644 index 000000000..6e79e1309 --- /dev/null +++ b/.dev/mailpit/Dockerfile @@ -0,0 +1,18 @@ +# 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. + +FROM axllent/mailpit:v1.28.0 + +EXPOSE 1025 +EXPOSE 8025 diff --git a/.dev/mailpit/manifests/pod.yaml b/.dev/mailpit/manifests/pod.yaml new file mode 100644 index 000000000..e96ec3a17 --- /dev/null +++ b/.dev/mailpit/manifests/pod.yaml @@ -0,0 +1,41 @@ +# 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 + +# https://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. + +apiVersion: v1 +kind: Pod +metadata: + name: mailpit + labels: + app.kubernetes.io/name: mailpit +spec: + containers: + - name: mailpit + image: mailpit + imagePullPolicy: Never # Need this for pushing directly into minikube + ports: + - containerPort: 1025 + name: smtp-port + - containerPort: 8025 + name: web-ui-port + readinessProbe: + tcpSocket: + port: 1025 + initialDelaySeconds: 10 + resources: + limits: + cpu: 250m + memory: 128Mi + requests: + cpu: 100m + memory: 64Mi diff --git a/.dev/mailpit/manifests/service.yaml b/.dev/mailpit/manifests/service.yaml new file mode 100644 index 000000000..3e1666611 --- /dev/null +++ b/.dev/mailpit/manifests/service.yaml @@ -0,0 +1,30 @@ +# 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 + +# https://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. + +apiVersion: v1 +kind: Service +metadata: + name: mailpit +spec: + selector: + app.kubernetes.io/name: mailpit + ports: + - name: smtp + protocol: TCP + port: 1025 + targetPort: smtp-port + - name: web-ui + protocol: TCP + port: 8025 + targetPort: web-ui-port diff --git a/.dev/mailpit/skaffold.yaml b/.dev/mailpit/skaffold.yaml new file mode 100644 index 000000000..62e1da612 --- /dev/null +++ b/.dev/mailpit/skaffold.yaml @@ -0,0 +1,31 @@ +# 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. + +apiVersion: skaffold/v4beta9 +kind: Config +metadata: + name: mailpit-config +profiles: + - name: local + build: + artifacts: + - image: mailpit + context: . + local: + useBuildkit: true + manifests: + rawYaml: + - manifests/* + deploy: + kubectl: {} diff --git a/DEVELOPMENT.md b/DEVELOPMENT.md index 55689c9d8..178a7737b 100644 --- a/DEVELOPMENT.md +++ b/DEVELOPMENT.md @@ -93,7 +93,7 @@ The above skaffold command deploys multiple resources: | valkey | Valkey | N/A | valkey:6379 | | auth | Auth Emulator | http://localhost:9099
http://localhost:9100/auth (ui) | http://auth:9099
http://auth:9100/auth (ui) | | wiremock | Wiremock | http://localhost:8087 | http://api-github-mock.default.svc.cluster.local:8080 (GitHub Mock) | -| pubsub | Pub/Sub Emulator | N/A | http://pubsub:8060 | +| pubsub | Pub/Sub Emulator | http://localhost:8060 | http://pubsub:8060 | | gcs | GCS Emulator | N/A | http://gcs:4443 | _In the event the servers are not responsive, make a temporary change to a file_ diff --git a/Makefile b/Makefile index 0410bfc46..aa25edd17 100644 --- a/Makefile +++ b/Makefile @@ -89,6 +89,8 @@ check-local-ports: $(call wait_for_port,9010,spanner) $(call wait_for_port,8086,datastore) $(call wait_for_port,8087,wiremock) + $(call wait_for_port,8060,pubsub) + $(call wait_for_port,8025,mailpit) port-forward-manual: port-forward-terminate @@ -98,6 +100,8 @@ port-forward-manual: port-forward-terminate kubectl wait --for=condition=ready pod/datastore kubectl wait --for=condition=ready pod/spanner kubectl wait --for=condition=ready pod/wiremock + kubectl wait --for=condition=ready pod/pubsub + kubectl wait --for=condition=ready pod/mailpit kubectl port-forward --address 127.0.0.1 pod/frontend 5555:5555 2>&1 >/dev/null & kubectl port-forward --address 127.0.0.1 pod/backend 8080:8080 2>&1 >/dev/null & kubectl port-forward --address 127.0.0.1 pod/auth 9099:9099 2>&1 >/dev/null & @@ -105,6 +109,8 @@ port-forward-manual: port-forward-terminate kubectl port-forward --address 127.0.0.1 pod/spanner 9010:9010 2>&1 >/dev/null & kubectl port-forward --address 127.0.0.1 pod/datastore 8086:8086 2>&1 >/dev/null & kubectl port-forward --address 127.0.0.1 pod/wiremock 8087:8080 2>&1 >/dev/null & + kubectl port-forward --address 127.0.0.1 pod/pubsub 8060:8060 2>&1 >/dev/null & + kubectl port-forward --address 127.0.0.1 pod/mailpit 8025:8025 2>&1 >/dev/null & make check-local-ports port-forward-terminate: @@ -115,6 +121,8 @@ port-forward-terminate: fuser -k 9010/tcp || true fuser -k 8086/tcp || true fuser -k 8087/tcp || true + fuser -k 8060/tcp || true + fuser -k 8025/tcp || true # Prerequisite target to start minikube if necessary minikube-running: diff --git a/backend/cmd/server/main.go b/backend/cmd/server/main.go index efe66f401..e8a670f5e 100644 --- a/backend/cmd/server/main.go +++ b/backend/cmd/server/main.go @@ -28,6 +28,8 @@ import ( "github.com/GoogleChrome/webstatus.dev/backend/pkg/httpserver" "github.com/GoogleChrome/webstatus.dev/lib/auth" "github.com/GoogleChrome/webstatus.dev/lib/cachetypes" + "github.com/GoogleChrome/webstatus.dev/lib/gcppubsub" + "github.com/GoogleChrome/webstatus.dev/lib/gcppubsub/gcppubsubadapters" "github.com/GoogleChrome/webstatus.dev/lib/gcpspanner" "github.com/GoogleChrome/webstatus.dev/lib/gcpspanner/spanneradapters" "github.com/GoogleChrome/webstatus.dev/lib/gds" @@ -197,11 +199,30 @@ func main() { } + pubsubProjectID := os.Getenv("PUBSUB_PROJECT_ID") + if pubsubProjectID == "" { + slog.ErrorContext(ctx, "missing pubsub project id") + os.Exit(1) + } + + ingestionTopicID := os.Getenv("INGESTION_TOPIC_ID") + if ingestionTopicID == "" { + slog.ErrorContext(ctx, "missing ingestion topic id") + os.Exit(1) + } + + queueClient, err := gcppubsub.NewClient(ctx, pubsubProjectID) + if err != nil { + slog.ErrorContext(ctx, "unable to create pub sub client", "error", err) + os.Exit(1) + } + srv := httpserver.NewHTTPServer( "8080", baseURL, datastoreadapters.NewBackend(fs), spanneradapters.NewBackend(spannerClient), + gcppubsubadapters.NewBackendAdapter(queueClient, ingestionTopicID), cache, routeCacheOptions, func(token string) *httpserver.UserGitHubClient { diff --git a/backend/go.mod b/backend/go.mod index bf8b724db..5b01b940d 100644 --- a/backend/go.mod +++ b/backend/go.mod @@ -23,6 +23,7 @@ require ( cloud.google.com/go/logging v1.13.1 // indirect cloud.google.com/go/longrunning v0.7.0 // indirect cloud.google.com/go/monitoring v1.24.3 // indirect + cloud.google.com/go/pubsub/v2 v2.0.0 // indirect cloud.google.com/go/secretmanager v1.16.0 // indirect cloud.google.com/go/spanner v1.86.1 // indirect cloud.google.com/go/storage v1.57.2 // indirect diff --git a/backend/go.sum b/backend/go.sum index 26b4d8057..2bdce011c 100644 --- a/backend/go.sum +++ b/backend/go.sum @@ -445,6 +445,8 @@ cloud.google.com/go/pubsub v1.26.0/go.mod h1:QgBH3U/jdJy/ftjPhTkyXNj543Tin1pRYcd cloud.google.com/go/pubsub v1.27.1/go.mod h1:hQN39ymbV9geqBnfQq6Xf63yNhUAhv9CZhzp5O6qsW0= cloud.google.com/go/pubsub v1.28.0/go.mod h1:vuXFpwaVoIPQMGXqRyUQigu/AX1S3IWugR9xznmcXX8= cloud.google.com/go/pubsub v1.30.0/go.mod h1:qWi1OPS0B+b5L+Sg6Gmc9zD1Y+HaM0MdUr7LsupY1P4= +cloud.google.com/go/pubsub/v2 v2.0.0 h1:0qS6mRJ41gD1lNmM/vdm6bR7DQu6coQcVwD+VPf0Bz0= +cloud.google.com/go/pubsub/v2 v2.0.0/go.mod h1:0aztFxNzVQIRSZ8vUr79uH2bS3jwLebwK6q1sgEub+E= cloud.google.com/go/pubsublite v1.5.0/go.mod h1:xapqNQ1CuLfGi23Yda/9l4bBCKz/wC3KIJ5gKcxveZg= cloud.google.com/go/pubsublite v1.6.0/go.mod h1:1eFCS0U11xlOuMFV/0iBqw3zP12kddMeCbj/F3FSj9k= cloud.google.com/go/pubsublite v1.7.0/go.mod h1:8hVMwRXfDfvGm3fahVbtDbiLePT3gpoiJYJY+vxWxVM= @@ -1105,6 +1107,8 @@ github.com/yusufpapurcu/wmi v1.2.4 h1:zFUKzehAFReQwLys1b/iSMl+JQGSCSjtVqQn9bBrPo github.com/yusufpapurcu/wmi v1.2.4/go.mod h1:SBZ9tNy3G9/m5Oi98Zks0QjeHVDvuK0qfxQmPyzfmi0= github.com/zeebo/assert v1.3.0/go.mod h1:Pq9JiuJQpG8JLJdtkwrJESF0Foym2/D9XMU5ciN/wJ0= github.com/zeebo/xxh3 v1.0.2/go.mod h1:5NWz9Sef7zIDm2JHfFlcQvNekmcEl9ekUZQQKCYaDcA= +go.einride.tech/aip v0.68.1 h1:16/AfSxcQISGN5z9C5lM+0mLYXihrHbQ1onvYTr93aQ= +go.einride.tech/aip v0.68.1/go.mod h1:XaFtaj4HuA3Zwk9xoBtTWgNubZ0ZZXv9BZJCkuKuWbg= go.opencensus.io v0.21.0/go.mod h1:mSImk1erAIZhrmZN+AvHh14ztQfjbGwt4TtuofqLduU= go.opencensus.io v0.22.0/go.mod h1:+kGneAE2xo2IficOXnaByMWTGM9T73dGwxeWcUqIpI8= go.opencensus.io v0.22.2/go.mod h1:yxeiOL68Rb0Xd1ddK5vPZ/oVn4vY4Ynel7k9FzqtOIw= diff --git a/backend/manifests/pod.yaml b/backend/manifests/pod.yaml index 9a003c83a..59c292cf6 100644 --- a/backend/manifests/pod.yaml +++ b/backend/manifests/pod.yaml @@ -59,6 +59,12 @@ spec: value: auth:9099 - name: GITHUB_API_BASE_URL value: http://api-github-mock.default.svc.cluster.local:8080/ + - name: PUBSUB_PROJECT_ID + value: local + - name: PUBSUB_EMULATOR_HOST + value: pubsub:8060 + - name: INGESTION_TOPIC_ID + value: 'ingestion-jobs-topic-id' resources: limits: cpu: 250m diff --git a/backend/pkg/httpserver/create_saved_search.go b/backend/pkg/httpserver/create_saved_search.go index 5896b99f8..3c6507c62 100644 --- a/backend/pkg/httpserver/create_saved_search.go +++ b/backend/pkg/httpserver/create_saved_search.go @@ -176,5 +176,11 @@ func (s *Server) CreateSavedSearch(ctx context.Context, request backend.CreateSa }, nil } + err = s.eventPublisher.PublishSearchConfigurationChanged(ctx, output, user.ID, true) + if err != nil { + // We should not mark this as a failure. Only log it. + slog.WarnContext(ctx, "unable to publish search configuration changed event during create", "error", err) + } + return backend.CreateSavedSearch201JSONResponse(*output), nil } diff --git a/backend/pkg/httpserver/create_saved_search_test.go b/backend/pkg/httpserver/create_saved_search_test.go index cc7840a63..519304f74 100644 --- a/backend/pkg/httpserver/create_saved_search_test.go +++ b/backend/pkg/httpserver/create_saved_search_test.go @@ -44,6 +44,7 @@ func TestCreateSavedSearch(t *testing.T) { testCases := []struct { name string mockCreateUserSavedSearchConfig *MockCreateUserSavedSearchConfig + mockPublishConfig *MockPublishSearchConfigurationChangedConfig authMiddlewareOption testServerOption request *http.Request expectedResponse *http.Response @@ -51,6 +52,7 @@ func TestCreateSavedSearch(t *testing.T) { { name: "name is 33 characters long, missing query", mockCreateUserSavedSearchConfig: nil, + mockPublishConfig: nil, request: httptest.NewRequest( http.MethodPost, "/v1/saved-searches", @@ -70,6 +72,7 @@ func TestCreateSavedSearch(t *testing.T) { { name: "name is empty", mockCreateUserSavedSearchConfig: nil, + mockPublishConfig: nil, authMiddlewareOption: withAuthMiddleware(mockAuthMiddleware(testUser)), request: httptest.NewRequest( http.MethodPost, @@ -88,6 +91,7 @@ func TestCreateSavedSearch(t *testing.T) { { name: "name is missing", mockCreateUserSavedSearchConfig: nil, + mockPublishConfig: nil, authMiddlewareOption: withAuthMiddleware(mockAuthMiddleware(testUser)), request: httptest.NewRequest( http.MethodPost, @@ -106,6 +110,7 @@ func TestCreateSavedSearch(t *testing.T) { { name: "query is empty", mockCreateUserSavedSearchConfig: nil, + mockPublishConfig: nil, authMiddlewareOption: withAuthMiddleware(mockAuthMiddleware(testUser)), request: httptest.NewRequest( http.MethodPost, @@ -124,6 +129,7 @@ func TestCreateSavedSearch(t *testing.T) { { name: "query is 257 characters long", mockCreateUserSavedSearchConfig: nil, + mockPublishConfig: nil, authMiddlewareOption: withAuthMiddleware(mockAuthMiddleware(testUser)), request: httptest.NewRequest( http.MethodPost, @@ -142,6 +148,7 @@ func TestCreateSavedSearch(t *testing.T) { { name: "description is empty", mockCreateUserSavedSearchConfig: nil, + mockPublishConfig: nil, authMiddlewareOption: withAuthMiddleware(mockAuthMiddleware(testUser)), request: httptest.NewRequest( http.MethodPost, @@ -160,6 +167,7 @@ func TestCreateSavedSearch(t *testing.T) { { name: "description is 1025 characters long", mockCreateUserSavedSearchConfig: nil, + mockPublishConfig: nil, authMiddlewareOption: withAuthMiddleware(mockAuthMiddleware(testUser)), request: httptest.NewRequest( http.MethodPost, @@ -179,6 +187,7 @@ func TestCreateSavedSearch(t *testing.T) { { name: "query has bad syntax", mockCreateUserSavedSearchConfig: nil, + mockPublishConfig: nil, authMiddlewareOption: withAuthMiddleware(mockAuthMiddleware(testUser)), request: httptest.NewRequest( http.MethodPost, @@ -197,6 +206,7 @@ func TestCreateSavedSearch(t *testing.T) { { name: "missing body creation error", mockCreateUserSavedSearchConfig: nil, + mockPublishConfig: nil, authMiddlewareOption: withAuthMiddleware(mockAuthMiddleware(testUser)), request: httptest.NewRequest( http.MethodPost, @@ -226,6 +236,7 @@ func TestCreateSavedSearch(t *testing.T) { output: nil, err: errTest, }, + mockPublishConfig: nil, authMiddlewareOption: withAuthMiddleware(mockAuthMiddleware(testUser)), request: httptest.NewRequest( http.MethodPost, @@ -251,6 +262,7 @@ func TestCreateSavedSearch(t *testing.T) { output: nil, err: errors.Join(backendtypes.ErrUserMaxSavedSearches, errTest), }, + mockPublishConfig: nil, authMiddlewareOption: withAuthMiddleware(mockAuthMiddleware(testUser)), request: httptest.NewRequest( http.MethodPost, @@ -289,6 +301,25 @@ func TestCreateSavedSearch(t *testing.T) { }, err: nil, }, + mockPublishConfig: &MockPublishSearchConfigurationChangedConfig{ + expectedResp: &backend.SavedSearchResponse{ + Id: "searchID1", + Name: "test name", + Query: `name:"test"`, + Description: nil, + Permissions: &backend.UserSavedSearchPermissions{ + Role: valuePtr(backend.SavedSearchOwner), + }, + BookmarkStatus: &backend.UserSavedSearchBookmark{ + Status: backend.BookmarkActive, + }, + CreatedAt: time.Date(2000, time.January, 1, 0, 0, 0, 0, time.UTC), + UpdatedAt: time.Date(2000, time.January, 1, 0, 0, 0, 0, time.UTC), + }, + expectedUserID: "testID1", + expectedIsCreation: true, + err: nil, + }, authMiddlewareOption: withAuthMiddleware(mockAuthMiddleware(testUser)), request: httptest.NewRequest( http.MethodPost, @@ -308,7 +339,7 @@ func TestCreateSavedSearch(t *testing.T) { ), }, { - name: "successful with name, query and description", + name: "successful with name, query and description, failed publish", mockCreateUserSavedSearchConfig: &MockCreateUserSavedSearchConfig{ expectedSavedSearch: backend.SavedSearch{ Name: "test name", @@ -332,6 +363,25 @@ func TestCreateSavedSearch(t *testing.T) { }, err: nil, }, + mockPublishConfig: &MockPublishSearchConfigurationChangedConfig{ + expectedResp: &backend.SavedSearchResponse{ + Id: "searchID1", + Name: "test name", + Query: `name:"test"`, + Description: valuePtr("test description"), + Permissions: &backend.UserSavedSearchPermissions{ + Role: valuePtr(backend.SavedSearchOwner), + }, + BookmarkStatus: &backend.UserSavedSearchBookmark{ + Status: backend.BookmarkActive, + }, + CreatedAt: time.Date(2000, time.January, 1, 0, 0, 0, 0, time.UTC), + UpdatedAt: time.Date(2000, time.January, 1, 0, 0, 0, 0, time.UTC), + }, + expectedUserID: "testID1", + expectedIsCreation: true, + err: errTest, + }, authMiddlewareOption: withAuthMiddleware(mockAuthMiddleware(testUser)), request: httptest.NewRequest( http.MethodPost, @@ -365,8 +415,13 @@ func TestCreateSavedSearch(t *testing.T) { createUserSavedSearchCfg: tc.mockCreateUserSavedSearchConfig, t: t, } + mockPublisher := &MockEventPublisher{ + t: t, + callCountPublishSearchConfigurationChanged: 0, + publishSearchConfigurationChangedCfg: tc.mockPublishConfig, + } myServer := Server{wptMetricsStorer: mockStorer, metadataStorer: nil, userGitHubClientFactory: nil, - operationResponseCaches: nil, baseURL: getTestBaseURL(t)} + operationResponseCaches: nil, baseURL: getTestBaseURL(t), eventPublisher: mockPublisher} assertTestServerRequest(t, &myServer, tc.request, tc.expectedResponse, []testServerOption{tc.authMiddlewareOption}...) }) diff --git a/backend/pkg/httpserver/create_subscription_test.go b/backend/pkg/httpserver/create_subscription_test.go index dc38321eb..f7af1476e 100644 --- a/backend/pkg/httpserver/create_subscription_test.go +++ b/backend/pkg/httpserver/create_subscription_test.go @@ -190,6 +190,7 @@ func TestCreateSubscription(t *testing.T) { metadataStorer: nil, userGitHubClientFactory: nil, operationResponseCaches: nil, + eventPublisher: nil, baseURL: getTestBaseURL(t), } assertTestServerRequest(t, &myServer, tc.request, tc.expectedResponse, tc.authMiddlewareOption) diff --git a/backend/pkg/httpserver/delete_notification_channel_test.go b/backend/pkg/httpserver/delete_notification_channel_test.go index 8133dac13..e69f2b4a0 100644 --- a/backend/pkg/httpserver/delete_notification_channel_test.go +++ b/backend/pkg/httpserver/delete_notification_channel_test.go @@ -95,6 +95,7 @@ func TestDeleteNotificationChannel(t *testing.T) { metadataStorer: nil, operationResponseCaches: nil, userGitHubClientFactory: nil, + eventPublisher: nil, } assertTestServerRequest(t, &myServer, tc.request, tc.expectedResponse, []testServerOption{tc.authMiddlewareOption}...) diff --git a/backend/pkg/httpserver/delete_subscription_test.go b/backend/pkg/httpserver/delete_subscription_test.go index 2e3028a76..1f959ef96 100644 --- a/backend/pkg/httpserver/delete_subscription_test.go +++ b/backend/pkg/httpserver/delete_subscription_test.go @@ -117,6 +117,7 @@ func TestDeleteSubscription(t *testing.T) { metadataStorer: nil, userGitHubClientFactory: nil, operationResponseCaches: nil, + eventPublisher: nil, baseURL: getTestBaseURL(t), } assertTestServerRequest(t, &myServer, tc.request, tc.expectedResponse, tc.authMiddlewareOption) diff --git a/backend/pkg/httpserver/get_feature_metadata_test.go b/backend/pkg/httpserver/get_feature_metadata_test.go index fc46bb853..c4c0c1e88 100644 --- a/backend/pkg/httpserver/get_feature_metadata_test.go +++ b/backend/pkg/httpserver/get_feature_metadata_test.go @@ -114,7 +114,9 @@ func TestGetFeatureMetadata(t *testing.T) { mockCacher := NewMockRawBytesDataCacher(t, tc.expectedCacheCalls, tc.expectedGetCalls) myServer := Server{wptMetricsStorer: mockStorer, metadataStorer: mockMetadataStorer, userGitHubClientFactory: nil, operationResponseCaches: initOperationResponseCaches(mockCacher, getTestRouteCacheOptions()), - baseURL: getTestBaseURL(t)} + baseURL: getTestBaseURL(t), + eventPublisher: nil, + } assertTestServerRequest(t, &myServer, tc.request, tc.expectedResponse) mockCacher.AssertExpectations() // TODO: Start tracking call count and assert call count. diff --git a/backend/pkg/httpserver/get_feature_test.go b/backend/pkg/httpserver/get_feature_test.go index 550c66c48..dad407e50 100644 --- a/backend/pkg/httpserver/get_feature_test.go +++ b/backend/pkg/httpserver/get_feature_test.go @@ -413,6 +413,7 @@ func TestGetFeature(t *testing.T) { mockCacher := NewMockRawBytesDataCacher(t, tc.expectedCacheCalls, tc.expectedGetCalls) myServer := Server{wptMetricsStorer: mockStorer, metadataStorer: nil, userGitHubClientFactory: nil, operationResponseCaches: initOperationResponseCaches(mockCacher, getTestRouteCacheOptions()), + eventPublisher: nil, baseURL: getTestBaseURL(t)} assertTestServerRequest(t, &myServer, tc.request, tc.expectedResponse) assertMocksExpectations(t, tc.expectedCallCount, mockStorer.callCountGetFeature, diff --git a/backend/pkg/httpserver/get_features_test.go b/backend/pkg/httpserver/get_features_test.go index dec6bad80..4c115e004 100644 --- a/backend/pkg/httpserver/get_features_test.go +++ b/backend/pkg/httpserver/get_features_test.go @@ -537,6 +537,7 @@ func TestListFeatures(t *testing.T) { mockCacher := NewMockRawBytesDataCacher(t, tc.expectedCacheCalls, tc.expectedGetCalls) myServer := Server{wptMetricsStorer: mockStorer, metadataStorer: nil, userGitHubClientFactory: nil, operationResponseCaches: initOperationResponseCaches(mockCacher, getTestRouteCacheOptions()), + eventPublisher: nil, baseURL: getTestBaseURL(t)} assertTestServerRequest(t, &myServer, tc.request, tc.expectedResponse) assertMocksExpectations(t, tc.expectedCallCount, mockStorer.callCountFeaturesSearch, diff --git a/backend/pkg/httpserver/get_notification_channel_test.go b/backend/pkg/httpserver/get_notification_channel_test.go index 4768dcb3b..fc544d1da 100644 --- a/backend/pkg/httpserver/get_notification_channel_test.go +++ b/backend/pkg/httpserver/get_notification_channel_test.go @@ -118,6 +118,7 @@ func TestGetNotificationChannel(t *testing.T) { metadataStorer: nil, operationResponseCaches: nil, userGitHubClientFactory: nil, + eventPublisher: nil, } assertTestServerRequest(t, &myServer, tc.request, tc.expectedResponse, []testServerOption{tc.authMiddlewareOption}...) diff --git a/backend/pkg/httpserver/get_saved_search_test.go b/backend/pkg/httpserver/get_saved_search_test.go index 8c36d86c6..0f2811e3a 100644 --- a/backend/pkg/httpserver/get_saved_search_test.go +++ b/backend/pkg/httpserver/get_saved_search_test.go @@ -73,6 +73,7 @@ func TestGetSavedSearch(t *testing.T) { t: t, } myServer := Server{wptMetricsStorer: mockStorer, metadataStorer: nil, userGitHubClientFactory: nil, + eventPublisher: nil, operationResponseCaches: nil, baseURL: getTestBaseURL(t)} assertTestServerRequest(t, &myServer, tc.request, tc.expectedResponse, []testServerOption{tc.authMiddlewareOption}...) diff --git a/backend/pkg/httpserver/get_subscription_test.go b/backend/pkg/httpserver/get_subscription_test.go index 68c44b182..6c4b3aa8e 100644 --- a/backend/pkg/httpserver/get_subscription_test.go +++ b/backend/pkg/httpserver/get_subscription_test.go @@ -144,6 +144,7 @@ func TestGetSubscription(t *testing.T) { metadataStorer: nil, userGitHubClientFactory: nil, operationResponseCaches: nil, + eventPublisher: nil, baseURL: getTestBaseURL(t), } assertTestServerRequest(t, &myServer, tc.request, tc.expectedResponse, tc.authMiddlewareOption) diff --git a/backend/pkg/httpserver/list_aggregated_baseline_status_counts_test.go b/backend/pkg/httpserver/list_aggregated_baseline_status_counts_test.go index 6eb0b8ee0..bf5c8827c 100644 --- a/backend/pkg/httpserver/list_aggregated_baseline_status_counts_test.go +++ b/backend/pkg/httpserver/list_aggregated_baseline_status_counts_test.go @@ -298,6 +298,7 @@ func TestListAggregatedBaselineStatusCounts(t *testing.T) { mockCacher := NewMockRawBytesDataCacher(t, tc.expectedCacheCalls, tc.expectedGetCalls) myServer := Server{wptMetricsStorer: mockStorer, metadataStorer: nil, userGitHubClientFactory: nil, operationResponseCaches: initOperationResponseCaches(mockCacher, getTestRouteCacheOptions()), + eventPublisher: nil, baseURL: getTestBaseURL(t)} assertTestServerRequest(t, &myServer, tc.request, tc.expectedResponse) assertMocksExpectations(t, tc.expectedCallCount, mockStorer.callCountListBaselineStatusCounts, diff --git a/backend/pkg/httpserver/list_aggregated_feature_support_test.go b/backend/pkg/httpserver/list_aggregated_feature_support_test.go index 76a1ea62c..033eafee0 100644 --- a/backend/pkg/httpserver/list_aggregated_feature_support_test.go +++ b/backend/pkg/httpserver/list_aggregated_feature_support_test.go @@ -315,6 +315,7 @@ func TestListAggregatedFeatureSupport(t *testing.T) { mockCacher := NewMockRawBytesDataCacher(t, tc.expectedCacheCalls, tc.expectedGetCalls) myServer := Server{wptMetricsStorer: mockStorer, metadataStorer: nil, userGitHubClientFactory: nil, operationResponseCaches: initOperationResponseCaches(mockCacher, getTestRouteCacheOptions()), + eventPublisher: nil, baseURL: getTestBaseURL(t)} assertTestServerRequest(t, &myServer, tc.request, tc.expectedResponse) assertMocksExpectations(t, tc.expectedCallCount, mockStorer.callCountListBrowserFeatureCountMetric, diff --git a/backend/pkg/httpserver/list_aggregated_wpt_metrics_test.go b/backend/pkg/httpserver/list_aggregated_wpt_metrics_test.go index 8ed40a45b..60651627e 100644 --- a/backend/pkg/httpserver/list_aggregated_wpt_metrics_test.go +++ b/backend/pkg/httpserver/list_aggregated_wpt_metrics_test.go @@ -301,6 +301,7 @@ func TestListAggregatedWPTMetrics(t *testing.T) { mockCacher := NewMockRawBytesDataCacher(t, tc.expectedCacheCalls, tc.expectedGetCalls) myServer := Server{wptMetricsStorer: mockStorer, metadataStorer: nil, userGitHubClientFactory: nil, operationResponseCaches: initOperationResponseCaches(mockCacher, getTestRouteCacheOptions()), + eventPublisher: nil, baseURL: getTestBaseURL(t)} assertTestServerRequest(t, &myServer, tc.request, tc.expectedResponse) assertMocksExpectations(t, tc.expectedCallCount, mockStorer.callCountListMetricsOverTimeWithAggregatedTotals, diff --git a/backend/pkg/httpserver/list_chromium_usage_test.go b/backend/pkg/httpserver/list_chromium_usage_test.go index f86a482fd..33a2c9a3c 100644 --- a/backend/pkg/httpserver/list_chromium_usage_test.go +++ b/backend/pkg/httpserver/list_chromium_usage_test.go @@ -155,6 +155,7 @@ func TestListChromeDailyUsageStats(t *testing.T) { mockCacher := NewMockRawBytesDataCacher(t, tc.expectedCacheCalls, tc.expectedGetCalls) myServer := Server{wptMetricsStorer: mockStorer, metadataStorer: nil, userGitHubClientFactory: nil, operationResponseCaches: initOperationResponseCaches(mockCacher, getTestRouteCacheOptions()), + eventPublisher: nil, baseURL: getTestBaseURL(t)} assertTestServerRequest(t, &myServer, tc.request, tc.expectedResponse) assertMocksExpectations(t, tc.expectedCallCount, mockStorer.callCountListChromeDailyUsageStats, diff --git a/backend/pkg/httpserver/list_feature_wpt_metrics_test.go b/backend/pkg/httpserver/list_feature_wpt_metrics_test.go index f85090bc7..d4efef3f4 100644 --- a/backend/pkg/httpserver/list_feature_wpt_metrics_test.go +++ b/backend/pkg/httpserver/list_feature_wpt_metrics_test.go @@ -297,6 +297,7 @@ func TestListFeatureWPTMetrics(t *testing.T) { mockCacher := NewMockRawBytesDataCacher(t, tc.expectedCacheCalls, tc.expectedGetCalls) myServer := Server{wptMetricsStorer: mockStorer, metadataStorer: nil, userGitHubClientFactory: nil, operationResponseCaches: initOperationResponseCaches(mockCacher, getTestRouteCacheOptions()), + eventPublisher: nil, baseURL: getTestBaseURL(t)} assertTestServerRequest(t, &myServer, tc.request, tc.expectedResponse) assertMocksExpectations(t, tc.expectedCallCount, mockStorer.callCountListMetricsForFeatureIDBrowserAndChannel, diff --git a/backend/pkg/httpserver/list_missing_one_implementation_counts_test.go b/backend/pkg/httpserver/list_missing_one_implementation_counts_test.go index 5af48d8e0..baa1a88de 100644 --- a/backend/pkg/httpserver/list_missing_one_implementation_counts_test.go +++ b/backend/pkg/httpserver/list_missing_one_implementation_counts_test.go @@ -346,6 +346,7 @@ func TestListMissingOneImplementationCounts(t *testing.T) { mockCacher := NewMockRawBytesDataCacher(t, tc.expectedCacheCalls, tc.expectedGetCalls) myServer := Server{wptMetricsStorer: mockStorer, metadataStorer: nil, userGitHubClientFactory: nil, operationResponseCaches: initOperationResponseCaches(mockCacher, getTestRouteCacheOptions()), + eventPublisher: nil, baseURL: getTestBaseURL(t)} assertTestServerRequest(t, &myServer, tc.request, tc.expectedResponse) assertMocksExpectations(t, tc.expectedCallCount, mockStorer.callCountListMissingOneImplCounts, diff --git a/backend/pkg/httpserver/list_missing_one_implementation_features_test.go b/backend/pkg/httpserver/list_missing_one_implementation_features_test.go index f7cc04b2a..da4cbb234 100644 --- a/backend/pkg/httpserver/list_missing_one_implementation_features_test.go +++ b/backend/pkg/httpserver/list_missing_one_implementation_features_test.go @@ -199,6 +199,7 @@ func TestListMissingOneImplementationFeatures(t *testing.T) { mockCacher := NewMockRawBytesDataCacher(t, nil, nil) myServer := Server{wptMetricsStorer: mockStorer, metadataStorer: nil, userGitHubClientFactory: nil, operationResponseCaches: initOperationResponseCaches(mockCacher, getTestRouteCacheOptions()), + eventPublisher: nil, baseURL: getTestBaseURL(t)} assertTestServerRequest(t, &myServer, tc.request, tc.expectedResponse) assertMocksExpectations(t, tc.expectedCallCount, mockStorer.callCountListMissingOneImplFeatures, diff --git a/backend/pkg/httpserver/list_notification_channels_test.go b/backend/pkg/httpserver/list_notification_channels_test.go index 1b6336f37..a875370f3 100644 --- a/backend/pkg/httpserver/list_notification_channels_test.go +++ b/backend/pkg/httpserver/list_notification_channels_test.go @@ -138,6 +138,7 @@ func TestListNotificationChannels(t *testing.T) { metadataStorer: nil, operationResponseCaches: nil, userGitHubClientFactory: nil, + eventPublisher: nil, } assertTestServerRequest(t, &myServer, tc.request, tc.expectedResponse, []testServerOption{tc.authMiddlewareOption}...) diff --git a/backend/pkg/httpserver/list_subscriptions_test.go b/backend/pkg/httpserver/list_subscriptions_test.go index c534e9fc1..02116721b 100644 --- a/backend/pkg/httpserver/list_subscriptions_test.go +++ b/backend/pkg/httpserver/list_subscriptions_test.go @@ -180,6 +180,7 @@ func TestListSubscriptions(t *testing.T) { metadataStorer: nil, userGitHubClientFactory: nil, operationResponseCaches: nil, + eventPublisher: nil, baseURL: getTestBaseURL(t), } assertTestServerRequest(t, &myServer, tc.request, tc.expectedResponse, tc.authMiddlewareOption) diff --git a/backend/pkg/httpserver/list_user_saved_searches_test.go b/backend/pkg/httpserver/list_user_saved_searches_test.go index de374ddd2..fa102007b 100644 --- a/backend/pkg/httpserver/list_user_saved_searches_test.go +++ b/backend/pkg/httpserver/list_user_saved_searches_test.go @@ -180,7 +180,8 @@ func TestListUserSavedSearches(t *testing.T) { t: t, } myServer := Server{wptMetricsStorer: mockStorer, metadataStorer: nil, userGitHubClientFactory: nil, - operationResponseCaches: nil, baseURL: getTestBaseURL(t)} + operationResponseCaches: nil, baseURL: getTestBaseURL(t), + eventPublisher: nil} assertTestServerRequest(t, &myServer, tc.request, tc.expectedResponse, []testServerOption{tc.authMiddlewareOption}...) assertMocksExpectations(t, tc.expectedCallCount, mockStorer.callCountListUserSavedSearches, diff --git a/backend/pkg/httpserver/ping_user_test.go b/backend/pkg/httpserver/ping_user_test.go index aa2a6e6d4..ea160252a 100644 --- a/backend/pkg/httpserver/ping_user_test.go +++ b/backend/pkg/httpserver/ping_user_test.go @@ -267,6 +267,7 @@ func TestPingUser(t *testing.T) { ), operationResponseCaches: nil, baseURL: getTestBaseURL(t), + eventPublisher: nil, } req := httptest.NewRequest(http.MethodPost, "/v1/users/me/ping", tc.body) diff --git a/backend/pkg/httpserver/put_user_saved_search_bookmark_test.go b/backend/pkg/httpserver/put_user_saved_search_bookmark_test.go index f336e115a..d2f9ff829 100644 --- a/backend/pkg/httpserver/put_user_saved_search_bookmark_test.go +++ b/backend/pkg/httpserver/put_user_saved_search_bookmark_test.go @@ -107,6 +107,7 @@ func TestPutUserSavedSearchBookmark(t *testing.T) { } myServer := Server{wptMetricsStorer: mockStorer, metadataStorer: nil, userGitHubClientFactory: nil, operationResponseCaches: nil, + eventPublisher: nil, baseURL: getTestBaseURL(t)} assertTestServerRequest(t, &myServer, tc.request, tc.expectedResponse, []testServerOption{authMiddlewareOption}...) diff --git a/backend/pkg/httpserver/remove_saved_search_test.go b/backend/pkg/httpserver/remove_saved_search_test.go index 919504950..98465a43b 100644 --- a/backend/pkg/httpserver/remove_saved_search_test.go +++ b/backend/pkg/httpserver/remove_saved_search_test.go @@ -106,7 +106,7 @@ func TestRemoveSavedSearch(t *testing.T) { t: t, } myServer := Server{wptMetricsStorer: mockStorer, metadataStorer: nil, userGitHubClientFactory: nil, - operationResponseCaches: nil, baseURL: getTestBaseURL(t)} + operationResponseCaches: nil, baseURL: getTestBaseURL(t), eventPublisher: nil} assertTestServerRequest(t, &myServer, tc.request, tc.expectedResponse, []testServerOption{authMiddlewareOption}...) assertMocksExpectations(t, 1, mockStorer.callCountDeleteUserSavedSearch, diff --git a/backend/pkg/httpserver/remove_user_saved_search_bookmark_test.go b/backend/pkg/httpserver/remove_user_saved_search_bookmark_test.go index ca3c9a0e2..7c09d0b7a 100644 --- a/backend/pkg/httpserver/remove_user_saved_search_bookmark_test.go +++ b/backend/pkg/httpserver/remove_user_saved_search_bookmark_test.go @@ -106,7 +106,7 @@ func TestRemoveUserSavedSearchBookmark(t *testing.T) { t: t, } myServer := Server{wptMetricsStorer: mockStorer, metadataStorer: nil, userGitHubClientFactory: nil, - operationResponseCaches: nil, baseURL: getTestBaseURL(t)} + operationResponseCaches: nil, baseURL: getTestBaseURL(t), eventPublisher: nil} assertTestServerRequest(t, &myServer, tc.request, tc.expectedResponse, []testServerOption{authMiddlewareOption}...) assertMocksExpectations(t, 1, mockStorer.callCountRemoveUserSavedSearchBookmark, diff --git a/backend/pkg/httpserver/server.go b/backend/pkg/httpserver/server.go index d0bc75814..d87685d53 100644 --- a/backend/pkg/httpserver/server.go +++ b/backend/pkg/httpserver/server.go @@ -170,6 +170,7 @@ type Server struct { operationResponseCaches *operationResponseCaches baseURL *url.URL userGitHubClientFactory UserGitHubClientFactory + eventPublisher EventPublisher } type GitHubUserClient interface { @@ -221,11 +222,17 @@ type RouteCacheOptions struct { AggregatedFeatureStatsOptions []cachetypes.CacheOption } +type EventPublisher interface { + PublishSearchConfigurationChanged(ctx context.Context, resp *backend.SavedSearchResponse, + userID string, isCreation bool) error +} + func NewHTTPServer( port string, baseURL *url.URL, metadataStorer WebFeatureMetadataStorer, wptMetricsStorer WPTMetricsStorer, + eventPublisher EventPublisher, rawBytesDataCacher RawBytesDataCacher, routeCacheOptions RouteCacheOptions, userGitHubClientFactory UserGitHubClientFactory, @@ -235,6 +242,7 @@ func NewHTTPServer( srv := &Server{ metadataStorer: metadataStorer, wptMetricsStorer: wptMetricsStorer, + eventPublisher: eventPublisher, operationResponseCaches: initOperationResponseCaches(rawBytesDataCacher, routeCacheOptions), baseURL: baseURL, userGitHubClientFactory: userGitHubClientFactory, diff --git a/backend/pkg/httpserver/server_test.go b/backend/pkg/httpserver/server_test.go index 6cc6e3c04..2db9c5926 100644 --- a/backend/pkg/httpserver/server_test.go +++ b/backend/pkg/httpserver/server_test.go @@ -855,6 +855,38 @@ func (m *MockWPTMetricsStorer) DeleteNotificationChannel( return m.deleteNotificationChannelCfg.err } +type MockPublishSearchConfigurationChangedConfig struct { + expectedResp *backend.SavedSearchResponse + expectedUserID string + expectedIsCreation bool + err error +} + +type MockEventPublisher struct { + t *testing.T + callCountPublishSearchConfigurationChanged int + publishSearchConfigurationChangedCfg *MockPublishSearchConfigurationChangedConfig +} + +func (m *MockEventPublisher) PublishSearchConfigurationChanged( + _ context.Context, + resp *backend.SavedSearchResponse, + userID string, + isCreation bool) error { + m.callCountPublishSearchConfigurationChanged++ + if !reflect.DeepEqual(resp, m.publishSearchConfigurationChangedCfg.expectedResp) { + m.t.Errorf("unexpected response %+v", resp) + } + if userID != m.publishSearchConfigurationChangedCfg.expectedUserID { + m.t.Errorf("unexpected user id %s", userID) + } + if isCreation != m.publishSearchConfigurationChangedCfg.expectedIsCreation { + m.t.Errorf("unexpected is creation %t", isCreation) + } + + return m.publishSearchConfigurationChangedCfg.err +} + func TestGetPageSizeOrDefault(t *testing.T) { testCases := []struct { name string diff --git a/backend/pkg/httpserver/update_saved_search.go b/backend/pkg/httpserver/update_saved_search.go index 06d67fff5..220ec220c 100644 --- a/backend/pkg/httpserver/update_saved_search.go +++ b/backend/pkg/httpserver/update_saved_search.go @@ -128,5 +128,11 @@ func (s *Server) UpdateSavedSearch( }, nil } + err = s.eventPublisher.PublishSearchConfigurationChanged(ctx, output, user.ID, false) + if err != nil { + // We should not mark this as a failure. Only log it. + slog.ErrorContext(ctx, "unable to publish search configuration changed event during update", "error", err) + } + return backend.UpdateSavedSearch200JSONResponse(*output), nil } diff --git a/backend/pkg/httpserver/update_saved_search_test.go b/backend/pkg/httpserver/update_saved_search_test.go index 6c32e03be..3eb4eee08 100644 --- a/backend/pkg/httpserver/update_saved_search_test.go +++ b/backend/pkg/httpserver/update_saved_search_test.go @@ -61,6 +61,7 @@ func TestUpdateSavedSearch(t *testing.T) { testCases := []struct { name string cfg *MockUpdateUserSavedSearchConfig + publishCfg *MockPublishSearchConfigurationChangedConfig authMiddlewareOption testServerOption request *http.Request expectedResponse *http.Response @@ -68,6 +69,7 @@ func TestUpdateSavedSearch(t *testing.T) { { name: "missing body update error", cfg: nil, + publishCfg: nil, authMiddlewareOption: withAuthMiddleware(mockAuthMiddleware(testUser)), request: httptest.NewRequest( http.MethodPatch, @@ -81,6 +83,7 @@ func TestUpdateSavedSearch(t *testing.T) { { name: "empty update mask error", cfg: nil, + publishCfg: nil, authMiddlewareOption: withAuthMiddleware(mockAuthMiddleware(testUser)), request: httptest.NewRequest( http.MethodPatch, @@ -94,6 +97,7 @@ func TestUpdateSavedSearch(t *testing.T) { { name: "update with invalid masks error", cfg: nil, + publishCfg: nil, authMiddlewareOption: withAuthMiddleware(mockAuthMiddleware(testUser)), request: httptest.NewRequest( http.MethodPatch, @@ -110,6 +114,7 @@ func TestUpdateSavedSearch(t *testing.T) { { name: "missing fields, all update masks set, update error", cfg: nil, + publishCfg: nil, authMiddlewareOption: withAuthMiddleware(mockAuthMiddleware(testUser)), request: httptest.NewRequest( http.MethodPatch, @@ -138,6 +143,7 @@ func TestUpdateSavedSearch(t *testing.T) { output: nil, err: backendtypes.ErrUserNotAuthorizedForAction, }, + publishCfg: nil, authMiddlewareOption: withAuthMiddleware(mockAuthMiddleware(testUser)), request: httptest.NewRequest( @@ -161,6 +167,7 @@ func TestUpdateSavedSearch(t *testing.T) { output: nil, err: backendtypes.ErrEntityDoesNotExist, }, + publishCfg: nil, authMiddlewareOption: withAuthMiddleware(mockAuthMiddleware(testUser)), request: httptest.NewRequest( @@ -184,6 +191,7 @@ func TestUpdateSavedSearch(t *testing.T) { output: nil, err: errTest, }, + publishCfg: nil, authMiddlewareOption: withAuthMiddleware(mockAuthMiddleware(testUser)), request: httptest.NewRequest( @@ -220,6 +228,25 @@ func TestUpdateSavedSearch(t *testing.T) { }, err: nil, }, + publishCfg: &MockPublishSearchConfigurationChangedConfig{ + expectedResp: &backend.SavedSearchResponse{ + Id: "saved-search-id", + Name: "test name", + Query: `name:"test"`, + Description: valuePtr("test description"), + Permissions: &backend.UserSavedSearchPermissions{ + Role: valuePtr(backend.SavedSearchOwner), + }, + BookmarkStatus: &backend.UserSavedSearchBookmark{ + Status: backend.BookmarkActive, + }, + CreatedAt: time.Date(2000, time.January, 1, 0, 0, 0, 0, time.UTC), + UpdatedAt: time.Date(2000, time.January, 1, 0, 0, 0, 0, time.UTC), + }, + expectedIsCreation: false, + expectedUserID: "testID1", + err: nil, + }, authMiddlewareOption: withAuthMiddleware(mockAuthMiddleware(testUser)), request: httptest.NewRequest( @@ -245,7 +272,7 @@ func TestUpdateSavedSearch(t *testing.T) { ), }, { - name: "success, all fields, clear description with explicit null", + name: "success, all fields, clear description with explicit null, failed publish", cfg: &MockUpdateUserSavedSearchConfig{ expectedSavedSearchID: "saved-search-id", expectedUserID: "testID1", @@ -266,6 +293,25 @@ func TestUpdateSavedSearch(t *testing.T) { }, err: nil, }, + publishCfg: &MockPublishSearchConfigurationChangedConfig{ + expectedResp: &backend.SavedSearchResponse{ + Id: "saved-search-id", + Name: "test name", + Query: `name:"test"`, + Description: nil, + Permissions: &backend.UserSavedSearchPermissions{ + Role: valuePtr(backend.SavedSearchOwner), + }, + BookmarkStatus: &backend.UserSavedSearchBookmark{ + Status: backend.BookmarkActive, + }, + CreatedAt: time.Date(2000, time.January, 1, 0, 0, 0, 0, time.UTC), + UpdatedAt: time.Date(2000, time.January, 1, 0, 0, 0, 0, time.UTC), + }, + expectedIsCreation: false, + expectedUserID: "testID1", + err: errTest, + }, authMiddlewareOption: withAuthMiddleware(mockAuthMiddleware(testUser)), request: httptest.NewRequest( @@ -318,6 +364,25 @@ func TestUpdateSavedSearch(t *testing.T) { }, err: nil, }, + publishCfg: &MockPublishSearchConfigurationChangedConfig{ + expectedResp: &backend.SavedSearchResponse{ + Id: "saved-search-id", + Name: "test name", + Query: `name:"test"`, + Description: nil, + Permissions: &backend.UserSavedSearchPermissions{ + Role: valuePtr(backend.SavedSearchOwner), + }, + BookmarkStatus: &backend.UserSavedSearchBookmark{ + Status: backend.BookmarkActive, + }, + CreatedAt: time.Date(2000, time.January, 1, 0, 0, 0, 0, time.UTC), + UpdatedAt: time.Date(2000, time.January, 1, 0, 0, 0, 0, time.UTC), + }, + expectedUserID: "testID1", + expectedIsCreation: false, + err: nil, + }, authMiddlewareOption: withAuthMiddleware(mockAuthMiddleware(testUser)), request: httptest.NewRequest( @@ -355,8 +420,13 @@ func TestUpdateSavedSearch(t *testing.T) { updateUserSavedSearchCfg: tc.cfg, t: t, } + mockPublisher := &MockEventPublisher{ + t: t, + callCountPublishSearchConfigurationChanged: 0, + publishSearchConfigurationChangedCfg: tc.publishCfg, + } myServer := Server{wptMetricsStorer: mockStorer, metadataStorer: nil, userGitHubClientFactory: nil, - operationResponseCaches: nil, baseURL: getTestBaseURL(t)} + operationResponseCaches: nil, baseURL: getTestBaseURL(t), eventPublisher: mockPublisher} assertTestServerRequest(t, &myServer, tc.request, tc.expectedResponse, []testServerOption{tc.authMiddlewareOption}...) }) diff --git a/backend/pkg/httpserver/update_subscription_test.go b/backend/pkg/httpserver/update_subscription_test.go index 4c6e16c9b..8e0686429 100644 --- a/backend/pkg/httpserver/update_subscription_test.go +++ b/backend/pkg/httpserver/update_subscription_test.go @@ -227,6 +227,7 @@ func TestUpdateSubscription(t *testing.T) { metadataStorer: nil, operationResponseCaches: nil, userGitHubClientFactory: nil, + eventPublisher: nil, } assertTestServerRequest(t, &myServer, tc.request, tc.expectedResponse, tc.authMiddlewareOption) assertMocksExpectations(t, diff --git a/backend/skaffold.yaml b/backend/skaffold.yaml index 2d7390e64..44a2f3403 100644 --- a/backend/skaffold.yaml +++ b/backend/skaffold.yaml @@ -22,6 +22,7 @@ requires: - path: ../.dev/valkey - path: ../.dev/spanner - path: ../.dev/wiremock + - path: ../.dev/pubsub profiles: - name: local build: diff --git a/e2e/tests/notifications.spec.ts b/e2e/tests/notifications.spec.ts new file mode 100644 index 000000000..b08337955 --- /dev/null +++ b/e2e/tests/notifications.spec.ts @@ -0,0 +1,236 @@ +// Copyright 2026 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. + +import {test, expect} from '@playwright/test'; +import {resetUserData, loginAsUser, gotoOverviewPageUrl} from './utils.js'; +import { + getLatestEmail, + triggerBatchJob, + triggerNonMatchingChange, + triggerMatchingChange, + triggerBatchChange, +} from './test-data-util.js'; + +const TEST_USER_1 = { + username: 'test user 1', + email: 'test.user.1@example.com', +}; + +test.describe('Notifications', () => { + test.beforeEach(async ({page}) => { + await loginAsUser(page, TEST_USER_1.username); + await resetUserData(); + }); + + test('Immediate Edit Flow', async ({page}) => { + await gotoOverviewPageUrl(page, 'http://localhost:5555/'); + await page.getByLabel('Search features').fill('group:css'); + await page.getByLabel('Search features').press('Enter'); + await page.getByRole('button', {name: 'Save Search'}).click(); + await page.getByLabel('Name').fill('Browser Features'); + await page.getByRole('button', {name: 'Save'}).click(); + await page.getByRole('button', {name: 'Subscribe to updates'}).click(); + await page.getByRole('radio', {name: 'Immediately'}).click(); + await page.getByRole('button', {name: 'Save'}).click(); + await expect(page.getByText('Subscription saved!')).toBeVisible(); + + // Trigger the notification + await page.getByRole('button', {name: 'Edit Search'}).click(); + await page.getByLabel('Query').fill('group:html'); + await page.getByRole('button', {name: 'Save'}).click(); + + // Verify email + const email = await test.step('Poll for email', async () => { + for (let i = 0; i < 10; i++) { + const email = await getLatestEmail(TEST_USER_1.email); + if (email) { + return email; + } + await new Promise(resolve => setTimeout(resolve, 1000)); + } + return null; + }); + + expect(email).not.toBeNull(); + expect(email.Content.Headers.Subject[0]).toContain('Update:'); + }); + + test('Batch Schedule Flow', async ({page}) => { + await gotoOverviewPageUrl(page, 'http://localhost:5555/'); + await page.getByLabel('Search features').fill('group:css'); + await page.getByLabel('Search features').press('Enter'); + await page.getByRole('button', {name: 'Save Search'}).click(); + await page.getByLabel('Name').fill('Browser Features'); + await page.getByRole('button', {name: 'Save'}).click(); + await page.getByRole('button', {name: 'Subscribe to updates'}).click(); + await page.getByRole('radio', {name: 'Weekly updates'}).click(); + await page.getByRole('button', {name: 'Save'}).click(); + await expect(page.getByText('Subscription saved!')).toBeVisible(); + + // Backdoor data change + triggerBatchChange(); + + // Trigger the batch job + await triggerBatchJob('weekly'); + + // Verify email + const email = await test.step('Poll for email', async () => { + for (let i = 0; i < 10; i++) { + const email = await getLatestEmail(TEST_USER_1.email); + if ( + email && + email.Content.Headers.Subject[0].includes('Weekly Digest') + ) { + return email; + } + await new Promise(resolve => setTimeout(resolve, 1000)); + } + return null; + }); + + expect(email).not.toBeNull(); + expect(email.Content.Headers.Subject[0]).toContain('Weekly Digest'); + }); + + test('2-Click Unsubscribe Flow', async ({page}) => { + // 1. Setup: Run the "Immediate Edit" flow to generate an email. + await gotoOverviewPageUrl(page, 'http://localhost:5555/'); + await page.getByLabel('Search features').fill('group:css'); + await page.getByLabel('Search features').press('Enter'); + await page.getByRole('button', {name: 'Save Search'}).click(); + await page.getByLabel('Name').fill('Browser Features'); + await page.getByRole('button', {name: 'Save'}).click(); + await page.getByRole('button', {name: 'Subscribe to updates'}).click(); + await page.getByRole('radio', {name: 'Immediately'}).click(); + await page.getByRole('button', {name: 'Save'}).click(); + await expect(page.getByText('Subscription saved!')).toBeVisible(); + await page.getByRole('button', {name: 'Edit Search'}).click(); + await page.getByLabel('Query').fill('group:html'); + await page.getByRole('button', {name: 'Save'}).click(); + const email = await test.step('Poll for email', async () => { + for (let i = 0; i < 10; i++) { + const email = await getLatestEmail(TEST_USER_1.email); + if (email) { + return email; + } + await new Promise(resolve => setTimeout(resolve, 1000)); + } + return null; + }); + expect(email).not.toBeNull(); + + // 2. Extract Unsubscribe Link + const unsubscribeLinkMatch = email.Content.Body.match( + /href="([^"]+action=unsubscribe[^"]+)"/, + ); + expect(unsubscribeLinkMatch).not.toBeNull(); + const unsubscribeUrl = unsubscribeLinkMatch[1]; + + // 3. Action: Navigate to the link + await page.goto(unsubscribeUrl); + + // 4. Interact: Confirm the unsubscription + await page.getByRole('button', {name: 'Confirm Unsubscribe'}).click(); + await expect(page.getByText('Subscription deleted!')).toBeVisible(); + + // 5. Verify: Go to the subscriptions page and check that the subscription is gone. + await page.goto('/settings/subscriptions'); + await expect(page.getByText('No subscriptions found.')).toBeVisible(); + }); + + test('Noise Filter Flow (Negative Test)', async ({page}) => { + // 1. Setup + await gotoOverviewPageUrl(page, 'http://localhost:5555/'); + await page.getByLabel('Search features').fill('group:css'); + await page.getByLabel('Search features').press('Enter'); + await page.getByRole('button', {name: 'Save Search'}).click(); + await page.getByLabel('Name').fill('Browser Features'); + await page.getByRole('button', {name: 'Save'}).click(); + await page.getByRole('button', {name: 'Subscribe to updates'}).click(); + await page.getByRole('radio', {name: 'Weekly updates'}).click(); + await page + .getByRole('checkbox', {name: '...becomes widely available'}) + .check(); + await page.getByRole('button', {name: 'Save'}).click(); + await expect(page.getByText('Subscription saved!')).toBeVisible(); + + // 2. Backdoor Action 1 (Non-matching change) + triggerNonMatchingChange(); + + // 3. Trigger + await triggerBatchJob('weekly'); + + // 4. Verify NO email is received + await new Promise(resolve => setTimeout(resolve, 5000)); // Wait for a reasonable time + let email = await getLatestEmail(TEST_USER_1.email); + expect(email).toBeNull(); + + // 5. Backdoor Action 2 (Matching change) + triggerMatchingChange(); + + // 6. Trigger + await triggerBatchJob('weekly'); + + // 7. Verify email IS received + email = await test.step('Poll for email', async () => { + for (let i = 0; i < 10; i++) { + const email = await getLatestEmail(TEST_USER_1.email); + if (email) { + return email; + } + await new Promise(resolve => setTimeout(resolve, 1000)); + } + return null; + }); + expect(email).not.toBeNull(); + }); + + test('Idempotency Flow', async ({page}) => { + // 1. Setup + await gotoOverviewPageUrl(page, 'http://localhost:5555/'); + await page.getByLabel('Search features').fill('group:css'); + await page.getByLabel('Search features').press('Enter'); + await page.getByRole('button', {name: 'Save Search'}).click(); + await page.getByLabel('Name').fill('Browser Features'); + await page.getByRole('button', {name: 'Save'}).click(); + await page.getByRole('button', {name: 'Subscribe to updates'}).click(); + await page.getByRole('radio', {name: 'Weekly updates'}).click(); + await page.getByRole('button', {name: 'Save'}).click(); + await expect(page.getByText('Subscription saved!')).toBeVisible(); + + // Placeholder for backdoor data change + triggerBatchChange(); + await triggerBatchJob('weekly'); + const firstEmail = await test.step('Poll for first email', async () => { + for (let i = 0; i < 10; i++) { + const email = await getLatestEmail(TEST_USER_1.email); + if (email) { + return email; + } + await new Promise(resolve => setTimeout(resolve, 1000)); + } + return null; + }); + expect(firstEmail).not.toBeNull(); + + // 2. Action: Trigger again + await triggerBatchJob('weekly'); + + // 3. Verify: No new email + await new Promise(resolve => setTimeout(resolve, 5000)); // Wait for a reasonable time + const secondEmail = await getLatestEmail(TEST_USER_1.email); + expect(secondEmail).not.toBeNull(); + expect(secondEmail.ID).toEqual(firstEmail.ID); // No new email, so latest is the same. + }); +}); diff --git a/e2e/tests/test-data-util.ts b/e2e/tests/test-data-util.ts new file mode 100644 index 000000000..d66d26695 --- /dev/null +++ b/e2e/tests/test-data-util.ts @@ -0,0 +1,63 @@ +// 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. + +import {execSync} from 'child_process'; + +const LOAD_FAKE_DATA_CMD = + "make dev_fake_data LOAD_FAKE_DATA_FLAGS='-trigger-scenario=%s'"; + +export async function triggerBatchJob(frequency: string) { + const message = { + messages: [ + { + data: Buffer.from(JSON.stringify({frequency})).toString('base64'), + }, + ], + }; + + await fetch( + 'http://localhost:8060/v1/projects/local/topics/batch-updates-topic-id:publish', + { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify(message), + }, + ); +} + +export async function getLatestEmail(recipient: string): Promise { + const response = await fetch('http://localhost:8025/api/v1/messages'); + const data = await response.json(); + const messages = data.messages || []; + for (const message of messages) { + if (message.To.some((r: any) => r.Address === recipient)) { + return message; + } + } + return null; +} + +export function triggerNonMatchingChange() { + execSync(LOAD_FAKE_DATA_CMD.replace('%s', 'non-matching')); +} + +export function triggerMatchingChange() { + execSync(LOAD_FAKE_DATA_CMD.replace('%s', 'matching')); +} + +export function triggerBatchChange() { + execSync(LOAD_FAKE_DATA_CMD.replace('%s', 'batch-change')); +} diff --git a/frontend/nginx.conf b/frontend/nginx.conf index 7c88c7fa8..ec33e8155 100644 --- a/frontend/nginx.conf +++ b/frontend/nginx.conf @@ -48,6 +48,10 @@ http { try_files $uri $uri/ /index.html; } + location = /settings/subscriptions { + try_files $uri $uri/ /index.html; + } + location = / { try_files $uri $uri/ =404; } diff --git a/frontend/src/static/js/api/client.ts b/frontend/src/static/js/api/client.ts index c60a39efe..0470929a2 100644 --- a/frontend/src/static/js/api/client.ts +++ b/frontend/src/static/js/api/client.ts @@ -57,6 +57,7 @@ type PageablePath = | '/v1/users/me/notification-channels' | '/v1/stats/features/browsers/{browser}/feature_counts' | '/v1/users/me/saved-searches' + | '/v1/users/me/subscriptions' | '/v1/stats/baseline_status/low_date_feature_counts'; type SuccessResponsePageableData< @@ -854,4 +855,143 @@ export class APIClient { } return response.data; } + + public async getSubscription( + subscriptionId: string, + token: string, + ): Promise { + const options = { + ...temporaryFetchOptions, + params: { + path: { + subscription_id: subscriptionId, + }, + }, + headers: { + Authorization: `Bearer ${token}`, + }, + }; + const response = await this.client.GET( + '/v1/users/me/subscriptions/{subscription_id}', + options, + ); + const error = response.error; + if (error !== undefined) { + throw createAPIError(error); + } + + return response.data; + } + + public async deleteSubscription(subscriptionId: string, token: string) { + const options = { + ...temporaryFetchOptions, + params: { + path: { + subscription_id: subscriptionId, + }, + }, + headers: { + Authorization: `Bearer ${token}`, + }, + }; + const response = await this.client.DELETE( + '/v1/users/me/subscriptions/{subscription_id}', + options, + ); + const error = response.error; + if (error !== undefined) { + throw createAPIError(error); + } + + return response.data; + } + + public async listSubscriptions( + token: string, + ): Promise { + type SubscriptionPage = SuccessResponsePageableData< + paths['/v1/users/me/subscriptions']['get'], + ParamsOption<'/v1/users/me/subscriptions'>, + 'application/json', + '/v1/users/me/subscriptions' + >; + + return this.getAllPagesOfData< + '/v1/users/me/subscriptions', + SubscriptionPage + >('/v1/users/me/subscriptions', { + headers: { + Authorization: `Bearer ${token}`, + }, + }); + } + + public async createSubscription( + token: string, + subscription: components['schemas']['Subscription'], + ): Promise { + const options: FetchOptions< + FilterKeys + > = { + headers: { + Authorization: `Bearer ${token}`, + }, + body: subscription, + credentials: temporaryFetchOptions.credentials, + }; + const response = await this.client.POST( + '/v1/users/me/subscriptions', + options, + ); + const error = response.error; + if (error !== undefined) { + throw createAPIError(error); + } + return response.data; + } + + public async updateSubscription( + subscriptionId: string, + token: string, + updates: { + triggers?: components['schemas']['SubscriptionTriggerWritable'][]; + frequency?: components['schemas']['SubscriptionFrequency']; + }, + ): Promise { + const req: components['schemas']['UpdateSubscriptionRequest'] = { + update_mask: [], + }; + if (updates.triggers !== undefined) { + req.update_mask.push('triggers'); + req.triggers = updates.triggers; + } + if (updates.frequency !== undefined) { + req.update_mask.push('frequency'); + req.frequency = updates.frequency; + } + const options: FetchOptions< + FilterKeys + > = { + headers: { + Authorization: `Bearer ${token}`, + }, + params: { + path: { + subscription_id: subscriptionId, + }, + }, + body: req, + credentials: temporaryFetchOptions.credentials, + }; + const response = await this.client.PATCH( + '/v1/users/me/subscriptions/{subscription_id}', + options, + ); + const error = response.error; + if (error !== undefined) { + throw createAPIError(error); + } + return response.data; + } } diff --git a/frontend/src/static/js/components/test/webstatus-manage-subscriptions-dialog.test.ts b/frontend/src/static/js/components/test/webstatus-manage-subscriptions-dialog.test.ts new file mode 100644 index 000000000..a3c28843c --- /dev/null +++ b/frontend/src/static/js/components/test/webstatus-manage-subscriptions-dialog.test.ts @@ -0,0 +1,368 @@ +/** + * 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. + */ + +import {fixture, html} from '@open-wc/testing'; +import {expect} from '@esm-bundle/chai'; +import sinon from 'sinon'; +import {APIClient} from '../../api/client.js'; +import {User} from '../../contexts/firebase-user-context.js'; +import '../webstatus-manage-subscriptions-dialog.js'; +import { + ManageSubscriptionsDialog, +} from '../webstatus-manage-subscriptions-dialog.js'; +import {type components} from 'webstatus.dev-backend'; + +describe('webstatus-manage-subscriptions-dialog', () => { + let sandbox: sinon.SinonSandbox; + let apiClient: APIClient; + let user: User; + let element: ManageSubscriptionsDialog; + + const mockSavedSearch: components['schemas']['SavedSearchResponse'] = { + id: 'test-search-id', + name: 'Test Saved Search', + query: 'is:test', + created_at: new Date().toISOString(), + updated_at: new Date().toISOString(), + permissions: {role: 'saved_search_owner'}, + bookmark_status: {status: 'bookmark_none'}, + }; + + const mockNotificationChannels: components['schemas']['NotificationChannelResponse'][] = [ + { + id: 'test-channel-id', + type: 'email', + name: 'test@example.com', + value: 'test@example.com', + status: 'enabled', + created_at: new Date().toISOString(), + updated_at: new Date().toISOString(), + }, + { + id: 'other-channel-id', + type: 'email', + name: 'other@example.com', + value: 'other@example.com', + status: 'enabled', + created_at: new Date().toISOString(), + updated_at: new Date().toISOString(), + }, + ]; + + const mockInitialSubscription: + components['schemas']['SubscriptionResponse'] = { + id: 'initial-sub-id', + saved_search_id: 'test-search-id', + channel_id: 'initial-channel-id', + frequency: 'weekly', + triggers: [{value: 'feature_baseline_to_newly'}], + created_at: new Date().toISOString(), + updated_at: new Date().toISOString(), + }; + + const mockOtherSubscription: + components['schemas']['SubscriptionResponse'] = { + id: 'other-sub-id', + saved_search_id: 'test-search-id', + channel_id: 'other-channel-id', + frequency: 'monthly', + triggers: [{value: 'feature_baseline_to_widely'}], + created_at: new Date().toISOString(), + updated_at: new Date().toISOString(), + }; + + beforeEach(async () => { + sandbox = sinon.createSandbox(); + apiClient = { + listNotificationChannels: sandbox.stub().resolves(mockNotificationChannels), + getSavedSearchByID: sandbox.stub().resolves(mockSavedSearch), + listSubscriptions: sandbox + .stub() + .resolves([mockInitialSubscription, mockOtherSubscription]), + createSubscription: sandbox.stub().resolves(mockInitialSubscription), + updateSubscription: sandbox.stub().resolves(mockInitialSubscription), + deleteSubscription: sandbox.stub().resolves(undefined), + getSubscription: sandbox.stub().resolves(mockInitialSubscription), + } as any as APIClient; + user = {getIdToken: async () => 'test-token'} as User; + + element = await fixture(html` + + `); + + // Ensure the loading task completes and the component re-renders + await element['_loadingTask'].taskComplete; + await element.updateComplete; + }); + + afterEach(() => { + sandbox.restore(); + }); + + it('renders correctly initially', async () => { + expect(element).to.be.instanceOf(ManageSubscriptionsDialog); + // Dialog is initially closed, so its content should not be visible. + expect(element.open).to.be.false; + expect(element.shadowRoot?.querySelector('sl-dialog[open]')).to.be.null; + // We will test content when the dialog is explicitly opened in another test. + expect(element.isDirty).to.be.false; + }); + + it('shows content when opened', async () => { + element.open = true; + await element['_loadingTask'].run(); + await element.updateComplete; + expect(element.shadowRoot?.querySelector('sl-spinner')).to.be.null; + expect(element.shadowRoot?.textContent).to.include('Test Saved Search'); + expect(element.shadowRoot?.textContent).to.include('Notification channels'); + }); + + + it('fetches data when opened for a saved search', async () => { + element.open = true; + await element['_loadingTask'].run(); // Explicitly re-run the task + + // The beforeEach already triggers the loading task + // We just need to assert the calls and state. + expect(apiClient.listNotificationChannels).to.have.been.calledWith('test-token'); + expect(apiClient.getSavedSearchByID).to.have.been.calledWith( + 'test-search-id', + 'test-token' + ); + expect(apiClient.listSubscriptions).to.have.been.calledWith('test-token'); + // Also verify that the dialog's internal state is updated + expect(element['_notificationChannels']).to.deep.equal(mockNotificationChannels); + expect(element['_savedSearch']).to.deep.equal(mockSavedSearch); + expect(element['_subscriptionsForSavedSearch']).to.deep.equal([ + mockInitialSubscription, + mockOtherSubscription, + ]); + }); + + it('is dirty when frequency changes', async () => { + element.open = true; + await element['_loadingTask'].run(); + await element.updateComplete; + + element['_selectedFrequency'] = 'monthly'; // Change from initial 'weekly' + expect(element.isDirty).to.be.true; + }); + + it('is dirty when triggers change', async () => { + element.open = true; + await element['_loadingTask'].run(); + await element.updateComplete; + + element['_selectedTriggers'] = ['feature_baseline_to_widely']; // Change from initial [ { value: 'feature_baseline_to_newly' } ] + expect(element.isDirty).to.be.true; + }); + + it('is not dirty when changes are reverted', async () => { + element.open = true; + await element['_loadingTask'].run(); + await element.updateComplete; + + // Simulate selecting the initial channel to set the baseline state correctly. + element['_handleChannelChange'](mockInitialSubscription.channel_id); + await element.updateComplete; + expect(element.isDirty, 'Should not be dirty after initialization').to.be + .false; + + // Make a change + element['_selectedFrequency'] = 'monthly'; + await element.updateComplete; + expect(element.isDirty, 'Should be dirty after change').to.be.true; + + // Revert the change + element['_selectedFrequency'] = mockInitialSubscription.frequency; + await element.updateComplete; + expect(element.isDirty, 'Should not be dirty after reverting change').to.be + .false; + }); + + it('dispatches SubscriptionSaveSuccessEvent on successful create', async () => { + const eventSpy = sandbox.spy(); + element.addEventListener('subscription-save-success', eventSpy); + + // Make it dirty for creation. + element.savedSearchId = 'new-saved-search-id'; + element['_activeChannelId'] = mockNotificationChannels[0].id; + element['_selectedFrequency'] = 'monthly'; + element['_selectedTriggers'] = ['feature_baseline_to_newly']; + element['_initialSelectedFrequency'] = 'immediate'; + element['_initialSelectedTriggers'] = []; + await element.updateComplete; + expect(element.isDirty).to.be.true; + + await element['_handleSave'](); + + expect(apiClient.createSubscription).to.have.been.calledWith('test-token', { + saved_search_id: 'new-saved-search-id', + channel_id: mockNotificationChannels[0].id, + frequency: 'monthly', + triggers: ['feature_baseline_to_newly'], + }); + expect(eventSpy).to.have.been.calledOnce; + }); + + it('dispatches SubscriptionSaveSuccessEvent on successful update', async () => { + const eventSpy = sandbox.spy(); + element.addEventListener('subscription-save-success', eventSpy); + + // Setup existing subscription + element['_subscriptionsForSavedSearch'] = [mockInitialSubscription]; + element['_activeChannelId'] = mockInitialSubscription.channel_id; + element['_selectedFrequency'] = 'immediate'; // Change frequency from 'weekly' + element['_initialSelectedFrequency'] = mockInitialSubscription.frequency; // Set initial + element['_initialSelectedTriggers'] = mockInitialSubscription.triggers.map(t => t.value) as components['schemas']['SubscriptionTriggerWritable'][]; + element['_selectedTriggers'] = [...mockInitialSubscription.triggers.map(t => t.value), 'feature_baseline_to_widely'] as components['schemas']['SubscriptionTriggerWritable'][]; + + await element.updateComplete; + expect(element.isDirty).to.be.true; + + await element['_handleSave'](); + + expect(apiClient.updateSubscription).to.have.been.calledWith( + mockInitialSubscription.id, + 'test-token', + { + frequency: 'immediate', + triggers: [...mockInitialSubscription.triggers.map(t => t.value), 'feature_baseline_to_widely'], + } + ); + expect(eventSpy).to.have.been.calledOnce; + }); + + it('dispatches SubscriptionSaveErrorEvent on save failure', async () => { + (apiClient.createSubscription as sinon.SinonStub) + .returns(Promise.reject(new Error('Save failed'))); + + const eventSpy = sandbox.spy(); + element.addEventListener('subscription-save-error', eventSpy); + + element.savedSearchId = 'test-search-id'; + element['_activeChannelId'] = mockNotificationChannels[0].id; + element['_selectedFrequency'] = 'monthly'; // Make it dirty + element['_initialSelectedFrequency'] = 'immediate'; + + await element['_handleSave'](); + + expect(eventSpy).to.have.been.calledOnce; + expect(eventSpy.args[0][0].detail.message).to.equal('Save failed'); + }); + + it('dispatches SubscriptionDeleteSuccessEvent on successful delete', async () => { + const eventSpy = sandbox.spy(); + element.addEventListener('subscription-delete-success', eventSpy); + + element.subscriptionId = 'test-sub-id'; + + await element['_handleDelete'](); + + expect(apiClient.deleteSubscription).to.have.been.calledWith('test-sub-id', 'test-token'); + expect(eventSpy).to.have.been.calledOnce; + }); + + it('dispatches SubscriptionDeleteErrorEvent on delete failure', async () => { + (apiClient.deleteSubscription as sinon.SinonStub) + .returns(Promise.reject(new Error('Delete failed'))); + + const eventSpy = sandbox.spy(); + element.addEventListener('subscription-delete-error', eventSpy); + + element.subscriptionId = 'test-sub-id'; + + await element['_handleDelete'](); + + expect(eventSpy).to.have.been.calledOnce; + expect(eventSpy.args[0][0].detail.message).to.equal('Delete failed'); + }); + + describe('_handleChannelChange', () => { + let confirmStub: sinon.SinonStub; + + beforeEach(async () => { + confirmStub = sandbox.stub(window, 'confirm'); + // listSubscriptions is already stubbed in the top-level beforeEach + + element.savedSearchId = 'test-search-id'; + element.open = true; + await element['_loadingTask'].taskComplete; + + // Manually set initial state for this test suite + element['_activeChannelId'] = mockInitialSubscription.channel_id; + element['_subscription'] = mockInitialSubscription; + element['_selectedTriggers'] = mockInitialSubscription.triggers.map(t => t.value) as components['schemas']['SubscriptionTriggerWritable'][]; + element['_selectedFrequency'] = mockInitialSubscription.frequency; + element['_initialSelectedTriggers'] = mockInitialSubscription.triggers.map(t => t.value) as components['schemas']['SubscriptionTriggerWritable'][]; + element['_initialSelectedFrequency'] = mockInitialSubscription.frequency; + await element.updateComplete; + + // Make it dirty by changing something on the initial channel + element['_selectedFrequency'] = 'immediate'; + await element.updateComplete; + expect(element.isDirty).to.be.true; + }); + + it('prompts user to discard changes when switching channels while dirty (cancel)', async () => { + confirmStub.returns(false); // User clicks cancel + + const originalActiveChannelId = element['_activeChannelId']; + const originalSelectedFrequency = element['_selectedFrequency']; + + element['_handleChannelChange'](mockOtherSubscription.channel_id); + await element.updateComplete; + + expect(confirmStub).to.have.been.calledOnce; + // Should revert to original channel + expect(element['_activeChannelId']).to.equal(originalActiveChannelId); + // Should keep original dirty changes + expect(element['_selectedFrequency']).to.equal(originalSelectedFrequency); + expect(element.isDirty).to.be.true; + }); + + it('discards changes and switches channels when switching channels while dirty (ok)', async () => { + confirmStub.returns(true); // User clicks OK + + element['_handleChannelChange'](mockOtherSubscription.channel_id); + await element.updateComplete; + + expect(confirmStub).to.have.been.calledOnce; + // Should switch to the new channel + expect(element['_activeChannelId']).to.equal(mockOtherSubscription.channel_id); + // Should have new settings from otherSubscription, thus no longer dirty + expect(element['_selectedFrequency']).to.equal(mockOtherSubscription.frequency); + expect(element.isDirty).to.be.false; + }); + + it('does not prompt user when switching channels while not dirty', async () => { + element['_selectedFrequency'] = mockInitialSubscription.frequency; // Make it not dirty + await element.updateComplete; + expect(element.isDirty).to.be.false; + + element['_handleChannelChange'](mockOtherSubscription.channel_id); + await element.updateComplete; + + expect(confirmStub).to.not.have.been.called; + expect(element['_activeChannelId']).to.equal(mockOtherSubscription.channel_id); + expect(element.isDirty).to.be.false; + }); + }); +}); diff --git a/frontend/src/static/js/components/test/webstatus-subscribe-button.test.ts b/frontend/src/static/js/components/test/webstatus-subscribe-button.test.ts new file mode 100644 index 000000000..a70607876 --- /dev/null +++ b/frontend/src/static/js/components/test/webstatus-subscribe-button.test.ts @@ -0,0 +1,43 @@ +/** + * 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. + */ + +import {fixture, html} from '@open-wc/testing'; +import {expect} from '@esm-bundle/chai'; +import sinon from 'sinon'; +import '../webstatus-subscribe-button.js'; +import { + SubscribeButton, + SubscribeEvent, +} from '../webstatus-subscribe-button.js'; + +describe('webstatus-subscribe-button', () => { + it('dispatches subscribe event on click', async () => { + const savedSearchId = 'test-search-id'; + const element = await fixture(html` + + `); + const eventSpy = sinon.spy(); + element.addEventListener('subscribe', eventSpy); + + element.shadowRoot?.querySelector('sl-button')?.click(); + + expect(eventSpy).to.have.been.calledOnce; + const event = eventSpy.args[0][0] as SubscribeEvent; + expect(event.detail.savedSearchId).to.equal(savedSearchId); + }); +}); diff --git a/frontend/src/static/js/components/test/webstatus-subscriptions-page.test.ts b/frontend/src/static/js/components/test/webstatus-subscriptions-page.test.ts new file mode 100644 index 000000000..9f4e81a65 --- /dev/null +++ b/frontend/src/static/js/components/test/webstatus-subscriptions-page.test.ts @@ -0,0 +1,144 @@ +/** + * 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. + */ + +import {fixture, html} from '@open-wc/testing'; +import {expect} from '@esm-bundle/chai'; +import sinon from 'sinon'; +import {APIClient} from '../../api/client.js'; +import {User} from '../../contexts/firebase-user-context.js'; +import '../webstatus-subscriptions-page.js'; +import {SubscriptionsPage} from '../webstatus-subscriptions-page.js'; +import {type components} from 'webstatus.dev-backend'; + +function mockLocation() { + let search = ''; + return { + setSearch: (s: string) => { + search = s; + }, + getLocation: (): Location => ({search} as Location), + }; +} + +describe('webstatus-subscriptions-page', () => { + let sandbox: sinon.SinonSandbox; + let apiClient: APIClient; + let user: User; + let element: SubscriptionsPage; + let mockLocationHelper: ReturnType; + + const mockSubscriptions: components['schemas']['SubscriptionResponse'][] = [ + { + id: 'sub1', + saved_search_id: 'search1', + channel_id: 'channel1', + frequency: 'weekly', + triggers: [], + created_at: new Date().toISOString(), + updated_at: new Date().toISOString(), + }, + ]; + + const mockSavedSearches: components['schemas']['SavedSearchResponse'][] = [ + { + id: 'search1', + name: 'Test Search 1', + query: 'is:test', + created_at: new Date().toISOString(), + updated_at: new Date().toISOString(), + permissions: {role: 'saved_search_owner'}, + bookmark_status: {status: 'bookmark_none'}, + }, + ]; + + beforeEach(async () => { + sandbox = sinon.createSandbox(); + apiClient = { + listSubscriptions: sandbox.stub().resolves(mockSubscriptions), + getAllUserSavedSearches: sandbox.stub().resolves(mockSavedSearches), + } as any as APIClient; + user = {getIdToken: async () => 'test-token'} as User; + mockLocationHelper = mockLocation(); + + element = await fixture(html` + + `); + element.toaster = sandbox.stub(); + await element.updateComplete; + }); + + afterEach(() => { + sandbox.restore(); + }); + + it('renders a loading spinner initially', () => { + // This is hard to test reliably as the task starts immediately. + // We'll focus on the complete and error states. + }); + + it('fetches and renders subscriptions', async () => { + await element['_loadingTask'].taskComplete; + await element.updateComplete; + + expect(apiClient.listSubscriptions).to.have.been.calledWith('test-token'); + expect(apiClient.getAllUserSavedSearches).to.have.been.calledWith( + 'test-token' + ); + const renderedText = element.shadowRoot?.textContent; + expect(renderedText).to.include('Test Search 1'); + expect(renderedText).to.include('channel1'); + expect(renderedText).to.include('weekly'); + }); + + it('opens dialog on unsubscribe link', async () => { + mockLocationHelper.setSearch('?unsubscribe=test-sub-id'); + // willUpdate is called before update, so we need to trigger an update. + element.requestUpdate(); + await element.updateComplete; + expect(element['_isSubscriptionDialogOpen']).to.be.true; + expect(element['_activeSubscriptionId']).to.equal('test-sub-id'); + }); + + it('refreshes on subscription save event', async () => { + await element['_loadingTask'].taskComplete; + await element.updateComplete; + const runSpy = sandbox.spy(element['_loadingTask'], 'run'); + + const dialog = element.shadowRoot?.querySelector( + 'webstatus-manage-subscriptions-dialog' + ); + dialog?.dispatchEvent(new CustomEvent('subscription-save-success')); + + expect(runSpy).to.have.been.calledOnce; + }); + + it('refreshes on subscription delete event', async () => { + await element['_loadingTask'].taskComplete; + await element.updateComplete; + const runSpy = sandbox.spy(element['_loadingTask'], 'run'); + + const dialog = element.shadowRoot?.querySelector( + 'webstatus-manage-subscriptions-dialog' + ); + dialog?.dispatchEvent(new CustomEvent('subscription-delete-success')); + + expect(runSpy).to.have.been.calledOnce; + }); +}); \ No newline at end of file diff --git a/frontend/src/static/js/components/webstatus-manage-subscriptions-dialog.ts b/frontend/src/static/js/components/webstatus-manage-subscriptions-dialog.ts new file mode 100644 index 000000000..f0642493a --- /dev/null +++ b/frontend/src/static/js/components/webstatus-manage-subscriptions-dialog.ts @@ -0,0 +1,409 @@ +/** + * 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. + */ + +import {consume} from '@lit/context'; +import {Task} from '@lit/task'; +import {LitElement, html, css, TemplateResult} from 'lit'; +import {customElement, property, state} from 'lit/decorators.js'; +import {apiClientContext} from '../contexts/api-client-context.js'; +import {APIClient} from '../api/client.js'; +import {SHARED_STYLES} from '../css/shared-css.js'; +import {type components} from 'webstatus.dev-backend'; +import {User, firebaseUserContext} from '../contexts/firebase-user-context.js'; + +export class SubscriptionSaveSuccessEvent extends CustomEvent { + constructor() { + super('subscription-save-success', {bubbles: true, composed: true}); + } +} + +export class SubscriptionSaveErrorEvent extends CustomEvent { + constructor(error: Error) { + super('subscription-save-error', { + bubbles: true, + composed: true, + detail: error, + }); + } +} + +export class SubscriptionDeleteSuccessEvent extends CustomEvent { + constructor() { + super('subscription-delete-success', {bubbles: true, composed: true}); + } +} + +export class SubscriptionDeleteErrorEvent extends CustomEvent { + constructor(error: Error) { + super('subscription-delete-error', { + bubbles: true, + composed: true, + detail: error, + }); + } +} + +@customElement('webstatus-manage-subscriptions-dialog') +export class ManageSubscriptionsDialog extends LitElement { + _loadingTask: Task; + + @consume({context: apiClientContext}) + @state() + apiClient!: APIClient; + + @consume({context: firebaseUserContext, subscribe: true}) + @state() + user: User | null | undefined; + + @property({type: String, attribute: 'saved-search-id'}) + savedSearchId = ''; + + @property({type: String, attribute: 'subscription-id'}) + subscriptionId = ''; + + @property({type: Boolean}) + open = false; + + @state() + private _notificationChannels: components['schemas']['NotificationChannelResponse'][] = + []; + + @state() + private _savedSearch: components['schemas']['SavedSearchResponse'] | null = + null; + + @state() + private _subscription: components['schemas']['SubscriptionResponse'] | null = + null; + + @state() + private _selectedTriggers: components['schemas']['SubscriptionTriggerWritable'][] = + []; + + @state() + private _selectedFrequency: components['schemas']['SubscriptionFrequency'] = + 'immediate'; + + @state() + private _initialSelectedTriggers: components['schemas']['SubscriptionTriggerWritable'][] = + []; + @state() + private _initialSelectedFrequency: components['schemas']['SubscriptionFrequency'] = + 'immediate'; + @state() + private _subscriptionsForSavedSearch: components['schemas']['SubscriptionResponse'][] = + []; + @state() + private _activeChannelId: string | undefined = undefined; + + static _TRIGGER_CONFIG: { + value: components['schemas']['SubscriptionTriggerWritable']; + label: string; + }[] = [ + { + value: 'feature_baseline_to_widely', + label: 'becomes widely available', + }, + { + value: 'feature_baseline_to_newly', + label: 'becomes newly available', + }, + { + value: 'feature_browser_implementation_any_complete', + label: 'gets a new browser implementation', + }, + { + value: 'feature_baseline_regression_to_limited', + label: 'regresses to limited availability', + }, + ]; + + static get styles() { + return [ + SHARED_STYLES, + css` + .dialog-overview { + --sl-dialog-width: 80vw; + } + `, + ]; + } + + get isDirty() { + console.log('isDirty check:'); + console.log(' _selectedFrequency:', this._selectedFrequency); + console.log(' _initialSelectedFrequency:', this._initialSelectedFrequency); + console.log(' _selectedTriggers:', this._selectedTriggers); + console.log(' _initialSelectedTriggers:', this._initialSelectedTriggers); + + if (this._selectedFrequency !== this._initialSelectedFrequency) { + return true; + } + + const sortedCurrent = [...this._selectedTriggers].sort(); + const sortedInitial = [...this._initialSelectedTriggers].sort(); + + if (sortedCurrent.length !== sortedInitial.length) { + return true; + } + + for (let i = 0; i < sortedCurrent.length; i++) { + if (sortedCurrent[i] !== sortedInitial[i]) { + return true; + } + } + + return false; + } + + constructor() { + super(); + this._loadingTask = new Task(this, { + args: () => [ + this.apiClient, + this.savedSearchId, + this.subscriptionId, + this.open, + ], + task: async ([apiClient, savedSearchId, subscriptionId, open]) => { + if (!open || !apiClient || !this.user) { + return; + } + const token = await this.user.getIdToken(); + + const promises = []; + promises.push( + apiClient.listNotificationChannels(token).then(r => { + this._notificationChannels = r || []; + }), + ); + + if (savedSearchId) { + promises.push( + apiClient.getSavedSearchByID(savedSearchId, token).then(r => { + this._savedSearch = r; + }), + ); + promises.push( + apiClient.listSubscriptions(token).then(r => { + this._subscriptionsForSavedSearch = + r.filter(s => s.saved_search_id === savedSearchId) || []; + }), + ); + } + + if (subscriptionId) { + // TODO: Fetch subscription details + } + + await Promise.all(promises); + }, + }); + } + + render(): TemplateResult { + return html` + (this.open = false)} + > + ${this._loadingTask.render({ + pending: () => html``, + complete: () => this.renderContent(), + error: e => html`Error: ${e}`, + })} + + `; + } + + renderContent(): TemplateResult { + const confirmDeletion = this.subscriptionId && !this.savedSearchId; + if (confirmDeletion) { + return html` +

Are you sure you want to unsubscribe?

+ + Confirm Unsubscribe + + `; + } + + return html` +

+ Select how and when you want to get updates for + ${this._subscription?.saved_search_id ?? + this._savedSearch?.name}. +

+ +
+
+

Notification channels

+ ${this._notificationChannels.map( + channel => html` + { + this._handleChannelChange(channel.id); + }} + >${channel.name} (${channel.type}) + `, + )} +
+
+

Triggers

+

Get an update when a feature...

+ ${ManageSubscriptionsDialog._TRIGGER_CONFIG.map( + trigger => html` + { + const checkbox = e.target as HTMLInputElement; + if (checkbox.checked) { + this._selectedTriggers.push(trigger.value); + } else { + this._selectedTriggers = this._selectedTriggers.filter( + t => t !== trigger.value, + ); + } + }} + >...${trigger.label} + `, + )} +
+
+

Frequency

+ { + const radioGroup = e.target as HTMLInputElement; + this._selectedFrequency = + radioGroup.value as components['schemas']['SubscriptionFrequency']; + }} + > + Immediately + Weekly updates + Monthly updates + +
+
+ + Save + `; + } + + private async _handleSave() { + if (!this.user || !this.isDirty || !this._activeChannelId) { + return; + } + try { + const token = await this.user.getIdToken(); + const existingSub = this._subscriptionsForSavedSearch.find( + s => s.channel_id === this._activeChannelId, + ); + + if (existingSub) { + // Update + const updates: { + triggers?: components['schemas']['SubscriptionTriggerWritable'][]; + frequency?: components['schemas']['SubscriptionFrequency']; + } = {}; + if (this._selectedFrequency !== this._initialSelectedFrequency) { + updates.frequency = this._selectedFrequency; + } + const triggersChanged = + this._selectedTriggers.length !== + this._initialSelectedTriggers.length || + [...this._selectedTriggers].sort().join(',') !== + [...this._initialSelectedTriggers].sort().join(','); + + if (triggersChanged) { + updates.triggers = this._selectedTriggers; + } + + await this.apiClient.updateSubscription(existingSub.id, token, updates); + } else { + // Create + await this.apiClient.createSubscription(token, { + saved_search_id: this.savedSearchId, + channel_id: this._activeChannelId, + frequency: this._selectedFrequency, + triggers: this._selectedTriggers, + }); + } + this.dispatchEvent(new SubscriptionSaveSuccessEvent()); + } catch (e) { + this.dispatchEvent(new SubscriptionSaveErrorEvent(e as Error)); + } + } + + private async _handleDelete() { + if (!this.subscriptionId || !this.user) { + return; + } + try { + const token = await this.user.getIdToken(); + await this.apiClient.deleteSubscription(this.subscriptionId, token); + this.dispatchEvent(new SubscriptionDeleteSuccessEvent()); + } catch (e) { + this.dispatchEvent(new SubscriptionDeleteErrorEvent(e as Error)); + } + } + + private _handleChannelChange(channelId: string) { + const previousActiveChannelId = this._activeChannelId; + if (this.isDirty) { + if (!confirm('You have unsaved changes. Discard them?')) { + // If user cancels, prevent channel change. The UI will naturally + // remain on the previously active radio button due to Lit's rendering. + // Alternatives include explicitly re-checking the old radio button or + // presenting a more advanced 'Save/Discard/Cancel' dialog. + this._activeChannelId = previousActiveChannelId; // Explicitly revert UI + return; + } + } + + this._activeChannelId = channelId; + const sub = this._subscriptionsForSavedSearch.find( + s => s.channel_id === channelId, + ); + if (sub) { + this._subscription = sub; + this._activeChannelId = sub.channel_id; + this._selectedTriggers = sub.triggers.map( + t => t.value as components['schemas']['SubscriptionTriggerWritable'], + ); + this._selectedFrequency = sub.frequency; + } else { + this._subscription = null; + this._selectedTriggers = []; + this._selectedFrequency = 'immediate'; + } + this._initialSelectedTriggers = [...this._selectedTriggers]; + this._initialSelectedFrequency = this._selectedFrequency; + } +} diff --git a/frontend/src/static/js/components/webstatus-overview-content.ts b/frontend/src/static/js/components/webstatus-overview-content.ts index d57532f17..ce24ace28 100644 --- a/frontend/src/static/js/components/webstatus-overview-content.ts +++ b/frontend/src/static/js/components/webstatus-overview-content.ts @@ -30,6 +30,8 @@ import {type components} from 'webstatus.dev-backend'; import './webstatus-overview-data-loader.js'; import './webstatus-overview-filters.js'; import './webstatus-overview-pagination.js'; +import './webstatus-subscribe-button.js'; +import './webstatus-manage-subscriptions-dialog.js'; import {SHARED_STYLES} from '../css/shared-css.js'; import {TaskTracker} from '../utils/task-tracker.js'; import {ApiError} from '../api/errors.js'; @@ -53,6 +55,13 @@ import { SavedSearchOperationType, UserSavedSearch, } from '../utils/constants.js'; +import {SubscribeEvent} from './webstatus-subscribe-button.js'; +import {ifDefined} from 'lit/directives/if-defined.js'; +import {toast} from '../utils/toast.js'; +import { + SubscriptionSaveErrorEvent, + SubscriptionDeleteErrorEvent, +} from './webstatus-manage-subscriptions-dialog.js'; @customElement('webstatus-overview-content') export class WebstatusOverviewContent extends LitElement { @@ -86,6 +95,12 @@ export class WebstatusOverviewContent extends LitElement { @query('webstatus-saved-search-editor') savedSearchEditor!: WebstatusSavedSearchEditor; + @state() + private _isSubscriptionDialogOpen = false; + + @state() + private _activeSavedSearchId: string | undefined = undefined; + static get styles(): CSSResultGroup { return [ SHARED_STYLES, @@ -235,6 +250,12 @@ export class WebstatusOverviewContent extends LitElement { .user=${this.user} .apiClient=${this.apiClient} > + ${userSavedSearch + ? html`` + : nothing}
+ (this._isSubscriptionDialogOpen = false)} + @subscription-save-success=${this._handleSubscriptionSaveSuccess} + @subscription-save-error=${this._handleSubscriptionSaveError} + @subscription-delete-success=${this._handleSubscriptionDeleteSuccess} + @subscription-delete-error=${this._handleSubscriptionDeleteError} + > + `; } + + private _handleSubscribe(e: SubscribeEvent) { + this._activeSavedSearchId = e.detail.savedSearchId; + this._isSubscriptionDialogOpen = true; + } + + private _handleSubscriptionSaveSuccess() { + this._isSubscriptionDialogOpen = false; + void toast('Subscription saved!', 'success'); + } + + private _handleSubscriptionSaveError(e: SubscriptionSaveErrorEvent) { + void toast(`Error saving subscription: ${e.detail.message}`, 'danger'); + } + + private _handleSubscriptionDeleteSuccess() { + this._isSubscriptionDialogOpen = false; + void toast('Subscription deleted!', 'success'); + } + + private _handleSubscriptionDeleteError(e: SubscriptionDeleteErrorEvent) { + void toast(`Error deleting subscription: ${e.detail.message}`, 'danger'); + } } diff --git a/frontend/src/static/js/components/webstatus-subscribe-button.ts b/frontend/src/static/js/components/webstatus-subscribe-button.ts new file mode 100644 index 000000000..5617d76fc --- /dev/null +++ b/frontend/src/static/js/components/webstatus-subscribe-button.ts @@ -0,0 +1,53 @@ +/** + * 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. + */ + +import {LitElement, html, css, TemplateResult} from 'lit'; +import {customElement, property} from 'lit/decorators.js'; + +export class SubscribeEvent extends CustomEvent<{savedSearchId: string}> { + constructor(savedSearchId: string) { + super('subscribe', { + bubbles: true, + composed: true, + detail: {savedSearchId}, + }); + } +} + +@customElement('webstatus-subscribe-button') +export class SubscribeButton extends LitElement { + @property({type: String, attribute: 'saved-search-id'}) + savedSearchId = ''; + + static styles = css` + sl-button::part(base) { + font-size: var(--sl-button-font-size-medium); + } + `; + + private _handleClick() { + this.dispatchEvent(new SubscribeEvent(this.savedSearchId)); + } + + render(): TemplateResult { + return html` + + + Subscribe to updates + + `; + } +} diff --git a/frontend/src/static/js/components/webstatus-subscriptions-page.ts b/frontend/src/static/js/components/webstatus-subscriptions-page.ts new file mode 100644 index 000000000..9d92bb941 --- /dev/null +++ b/frontend/src/static/js/components/webstatus-subscriptions-page.ts @@ -0,0 +1,183 @@ +/** + * 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. + */ + +import {LitElement, html, TemplateResult} from 'lit'; +import {customElement, state, property} from 'lit/decorators.js'; +import {Task} from '@lit/task'; +import {consume} from '@lit/context'; +import {APIClient} from '../api/client.js'; +import {apiClientContext} from '../contexts/api-client-context.js'; +import {User, firebaseUserContext} from '../contexts/firebase-user-context.js'; +import {toast} from '../utils/toast.js'; +import { + SubscriptionSaveErrorEvent, + SubscriptionDeleteErrorEvent, +} from './webstatus-manage-subscriptions-dialog.js'; +import {ifDefined} from 'lit/directives/if-defined.js'; +import {type components} from 'webstatus.dev-backend'; + +interface GetLocationFunction { + (): Location; +} + +@customElement('webstatus-subscriptions-page') +export class SubscriptionsPage extends LitElement { + _loadingTask: Task; + + @consume({context: apiClientContext}) + @state() + apiClient!: APIClient; + + @consume({context: firebaseUserContext, subscribe: true}) + @state() + user: User | null | undefined; + + @property({attribute: false}) + getLocation: GetLocationFunction = () => window.location; + + @property({attribute: false}) + toaster = toast; + + @state() + private _isSubscriptionDialogOpen = false; + + @state() + private _activeSubscriptionId: string | undefined = undefined; + + @state() + private _subscriptions: components['schemas']['SubscriptionResponse'][] = []; + + @state() + private _savedSearches: Map< + string, + components['schemas']['SavedSearchResponse'] + > = new Map(); + + constructor() { + super(); + this._loadingTask = new Task(this, { + args: () => [this.apiClient, this.user], + task: async ([apiClient, user]) => { + if (!apiClient || !user) { + return; + } + const token = await user.getIdToken(); + + const [subscriptions, savedSearches] = await Promise.all([ + apiClient.listSubscriptions(token), + apiClient.getAllUserSavedSearches(token), + ]); + + this._subscriptions = subscriptions; + this._savedSearches = new Map(savedSearches.map(ss => [ss.id, ss])); + }, + }); + } + + willUpdate() { + const urlParams = new URLSearchParams(this.getLocation().search); + const unsubscribeToken = urlParams.get('unsubscribe'); + if (unsubscribeToken) { + this._activeSubscriptionId = unsubscribeToken; + this._isSubscriptionDialogOpen = true; + } + } + + render(): TemplateResult { + return html` +

My Subscriptions

+ ${this._loadingTask.render({ + pending: () => html``, + complete: () => this.renderSubscriptions(), + error: e => html`Error: ${e}`, + })} + (this._isSubscriptionDialogOpen = false)} + @subscription-save-success=${this._handleSubscriptionSaveSuccess} + @subscription-save-error=${this._handleSubscriptionSaveError} + @subscription-delete-success=${this._handleSubscriptionDeleteSuccess} + @subscription-delete-error=${this._handleSubscriptionDeleteError} + > + + `; + } + + private renderSubscriptions(): TemplateResult { + if (this._subscriptions.length === 0) { + return html`

No subscriptions found.

`; + } + + return html` +
    + ${this._subscriptions.map(sub => { + const savedSearch = this._savedSearches.get(sub.saved_search_id); + return html` +
  • + ${savedSearch?.name ?? sub.saved_search_id} + (Channel: ${sub.channel_id}, Frequency: ${sub.frequency}) + this._openEditDialog(sub.id)} + >Edit + this._openDeleteDialog(sub.id)} + >Delete +
  • + `; + })} +
+ `; + } + + private _openEditDialog(subscriptionId: string) { + this._activeSubscriptionId = subscriptionId; + this._isSubscriptionDialogOpen = true; + } + + private _openDeleteDialog(subscriptionId: string) { + this._activeSubscriptionId = subscriptionId; + // In this case, we're initiating a delete from the list, not an unsubscribe link. + // The dialog itself will handle the confirmation internally. + this._isSubscriptionDialogOpen = true; + // The dialog should internally check if it's a delete scenario via subscriptionId only + // and render the confirmation view if savedSearchId is not present. + } + + private _handleSubscriptionSaveSuccess() { + this._isSubscriptionDialogOpen = false; + void this.toaster('Subscription saved!', 'success'); + void this._loadingTask.run(); + } + + private _handleSubscriptionSaveError(e: SubscriptionSaveErrorEvent) { + void this.toaster(`Error saving subscription: ${e.detail.message}`, 'danger'); + } + + private _handleSubscriptionDeleteSuccess() { + this._isSubscriptionDialogOpen = false; + void this.toaster('Subscription deleted!', 'success'); + void this._loadingTask.run(); + } + + private _handleSubscriptionDeleteError(e: SubscriptionDeleteErrorEvent) { + void this.toaster(`Error deleting subscription: ${e.detail.message}`, 'danger'); + } +} diff --git a/frontend/src/static/js/contexts/api-client-context.ts b/frontend/src/static/js/contexts/api-client-context.ts index eea6dab01..973678a29 100644 --- a/frontend/src/static/js/contexts/api-client-context.ts +++ b/frontend/src/static/js/contexts/api-client-context.ts @@ -17,6 +17,6 @@ import {createContext} from '@lit/context'; import type {APIClient} from '../api/client.js'; -export type {APIClient} from '../api/client.js'; +export {APIClient} from '../api/client.js'; export const apiClientContext = createContext('api-client'); diff --git a/frontend/src/static/js/utils/app-router.ts b/frontend/src/static/js/utils/app-router.ts index 1b882c85f..709056f07 100644 --- a/frontend/src/static/js/utils/app-router.ts +++ b/frontend/src/static/js/utils/app-router.ts @@ -22,6 +22,7 @@ import '../components/webstatus-stats-page.js'; import '../components/webstatus-notfound-error-page.js'; import '../components/webstatus-feature-gone-split-page.js'; import '../components/webstatus-notification-channels-page.js'; +import '../components/webstatus-subscriptions-page.js'; export const initRouter = async (element: HTMLElement): Promise => { const router = new Router(element); @@ -42,6 +43,10 @@ export const initRouter = async (element: HTMLElement): Promise => { component: 'webstatus-notification-channels-page', path: '/settings/notification-channels', }, + { + component: 'webstatus-subscriptions-page', + path: '/settings/subscriptions', + }, { component: 'webstatus-feature-gone-split-page', path: '/errors-410/feature-gone-split', diff --git a/lib/email/smtpsender/client.go b/lib/email/smtpsender/client.go new file mode 100644 index 000000000..a42be3607 --- /dev/null +++ b/lib/email/smtpsender/client.go @@ -0,0 +1,71 @@ +// 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 smtpsender + +import ( + "errors" + "fmt" + "net/smtp" +) + +// SMTPClientConfig holds configuration for the SMTP client. +type SMTPClientConfig struct { + Host string + Port int + Username string + Password string +} + +type Client struct { + config SMTPClientConfig + send sendFunc + addr string + auth smtp.Auth + from string +} + +type sendFunc func(addr string, a smtp.Auth, from string, to []string, msg []byte) error + +// NewClient creates a new Client. +func NewClient(cfg SMTPClientConfig, from string) (*Client, error) { + if cfg.Host == "" || cfg.Port == 0 { + return nil, fmt.Errorf("%w: SMTP host and port are required", ErrSMTPConfig) + } + var auth smtp.Auth + if cfg.Username != "" && cfg.Password != "" { + auth = smtp.PlainAuth("", cfg.Username, cfg.Password, cfg.Host) + } + addr := fmt.Sprintf("%s:%d", cfg.Host, cfg.Port) + + return &Client{config: cfg, send: smtp.SendMail, auth: auth, addr: addr, from: from}, nil +} + +func (c *Client) From() string { + return c.from +} + +func (c *Client) SendMail(to []string, msg []byte) error { + err := c.send(c.addr, c.auth, c.from, to, msg) + if err != nil { + return fmt.Errorf("%w: failed to send email: %w", ErrSMTPFailedSend, err) + } + + return nil +} + +var ( + ErrSMTPConfig = errors.New("smtp configuration error") + ErrSMTPFailedSend = errors.New("smtp failed to send email") +) diff --git a/lib/email/smtpsender/client_test.go b/lib/email/smtpsender/client_test.go new file mode 100644 index 000000000..6d982c334 --- /dev/null +++ b/lib/email/smtpsender/client_test.go @@ -0,0 +1,106 @@ +// 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 smtpsender + +import ( + "errors" + "net/smtp" + "testing" +) + +func TestNewSMTPClient(t *testing.T) { + t.Parallel() + var emptyCfg SMTPClientConfig + + testCases := []struct { + name string + config SMTPClientConfig + expectErr error + }{ + { + name: "Valid config", + config: SMTPClientConfig{Host: "localhost", Port: 1025, Username: "", Password: ""}, + expectErr: nil, + }, + { + name: "Missing host", + config: SMTPClientConfig{Host: "", Port: 1025, Username: "", Password: ""}, + expectErr: ErrSMTPConfig, + }, + { + name: "Missing port", + config: SMTPClientConfig{Host: "localhost", Port: 0, Username: "", Password: ""}, + expectErr: ErrSMTPConfig, + }, + { + name: "Empty config", + config: emptyCfg, + expectErr: ErrSMTPConfig, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + client, err := NewClient(tc.config, "from@example.com") + if !errors.Is(err, tc.expectErr) { + t.Errorf("Expected error wrapping %v, but got %v", tc.expectErr, err) + } + if err == nil && client == nil { + t.Fatal("Expected client, but got nil") + } + }) + } +} + +func TestSMTPClient_SendMail(t *testing.T) { + cfg := SMTPClientConfig{Host: "localhost", Port: 1025, Username: "fake", Password: "fake"} + + testCases := []struct { + name string + mockSendMail func(addr string, a smtp.Auth, from string, to []string, msg []byte) error + expectedError error + }{ + { + name: "Successful send", + mockSendMail: func(_ string, _ smtp.Auth, _ string, _ []string, _ []byte) error { + return nil + }, + expectedError: nil, + }, + { + name: "Some error", + mockSendMail: func(_ string, _ smtp.Auth, _ string, _ []string, _ []byte) error { + return errors.New("connection refused") + }, + expectedError: ErrSMTPFailedSend, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + client, err := NewClient(cfg, "from@example.com") + if err != nil { + t.Fatalf("Failed to create client: %v", err) + } + client.send = tc.mockSendMail + + err = client.SendMail([]string{"to@example.com"}, []byte("body")) + + if !errors.Is(err, tc.expectedError) { + t.Errorf("Expected error wrapping %v, but got %v", tc.expectedError, err) + } + }) + } +} diff --git a/lib/email/smtpsender/smtpsenderadapters/email_worker.go b/lib/email/smtpsender/smtpsenderadapters/email_worker.go new file mode 100644 index 000000000..da0d79ad1 --- /dev/null +++ b/lib/email/smtpsender/smtpsenderadapters/email_worker.go @@ -0,0 +1,64 @@ +// 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 smtpsenderadapters + +import ( + "context" + "errors" + "log/slog" + + "github.com/GoogleChrome/webstatus.dev/lib/workertypes" +) + +// EmailWorkerSMTPAdapter implements the interface for the email worker +// using the SMTP client. +type EmailWorkerSMTPAdapter struct { + sender Sender +} + +type Sender interface { + SendMail(to []string, msg []byte) error + From() string +} + +// NewEmailWorkerSMTPAdapter creates a new adapter for the email worker to use SMTP. +func NewEmailWorkerSMTPAdapter(client Sender) *EmailWorkerSMTPAdapter { + return &EmailWorkerSMTPAdapter{ + sender: client, + } +} + +// Send implements the EmailSender interface for the email worker. +func (a *EmailWorkerSMTPAdapter) Send(ctx context.Context, id string, + to string, + subject string, + htmlBody string) error { + + slog.InfoContext(ctx, "sending email via SMTP", "to", to, "id", id) + + msg := []byte("To: " + to + "\r\n" + + "From: " + a.sender.From() + "\r\n" + + "Subject: " + subject + "\r\n" + + "Content-Type: text/html; charset=UTF-8\r\n" + + "\r\n" + htmlBody) + + err := a.sender.SendMail([]string{to}, msg) + if err != nil { + return errors.Join(workertypes.ErrUnrecoverableSystemFailureEmailSending, err) + + } + + return nil +} diff --git a/lib/email/smtpsender/smtpsenderadapters/email_worker_test.go b/lib/email/smtpsender/smtpsenderadapters/email_worker_test.go new file mode 100644 index 000000000..eeda90036 --- /dev/null +++ b/lib/email/smtpsender/smtpsenderadapters/email_worker_test.go @@ -0,0 +1,75 @@ +// 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 smtpsenderadapters + +import ( + "context" + "errors" + "testing" + + "github.com/GoogleChrome/webstatus.dev/lib/email/smtpsender" + "github.com/GoogleChrome/webstatus.dev/lib/workertypes" +) + +// mockSMTPSenderClient is a mock implementation of Sender for testing. +type mockSMTPSenderClient struct { + sendMailErr error +} + +func (m *mockSMTPSenderClient) SendMail(_ []string, _ []byte) error { + return m.sendMailErr +} + +func (m *mockSMTPSenderClient) From() string { + return "from@example.com" +} + +func TestEmailWorkerSmtpAdapter_Send(t *testing.T) { + ctx := context.Background() + + testCases := []struct { + name string + smtpSendErr error + expectedError error + }{ + { + name: "Success", + smtpSendErr: nil, + expectedError: nil, + }, + { + name: "SMTP Failed Send Error", + smtpSendErr: smtpsender.ErrSMTPFailedSend, + expectedError: workertypes.ErrUnrecoverableSystemFailureEmailSending, + }, + { + name: "SMTP Config Error", + smtpSendErr: smtpsender.ErrSMTPConfig, + expectedError: workertypes.ErrUnrecoverableSystemFailureEmailSending, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + mockClient := &mockSMTPSenderClient{sendMailErr: tc.smtpSendErr} + adapter := NewEmailWorkerSMTPAdapter(mockClient) + + err := adapter.Send(ctx, "test-id", "to@example.com", "Test Subject", "

Hello

") + if !errors.Is(err, tc.expectedError) { + t.Errorf("Expected error wrapping %v, but got %v (raw: %v)", tc.expectedError, err, errors.Unwrap(err)) + } + }) + } +} diff --git a/lib/gcppubsub/gcppubsubadapters/backend.go b/lib/gcppubsub/gcppubsubadapters/backend.go new file mode 100644 index 000000000..8b75feb43 --- /dev/null +++ b/lib/gcppubsub/gcppubsubadapters/backend.go @@ -0,0 +1,67 @@ +// Copyright 2026 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 gcppubsubadapters + +import ( + "context" + "fmt" + "log/slog" + + "github.com/GoogleChrome/webstatus.dev/lib/event" + searchconfigv1 "github.com/GoogleChrome/webstatus.dev/lib/event/searchconfigurationchanged/v1" + "github.com/GoogleChrome/webstatus.dev/lib/gen/openapi/backend" +) + +type BackendAdapter struct { + client EventPublisher + topicID string +} + +func NewBackendAdapter(client EventPublisher, topicID string) *BackendAdapter { + return &BackendAdapter{client: client, topicID: topicID} +} + +func (p *BackendAdapter) PublishSearchConfigurationChanged( + ctx context.Context, + resp *backend.SavedSearchResponse, + userID string, + isCreation bool) error { + + evt := searchconfigv1.SearchConfigurationChangedEvent{ + SearchID: resp.Id, + Query: resp.Query, + UserID: userID, + Timestamp: resp.UpdatedAt, + IsCreation: isCreation, + Frequency: searchconfigv1.FrequencyImmediate, + } + + msg, err := event.New(evt) + if err != nil { + return fmt.Errorf("failed to create event: %w", err) + } + + id, err := p.client.Publish(ctx, p.topicID, msg) + if err != nil { + return fmt.Errorf("failed to publish message: %w", err) + } + + slog.InfoContext(ctx, "published search configuration changed event", + "msgID", id, + "searchID", evt.SearchID, + "isCreation", evt.IsCreation) + + return nil +} diff --git a/lib/gcppubsub/gcppubsubadapters/backend_test.go b/lib/gcppubsub/gcppubsubadapters/backend_test.go new file mode 100644 index 000000000..061d5a462 --- /dev/null +++ b/lib/gcppubsub/gcppubsubadapters/backend_test.go @@ -0,0 +1,136 @@ +// 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 gcppubsubadapters + +import ( + "context" + "encoding/json" + "errors" + "testing" + "time" + + "github.com/GoogleChrome/webstatus.dev/lib/gen/openapi/backend" + "github.com/google/go-cmp/cmp" +) + +func testSavedSearchResponse(id string, query string, updatedAt time.Time) *backend.SavedSearchResponse { + var resp backend.SavedSearchResponse + resp.Id = id + resp.Query = query + resp.UpdatedAt = updatedAt + + return &resp +} +func TestSearchConfigurationPublisherAdapter_Publish(t *testing.T) { + fixedTime := time.Date(2025, 1, 1, 0, 0, 0, 0, time.UTC) + + tests := []struct { + name string + resp *backend.SavedSearchResponse + userID string + isCreation bool + publishErr error + wantErr bool + expectedJSON string + }{ + { + name: "success creation", + resp: testSavedSearchResponse("search-123", "group:css", fixedTime), + userID: "user-1", + isCreation: true, + publishErr: nil, + wantErr: false, + expectedJSON: `{ + "apiVersion": "v1", + "kind": "SearchConfigurationChangedEvent", + "data": { + "search_id": "search-123", + "query": "group:css", + "user_id": "user-1", + "timestamp": "2025-01-01T00:00:00Z", + "is_creation": true, + "frequency": "IMMEDIATE" + } + }`, + }, + { + name: "success update", + resp: testSavedSearchResponse("search-456", "group:html", fixedTime.Add(24*time.Hour)), + userID: "user-1", + isCreation: false, + publishErr: nil, + wantErr: false, + expectedJSON: `{ + "apiVersion": "v1", + "kind": "SearchConfigurationChangedEvent", + "data": { + "search_id": "search-456", + "query": "group:html", + "user_id": "user-1", + "timestamp": "2025-01-02T00:00:00Z", + "is_creation": false, + "frequency": "IMMEDIATE" + } + }`, + }, + { + name: "publish error", + resp: testSavedSearchResponse("search-err", "group:html", fixedTime), + userID: "user-1", + isCreation: false, + publishErr: errors.New("pubsub error"), + wantErr: true, + expectedJSON: "", + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + publisher := new(mockPublisher) + publisher.err = tc.publishErr + adapter := NewBackendAdapter(publisher, "test-topic") + + err := adapter.PublishSearchConfigurationChanged(context.Background(), tc.resp, tc.userID, tc.isCreation) + + if (err != nil) != tc.wantErr { + t.Errorf("PublishSearchConfigurationChanged() error = %v, wantErr %v", err, tc.wantErr) + } + + if tc.wantErr { + return + } + + if publisher.publishedTopic != "test-topic" { + t.Errorf("Topic mismatch: got %s, want test-topic", publisher.publishedTopic) + } + + // Unmarshal actual data + var actual interface{} + if err := json.Unmarshal(publisher.publishedData, &actual); err != nil { + t.Fatalf("failed to unmarshal published data: %v", err) + } + + // Unmarshal expected data + var expected interface{} + if err := json.Unmarshal([]byte(tc.expectedJSON), &expected); err != nil { + t.Fatalf("failed to unmarshal expected data: %v", err) + } + + if diff := cmp.Diff(expected, actual); diff != "" { + t.Errorf("Payload mismatch (-want +got):\n%s", diff) + } + }) + } +} diff --git a/lib/gcpspanner/feature_group_lookups.go b/lib/gcpspanner/feature_group_lookups.go index 120c15926..6fb81e239 100644 --- a/lib/gcpspanner/feature_group_lookups.go +++ b/lib/gcpspanner/feature_group_lookups.go @@ -124,3 +124,30 @@ func calculateAllFeatureGroupLookups( } } } + +// AddFeatureToGroup adds a feature to a group. +func (c *Client) AddFeatureToGroup(ctx context.Context, featureKey, groupKey string) error { + _, err := c.ReadWriteTransaction(ctx, func(ctx context.Context, txn *spanner.ReadWriteTransaction) error { + featureID, err := c.GetIDFromFeatureKey(ctx, NewFeatureKeyFilter(featureKey)) + if err != nil { + return err + } + groupID, err := c.GetGroupIDFromGroupKey(ctx, groupKey) + if err != nil { + return err + } + m, err := spanner.InsertStruct(featureGroupKeysLookupTable, spannerFeatureGroupKeysLookup{ + GroupID: *groupID, + GroupKeyLowercase: groupKey, + WebFeatureID: *featureID, + Depth: 0, + }) + if err != nil { + return err + } + + return txn.BufferWrite([]*spanner.Mutation{m}) + }) + + return err +} diff --git a/lib/gcpspanner/saved_search_subscription.go b/lib/gcpspanner/saved_search_subscription.go index 4dcd93eff..a69ab756d 100644 --- a/lib/gcpspanner/saved_search_subscription.go +++ b/lib/gcpspanner/saved_search_subscription.go @@ -412,3 +412,27 @@ func (c *Client) FindAllActivePushSubscriptions( return results, nil } + +// DeleteUserSubscriptions deletes all saved search subscriptions for a given list of user IDs. +// Used for E2E tests. +func (c *Client) DeleteUserSubscriptions(ctx context.Context, userIDs []string) error { + _, err := c.ReadWriteTransaction(ctx, func(ctx context.Context, txn *spanner.ReadWriteTransaction) error { + _, err := txn.Update(ctx, spanner.Statement{ + SQL: `DELETE FROM SavedSearchSubscriptions WHERE ChannelID IN + (SELECT ID FROM NotificationChannels WHERE UserID IN UNNEST(@userIDs))`, + Params: map[string]interface{}{ + "userIDs": userIDs, + }, + }) + if err != nil { + return errors.Join(ErrInternalQueryFailure, err) + } + + return nil + }) + if err != nil { + return fmt.Errorf("failed to delete user subscriptions: %w", err) + } + + return nil +} diff --git a/lib/gcpspanner/web_features.go b/lib/gcpspanner/web_features.go index 292499b9e..4aa005392 100644 --- a/lib/gcpspanner/web_features.go +++ b/lib/gcpspanner/web_features.go @@ -513,6 +513,22 @@ func (c *Client) FetchAllFeatureKeys(ctx context.Context) ([]string, error) { return fetchSingleColumnValuesWithTransaction[string](ctx, txn, webFeaturesTable, "FeatureKey") } +// UpdateFeatureDescription updates the description of a web feature. +// Useful for e2e tests. +func (c *Client) UpdateFeatureDescription( + ctx context.Context, featureKey, newDescription string) error { + _, err := c.ReadWriteTransaction(ctx, func(_ context.Context, txn *spanner.ReadWriteTransaction) error { + return txn.BufferWrite([]*spanner.Mutation{ + spanner.Update(webFeaturesTable, + []string{"FeatureKey", "Description"}, + []any{featureKey, newDescription}, + ), + }) + }) + + return err +} + type SpannerFeatureIDAndKey struct { ID string `spanner:"ID"` FeatureKey string `spanner:"FeatureKey"` diff --git a/skaffold.yaml b/skaffold.yaml index be8cdd607..8dff3e4f4 100644 --- a/skaffold.yaml +++ b/skaffold.yaml @@ -19,3 +19,4 @@ metadata: requires: - path: ./backend - path: ./frontend + - path: ./workers diff --git a/util/cmd/load_fake_data/main.go b/util/cmd/load_fake_data/main.go index 53186d190..4f2ca617d 100644 --- a/util/cmd/load_fake_data/main.go +++ b/util/cmd/load_fake_data/main.go @@ -248,6 +248,13 @@ func resetTestData(ctx context.Context, spannerClient *gcpspanner.Client, authCl return nil } + // Delete all subscriptions for the test users. + err := spannerClient.DeleteUserSubscriptions(ctx, userIDs) + if err != nil { + return fmt.Errorf("failed to delete test user subscriptions: %w", err) + } + slog.InfoContext(ctx, "Deleted subscriptions for test users") + for _, userID := range userIDs { page, err := spannerClient.ListUserSavedSearches(ctx, userID, 1000, nil) if err != nil { @@ -740,6 +747,59 @@ func generateSavedSearchBookmarks(ctx context.Context, spannerClient *gcpspanner return len(bookmarksToInsert), nil } +func generateSubscriptions(ctx context.Context, spannerClient *gcpspanner.Client, + authClient *auth.Client) (int, error) { + // Get the channel ID for test.user.1@example.com's primary email. + userID, err := findUserIDByEmail(ctx, "test.user.1@example.com", authClient) + if err != nil { + return 0, fmt.Errorf("could not find userID for test.user.1@example.com: %w", err) + } + channels, _, err := spannerClient.ListNotificationChannels(ctx, gcpspanner.ListNotificationChannelsRequest{ + UserID: userID, + PageSize: 100, + PageToken: nil, + }) + if err != nil { + return 0, fmt.Errorf("could not list notification channels for user %s: %w", userID, err) + } + if len(channels) == 0 { + return 0, fmt.Errorf("no notification channels found for user %s", userID) + } + primaryChannelID := channels[0].ID + + subscriptionsToInsert := []struct { + SavedSearchUUID string + ChannelID string + Frequency gcpspanner.SavedSearchSnapshotType + Triggers []gcpspanner.SubscriptionTrigger + }{ + { + // Subscription for "my first project query" + SavedSearchUUID: "74bdb85f-59d3-43b0-8061-20d5818e8c97", + ChannelID: primaryChannelID, + Frequency: gcpspanner.SavedSearchSnapshotTypeWeekly, + Triggers: []gcpspanner.SubscriptionTrigger{ + gcpspanner.SubscriptionTriggerFeatureBaselinePromoteToWidely, + }, + }, + } + + for _, sub := range subscriptionsToInsert { + _, err := spannerClient.CreateSavedSearchSubscription(ctx, gcpspanner.CreateSavedSearchSubscriptionRequest{ + UserID: userID, + ChannelID: sub.ChannelID, + SavedSearchID: sub.SavedSearchUUID, + Triggers: sub.Triggers, + Frequency: sub.Frequency, + }) + if err != nil { + return 0, fmt.Errorf("failed to create subscription for saved search %s: %w", sub.SavedSearchUUID, err) + } + } + + return len(subscriptionsToInsert), nil +} + func generateUserData(ctx context.Context, spannerClient *gcpspanner.Client, authClient *auth.Client) error { savedSearchesCount, err := generateSavedSearches(ctx, spannerClient, authClient) @@ -757,6 +817,13 @@ func generateUserData(ctx context.Context, spannerClient *gcpspanner.Client, slog.InfoContext(ctx, "saved search bookmarks generated", "amount of bookmarks created", bookmarkCount) + subscriptionsCount, err := generateSubscriptions(ctx, spannerClient, authClient) + if err != nil { + return fmt.Errorf("subscriptions generation failed %w", err) + } + slog.InfoContext(ctx, "subscriptions generated", + "amount of subscriptions created", subscriptionsCount) + return nil } func generateData(ctx context.Context, spannerClient *gcpspanner.Client, datastoreClient *gds.Client) error { @@ -1354,6 +1421,7 @@ func main() { datastoreDatabase = flag.String("datastore_database", "", "Datastore Database") scope = flag.String("scope", "all", "Scope of data generation: all, user") resetFlag = flag.Bool("reset", false, "Reset test user data before loading") + triggerScenario = flag.String("trigger-scenario", "", "Trigger a specific data change scenario for E2E tests") ) flag.Parse() @@ -1387,6 +1455,16 @@ func main() { gofakeit.GlobalFaker = gofakeit.New(seedValue) ctx := context.Background() + if *triggerScenario != "" { + err := triggerDataChange(ctx, spannerClient, *triggerScenario) + if err != nil { + slog.ErrorContext(ctx, "Failed to trigger data change", "scenario", *triggerScenario, "error", err) + os.Exit(1) + } + slog.InfoContext(ctx, "Data change triggered successfully", "scenario", *triggerScenario) + + return // Exit immediately after triggering the change + } var finalErr error @@ -1425,5 +1503,36 @@ func main() { slog.ErrorContext(ctx, "Data generation failed", "scope", *scope, "reset", *resetFlag, "error", finalErr) os.Exit(1) } + slog.InfoContext(ctx, "loading fake data successful") } + +func triggerDataChange(ctx context.Context, spannerClient *gcpspanner.Client, scenario string) error { + slog.InfoContext(ctx, "Triggering data change", "scenario", scenario) + // These feature keys are used in the E2E tests. + const nonMatchingFeatureKey = "popover" + const matchingFeatureKey = "popover" + const batchChangeFeatureKey = "dialog" + const batchChangeGroupKey = "css" + + switch scenario { + case "non-matching": + // Change a property that the test subscription is not listening for. + return spannerClient.UpdateFeatureDescription(ctx, nonMatchingFeatureKey, "A non-matching change") + case "matching": + // Change the BaselineStatus to 'widely' to match the test subscription's trigger. + status := gcpspanner.BaselineStatusHigh + + return spannerClient.UpsertFeatureBaselineStatus(ctx, matchingFeatureKey, gcpspanner.FeatureBaselineStatus{ + Status: &status, + // In reality, these would be set, but we are strictly testing the transition of baseline status. + LowDate: nil, + HighDate: nil, + }) + case "batch-change": + // Change a feature to match the 'group:css' search criteria. + return spannerClient.AddFeatureToGroup(ctx, batchChangeFeatureKey, batchChangeGroupKey) + default: + return fmt.Errorf("unknown trigger scenario: %s", scenario) + } +} diff --git a/workers/email/cmd/job/main.go b/workers/email/cmd/job/main.go index 91bf8ec39..6ad983f4d 100644 --- a/workers/email/cmd/job/main.go +++ b/workers/email/cmd/job/main.go @@ -19,8 +19,13 @@ import ( "log/slog" "net/url" "os" + "strconv" + "strings" + "github.com/GoogleChrome/webstatus.dev/lib/email/chime" "github.com/GoogleChrome/webstatus.dev/lib/email/chime/chimeadapters" + "github.com/GoogleChrome/webstatus.dev/lib/email/smtpsender" + "github.com/GoogleChrome/webstatus.dev/lib/email/smtpsender/smtpsenderadapters" "github.com/GoogleChrome/webstatus.dev/lib/gcppubsub" "github.com/GoogleChrome/webstatus.dev/lib/gcppubsub/gcppubsubadapters" "github.com/GoogleChrome/webstatus.dev/lib/gcpspanner" @@ -29,6 +34,33 @@ import ( "github.com/GoogleChrome/webstatus.dev/workers/email/pkg/sender" ) +func getSMTPSender(ctx context.Context, smtpHost string) *smtpsenderadapters.EmailWorkerSMTPAdapter { + slog.InfoContext(ctx, "using smtp email sender") + smtpPortStr := os.Getenv("SMTP_PORT") + smtpPort, err := strconv.Atoi(smtpPortStr) + if err != nil { + slog.ErrorContext(ctx, "invalid SMTP_PORT", "error", err) + os.Exit(1) + } + smtpUsername := os.Getenv("SMTP_USERNAME") + smtpPassword := os.Getenv("SMTP_PASSWORD") + fromAddress := os.Getenv("FROM_ADDRESS") + + smtpCfg := smtpsender.SMTPClientConfig{ + Host: smtpHost, + Port: smtpPort, + Username: smtpUsername, + Password: smtpPassword, + } + smtpClient, err := smtpsender.NewClient(smtpCfg, fromAddress) + if err != nil { + slog.ErrorContext(ctx, "failed to create smtp client", "error", err) + os.Exit(1) + } + + return smtpsenderadapters.NewEmailWorkerSMTPAdapter(smtpClient) +} + func main() { ctx := context.Background() @@ -86,8 +118,33 @@ func main() { os.Exit(1) } + var emailSender sender.EmailSender + smtpHost := os.Getenv("SMTP_HOST") + if smtpHost != "" { + emailSender = getSMTPSender(ctx, smtpHost) + } else { + slog.InfoContext(ctx, "using chime email sender") + chimeEnvStr := os.Getenv("CHIME_ENV") + chimeEnv := chime.EnvProd + if chimeEnvStr == "autopush" { + chimeEnv = chime.EnvAutopush + } + chimeBCC := os.Getenv("CHIME_BCC") + bccList := []string{} + if chimeBCC != "" { + bccList = strings.Split(chimeBCC, ",") + } + fromAddress := os.Getenv("FROM_ADDRESS") + chimeSender, err := chime.NewChimeSender(ctx, chimeEnv, bccList, fromAddress, nil) + if err != nil { + slog.ErrorContext(ctx, "failed to create chime sender", "error", err) + os.Exit(1) + } + emailSender = chimeadapters.NewEmailWorkerChimeAdapter(chimeSender) + } + listener := gcppubsubadapters.NewEmailWorkerSubscriberAdapter(sender.NewSender( - chimeadapters.NewEmailWorkerChimeAdapter(nil), + emailSender, spanneradapters.NewEmailWorkerChannelStateManager(spannerClient), renderer, ), queueClient, emailSubID) diff --git a/workers/email/manifests/pod.yaml b/workers/email/manifests/pod.yaml index d16c68011..b5a1e80f7 100644 --- a/workers/email/manifests/pod.yaml +++ b/workers/email/manifests/pod.yaml @@ -38,6 +38,12 @@ spec: value: 'chime-delivery-sub-id' - name: FRONTEND_BASE_URL value: 'http://localhost:5555' + - name: SMTP_HOST + value: 'mailpit' + - name: SMTP_PORT + value: '1025' + - name: FROM_ADDRESS + value: 'test@webstatus.dev' resources: limits: cpu: 250m diff --git a/workers/email/skaffold.yaml b/workers/email/skaffold.yaml index 1e4c3c6ad..4650b090b 100644 --- a/workers/email/skaffold.yaml +++ b/workers/email/skaffold.yaml @@ -19,6 +19,7 @@ metadata: requires: - path: ../../.dev/pubsub - path: ../../.dev/spanner + - path: ../../.dev/mailpit profiles: - name: local build: diff --git a/workers/skaffold.yaml b/workers/skaffold.yaml new file mode 100644 index 000000000..13045a967 --- /dev/null +++ b/workers/skaffold.yaml @@ -0,0 +1,22 @@ +# Copyright 2026 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 + +# https://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. + +apiVersion: skaffold/v4beta9 +kind: Config +metadata: + name: workers +requires: + - path: ./email + - path: ./event_producer + - path: ./push_delivery