From fdf79728c265f1ec9e6aa55282d59564ffb40466 Mon Sep 17 00:00:00 2001 From: James Scott Date: Fri, 26 Dec 2025 22:43:25 +0000 Subject: [PATCH 01/27] implement state retrieval and locking infrastructure Enables the Event Producer to fetch the previous state and coordinate execution via distributed locking. Changes: - **Spanner**: Added `GetLatestSavedSearchNotificationEvent` to fetch the last known state for a search/frequency pair. - **Schema**: Added index `SavedSearchNotificationEvents_BySearchAndSnapshotType` to optimize state lookups. - **Producer**: Updated `ProcessSearch` to acquire/release locks (currently using triggerID) and fetch the previous state before diffing. - **Refactor**: Exported `DefaultBrowsers` in `backendtypes` for shared use between the API server and worker adapters --- backend/pkg/httpserver/get_feature.go | 2 +- backend/pkg/httpserver/get_features.go | 2 +- backend/pkg/httpserver/server.go | 12 ----- infra/storage/spanner/migrations/000029.sql | 17 ++++++ lib/backendtypes/types.go | 12 +++++ .../saved_search_notification_events.go | 39 ++++++++++++++ .../saved_search_notification_events_test.go | 52 +++++++++++++++++++ .../event_producer/pkg/producer/producer.go | 24 +++++++++ .../pkg/producer/producer_test.go | 11 ++++ 9 files changed, 157 insertions(+), 14 deletions(-) create mode 100644 infra/storage/spanner/migrations/000029.sql diff --git a/backend/pkg/httpserver/get_feature.go b/backend/pkg/httpserver/get_feature.go index 339f8c5f8..0d6adeffb 100644 --- a/backend/pkg/httpserver/get_feature.go +++ b/backend/pkg/httpserver/get_feature.go @@ -82,7 +82,7 @@ func (s *Server) GetFeature( } result, err := s.wptMetricsStorer.GetFeature(ctx, request.FeatureId, getWPTMetricViewOrDefault(request.Params.WptMetricView), - defaultBrowsers(), + backendtypes.DefaultBrowsers(), ) if err != nil { if errors.Is(err, gcpspanner.ErrQueryReturnedNoResults) { diff --git a/backend/pkg/httpserver/get_features.go b/backend/pkg/httpserver/get_features.go index 4e35d06b2..c0fe766d0 100644 --- a/backend/pkg/httpserver/get_features.go +++ b/backend/pkg/httpserver/get_features.go @@ -69,7 +69,7 @@ func (s *Server) ListFeatures( node, req.Params.Sort, getWPTMetricViewOrDefault(req.Params.WptMetricView), - defaultBrowsers(), + backendtypes.DefaultBrowsers(), ) if err != nil { diff --git a/backend/pkg/httpserver/server.go b/backend/pkg/httpserver/server.go index 1bc48deaf..d0bc75814 100644 --- a/backend/pkg/httpserver/server.go +++ b/backend/pkg/httpserver/server.go @@ -183,18 +183,6 @@ type UserGitHubClient struct { type UserGitHubClientFactory func(token string) *UserGitHubClient -func defaultBrowsers() []backend.BrowserPathParam { - return []backend.BrowserPathParam{ - backend.Chrome, - backend.Edge, - backend.Firefox, - backend.Safari, - backend.ChromeAndroid, - backend.FirefoxAndroid, - backend.SafariIos, - } -} - func getPageSizeOrDefault(pageSize *int) int { // maxPageSize comes from the /openapi/backend/openapi.yaml maxPageSize := 100 diff --git a/infra/storage/spanner/migrations/000029.sql b/infra/storage/spanner/migrations/000029.sql new file mode 100644 index 000000000..5b1257938 --- /dev/null +++ b/infra/storage/spanner/migrations/000029.sql @@ -0,0 +1,17 @@ +-- 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. + +-- This index helps get the latest events for a search for the event producer worker. +CREATE INDEX IF NOT EXISTS SavedSearchNotificationEvents_BySearchAndSnapshotType +ON SavedSearchNotificationEvents (SavedSearchId, SnapshotType, Timestamp DESC); \ No newline at end of file diff --git a/lib/backendtypes/types.go b/lib/backendtypes/types.go index 8382a2e0a..b1dd3eae3 100644 --- a/lib/backendtypes/types.go +++ b/lib/backendtypes/types.go @@ -86,3 +86,15 @@ func AttemptToStoreSubscriptionTriggerUnknown() backend.SubscriptionTriggerRespo return ret } + +func DefaultBrowsers() []backend.BrowserPathParam { + return []backend.BrowserPathParam{ + backend.Chrome, + backend.Edge, + backend.Firefox, + backend.Safari, + backend.ChromeAndroid, + backend.FirefoxAndroid, + backend.SafariIos, + } +} diff --git a/lib/gcpspanner/saved_search_notification_events.go b/lib/gcpspanner/saved_search_notification_events.go index ea10c8f24..c2543621e 100644 --- a/lib/gcpspanner/saved_search_notification_events.go +++ b/lib/gcpspanner/saved_search_notification_events.go @@ -133,3 +133,42 @@ func (c *Client) PublishSavedSearchNotificationEvent(ctx context.Context, return id, err } + +type savedSearchNotificationEventBySearchAndSnapshotTypeKey struct { + SavedSearchID string + SnapshotType SavedSearchSnapshotType +} + +type savedSearchNotificationEventBySearchAndSnapshotTypeMapper struct{} + +func (m savedSearchNotificationEventBySearchAndSnapshotTypeMapper) Table() string { + return savedSearchNotificationEventsTable +} + +func (m savedSearchNotificationEventBySearchAndSnapshotTypeMapper) SelectOne( + key savedSearchNotificationEventBySearchAndSnapshotTypeKey) spanner.Statement { + return spanner.Statement{ + SQL: `SELECT * FROM SavedSearchNotificationEvents + WHERE SavedSearchId = @SavedSearchId AND SnapshotType = @SnapshotType + ORDER BY Timestamp DESC + LIMIT 1`, + Params: map[string]any{ + "SavedSearchId": key.SavedSearchID, + "SnapshotType": key.SnapshotType, + }, + } +} + +func (c *Client) GetLatestSavedSearchNotificationEvent( + ctx context.Context, savedSearchID string, + snapshotType SavedSearchSnapshotType) (*SavedSearchNotificationEvent, error) { + r := newEntityReader[savedSearchNotificationEventBySearchAndSnapshotTypeMapper, SavedSearchNotificationEvent, + savedSearchNotificationEventBySearchAndSnapshotTypeKey](c) + + key := savedSearchNotificationEventBySearchAndSnapshotTypeKey{ + SavedSearchID: savedSearchID, + SnapshotType: snapshotType, + } + + return r.readRowByKey(ctx, key) +} diff --git a/lib/gcpspanner/saved_search_notification_events_test.go b/lib/gcpspanner/saved_search_notification_events_test.go index ebb784fd6..8ea64afa4 100644 --- a/lib/gcpspanner/saved_search_notification_events_test.go +++ b/lib/gcpspanner/saved_search_notification_events_test.go @@ -349,3 +349,55 @@ func TestGetSavedSearchNotificationEvent_NotFound(t *testing.T) { t.Errorf("expected ErrQueryReturnedNoResults, got %v", err) } } + +func TestGetLatestSavedSearchNotificationEvent(t *testing.T) { + ctx := t.Context() + restartDatabaseContainer(t) + // Insert multiple events for the same saved search and snapshot type + savedSearchID := createSavedSearchForNotificationTests(ctx, t) + snapshotType := SavedSearchSnapshotType("compat-stats") + + eventTimes := []time.Time{ + time.Date(2025, 1, 1, 10, 0, 0, 0, time.UTC), + time.Date(2025, 1, 1, 11, 0, 0, 0, time.UTC), + time.Date(2025, 1, 1, 12, 0, 0, 0, time.UTC), + } + // Setup and acquire lock + setupLockAndInitialState(ctx, t, savedSearchID, string(snapshotType), "worker-1", "path/initial", 10*time.Minute, + time.Date(2025, 1, 1, 9, 0, 0, 0, time.UTC)) + + var latestEventID string + for i, eventTime := range eventTimes { + eventID := "event-" + string(rune('A'+i)) + _, err := spannerClient.PublishSavedSearchNotificationEvent(ctx, SavedSearchNotificationCreateRequest{ + SavedSearchID: savedSearchID, + SnapshotType: snapshotType, + Timestamp: eventTime, + EventType: "IMMEDIATE_DIFF", + Reasons: []string{"DATA_UPDATED"}, + BlobPath: "path/" + eventID, + DiffBlobPath: "diff/path/" + eventID, + Summary: spanner.NullJSON{ + Value: nil, + Valid: false, + }, + }, "path/"+eventID, "worker-1", WithID(eventID)) + if err != nil { + t.Fatalf("PublishSavedSearchNotificationEvent() failed: %v", err) + } + latestEventID = eventID + } + + // Now retrieve the latest event + latestEvent, err := spannerClient.GetLatestSavedSearchNotificationEvent(ctx, savedSearchID, snapshotType) + if err != nil { + t.Fatalf("GetLatestSavedSearchNotificationEvent() failed: %v", err) + } + + if latestEvent.ID != latestEventID { + t.Errorf("Latest event ID mismatch: got %s, want %s", latestEvent.ID, latestEventID) + } + if !latestEvent.Timestamp.Equal(eventTimes[2]) { + t.Errorf("Latest event Timestamp mismatch: got %v, want %v", latestEvent.Timestamp, eventTimes[2]) + } +} diff --git a/workers/event_producer/pkg/producer/producer.go b/workers/event_producer/pkg/producer/producer.go index 6e3483069..dd72d89e3 100644 --- a/workers/event_producer/pkg/producer/producer.go +++ b/workers/event_producer/pkg/producer/producer.go @@ -19,7 +19,9 @@ import ( "errors" "fmt" "log/slog" + "time" + "github.com/GoogleChrome/webstatus.dev/lib/event" "github.com/GoogleChrome/webstatus.dev/lib/workertypes" "github.com/GoogleChrome/webstatus.dev/workers/event_producer/pkg/differ" ) @@ -37,6 +39,10 @@ type BlobStorage interface { // EventMetadataStore handles the publishing and retrieval of event metadata. type EventMetadataStore interface { + AcquireLock(ctx context.Context, searchID string, frequency workertypes.JobFrequency, + workerID string, lockTTL time.Duration) error + ReleaseLock(ctx context.Context, searchID string, frequency workertypes.JobFrequency, + workerID string) error PublishEvent(ctx context.Context, req workertypes.PublishEventRequest) error // GetLatestEvent retrieves the last known event for a search to establish continuity. GetLatestEvent(ctx context.Context, @@ -74,10 +80,28 @@ func baseblobname(id string) string { return fmt.Sprintf("%s.json", id) } +func getDefaultLockTTL() time.Duration { + return 2 * time.Minute +} + // ProcessSearch is the main entry point triggered when a search query needs to be checked. // triggerID is the unique ID for this execution (e.g., from a Pub/Sub message). func (p *EventProducer) ProcessSearch(ctx context.Context, searchID string, query string, frequency workertypes.JobFrequency, triggerID string) error { + // 0. Acquire Lock + // TODO: For now, use the triggerID as the worker ID. + // https://github.com/GoogleChrome/webstatus.dev/issues/2123 + workerID := triggerID + if err := p.metaStore.AcquireLock(ctx, searchID, frequency, workerID, getDefaultLockTTL()); err != nil { + slog.ErrorContext(ctx, "failed to acquire lock", "search_id", searchID, "worker_id", workerID, "error", err) + + return fmt.Errorf("%w: failed to acquire lock: %w", event.ErrTransientFailure, err) + } + defer func() { + if err := p.metaStore.ReleaseLock(ctx, searchID, frequency, workerID); err != nil { + slog.ErrorContext(ctx, "failed to release lock", "search_id", searchID, "worker_id", workerID, "error", err) + } + }() // 1. Fetch Previous State // We need the last known state to compute the diff. lastEvent, err := p.metaStore.GetLatestEvent(ctx, frequency, searchID) diff --git a/workers/event_producer/pkg/producer/producer_test.go b/workers/event_producer/pkg/producer/producer_test.go index bc79fbc91..297d41763 100644 --- a/workers/event_producer/pkg/producer/producer_test.go +++ b/workers/event_producer/pkg/producer/producer_test.go @@ -94,6 +94,17 @@ type mockEventMetadataStore struct { info *workertypes.LatestEventInfo err error } + acquireLockReturns error + releaseLockReturns error +} + +func (m *mockEventMetadataStore) AcquireLock(_ context.Context, _ string, _ workertypes.JobFrequency, _ string, + _ time.Duration) error { + return m.acquireLockReturns +} + +func (m *mockEventMetadataStore) ReleaseLock(_ context.Context, _ string, _ workertypes.JobFrequency, _ string) error { + return m.releaseLockReturns } func (m *mockEventMetadataStore) PublishEvent(_ context.Context, req workertypes.PublishEventRequest) error { From a9bebdaf91d906b74b3804f222edf72e24fae243 Mon Sep 17 00:00:00 2001 From: James Scott Date: Fri, 26 Dec 2025 22:46:22 +0000 Subject: [PATCH 02/27] feat(event_producer): implement spanner adapters Introduces the concrete Spanner adapters that connect the `EventProducer` domain logic to the underlying Spanner client. This includes: - `EventProducerDiffer`: Adapts the backend's `FeaturesSearch` and `GetFeature` logic to the interface required by the differ package. - `EventProducer`: The main adapter that handles the orchestration of locking, state retrieval, and event publishing, including the JSON serialization of event summaries. --- .../spanneradapters/event_producer.go | 226 ++++++ .../spanneradapters/event_producer_test.go | 655 ++++++++++++++++++ 2 files changed, 881 insertions(+) create mode 100644 lib/gcpspanner/spanneradapters/event_producer.go create mode 100644 lib/gcpspanner/spanneradapters/event_producer_test.go diff --git a/lib/gcpspanner/spanneradapters/event_producer.go b/lib/gcpspanner/spanneradapters/event_producer.go new file mode 100644 index 000000000..cf9c1160b --- /dev/null +++ b/lib/gcpspanner/spanneradapters/event_producer.go @@ -0,0 +1,226 @@ +// 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 spanneradapters + +import ( + "context" + "encoding/json" + "fmt" + "log/slog" + "time" + + "cloud.google.com/go/spanner" + "github.com/GoogleChrome/webstatus.dev/lib/backendtypes" + "github.com/GoogleChrome/webstatus.dev/lib/gcpspanner" + "github.com/GoogleChrome/webstatus.dev/lib/gcpspanner/searchtypes" + "github.com/GoogleChrome/webstatus.dev/lib/gen/openapi/backend" + "github.com/GoogleChrome/webstatus.dev/lib/workertypes" +) + +type BackendAdapterForEventProducerDiffer interface { + GetFeature( + ctx context.Context, + featureID string, + wptMetricView backend.WPTMetricView, + browsers []backend.BrowserPathParam, + ) (*backendtypes.GetFeatureResult, error) + FeaturesSearch( + ctx context.Context, + pageToken *string, + pageSize int, + searchNode *searchtypes.SearchNode, + sortOrder *backend.ListFeaturesParamsSort, + wptMetricView backend.WPTMetricView, + browsers []backend.BrowserPathParam, + ) (*backend.FeaturePage, error) +} + +type EventProducerDiffer struct { + backendAdapter BackendAdapterForEventProducerDiffer +} + +type EventProducerDifferSpannerClient interface { + BackendSpannerClient +} + +// NewEventProducerDiffer constructs an adapter for the differ in the event producer service. +func NewEventProducerDiffer(adapter BackendAdapterForEventProducerDiffer) *EventProducerDiffer { + return &EventProducerDiffer{backendAdapter: adapter} +} + +func (e *EventProducerDiffer) GetFeature( + ctx context.Context, + featureID string) (*backendtypes.GetFeatureResult, error) { + return e.backendAdapter.GetFeature(ctx, featureID, backend.TestCounts, + backendtypes.DefaultBrowsers()) +} + +func (e *EventProducerDiffer) FetchFeatures(ctx context.Context, query string) ([]backend.Feature, error) { + parser := searchtypes.FeaturesSearchQueryParser{} + node, err := parser.Parse(query) + if err != nil { + return nil, err + } + var features []backend.Feature + + s := backend.NameAsc + var pageToken *string + for { + featurePage, err := e.backendAdapter.FeaturesSearch( + ctx, + pageToken, + // TODO: Use helper for page size https://github.com/GoogleChrome/webstatus.dev/issues/2122 + 100, + node, + &s, + //TODO: Use helper for test type https://github.com/GoogleChrome/webstatus.dev/issues/2122 + backend.TestCounts, + backendtypes.DefaultBrowsers(), + ) + if err != nil { + return nil, err + } + features = append(features, featurePage.Data...) + if featurePage.Metadata.NextPageToken == nil { + break + } + pageToken = featurePage.Metadata.NextPageToken + } + + return features, nil +} + +type EventProducerSpannerClient interface { + TryAcquireSavedSearchStateWorkerLock( + ctx context.Context, + savedSearchID string, + snapshotType gcpspanner.SavedSearchSnapshotType, + workerID string, + ttl time.Duration) (bool, error) + PublishSavedSearchNotificationEvent(ctx context.Context, + event gcpspanner.SavedSearchNotificationCreateRequest, newStatePath, workerID string, + opts ...gcpspanner.CreateOption) (*string, error) + GetLatestSavedSearchNotificationEvent( + ctx context.Context, + savedSearchID string, + snapshotType gcpspanner.SavedSearchSnapshotType, + ) (*gcpspanner.SavedSearchNotificationEvent, error) + ReleaseSavedSearchStateWorkerLock( + ctx context.Context, + savedSearchID string, + snapshotType gcpspanner.SavedSearchSnapshotType, + workerID string) error +} + +type EventProducer struct { + client EventProducerSpannerClient +} + +func NewEventProducer(client EventProducerSpannerClient) *EventProducer { + return &EventProducer{client: client} +} + +func convertFrequencyToSnapshotType(freq workertypes.JobFrequency) gcpspanner.SavedSearchSnapshotType { + switch freq { + // Eventually daily and unknown will be their own types. + case workertypes.FrequencyImmediate, workertypes.FrequencyDaily, workertypes.FrequencyUnknown: + return gcpspanner.SavedSearchSnapshotTypeImmediate + case workertypes.FrequencyWeekly: + return gcpspanner.SavedSearchSnapshotTypeWeekly + case workertypes.FrequencyMonthly: + return gcpspanner.SavedSearchSnapshotTypeMonthly + } + + return gcpspanner.SavedSearchSnapshotTypeImmediate +} + +func convertWorktypeReasonsToSpanner(reasons []workertypes.Reason) []string { + if reasons == nil { + return nil + } + spannerReasons := make([]string, 0, len(reasons)) + for _, r := range reasons { + spannerReasons = append(spannerReasons, string(r)) + } + + return spannerReasons +} + +func (e *EventProducer) AcquireLock(ctx context.Context, searchID string, frequency workertypes.JobFrequency, + workerID string, lockTTL time.Duration) error { + snapshotType := convertFrequencyToSnapshotType(frequency) + _, err := e.client.TryAcquireSavedSearchStateWorkerLock( + ctx, + searchID, + snapshotType, + workerID, + lockTTL, + ) + + return err +} + +func (e *EventProducer) GetLatestEvent(ctx context.Context, frequency workertypes.JobFrequency, + searchID string) (*workertypes.LatestEventInfo, error) { + snapshotType := convertFrequencyToSnapshotType(frequency) + + event, err := e.client.GetLatestSavedSearchNotificationEvent(ctx, searchID, snapshotType) + if err != nil { + return nil, err + } + + return &workertypes.LatestEventInfo{ + EventID: event.ID, + StateBlobPath: event.BlobPath, + }, nil +} + +func (e *EventProducer) ReleaseLock(ctx context.Context, searchID string, frequency workertypes.JobFrequency, + workerID string) error { + snapshotType := convertFrequencyToSnapshotType(frequency) + + return e.client.ReleaseSavedSearchStateWorkerLock(ctx, searchID, snapshotType, workerID) +} + +func (e *EventProducer) PublishEvent(ctx context.Context, req workertypes.PublishEventRequest) error { + var summaryObj interface{} + if req.Summary != nil { + if err := json.Unmarshal(req.Summary, &summaryObj); err != nil { + return fmt.Errorf("failed to unmarshal summary JSON: %w", err) + } + } + snapshotType := convertFrequencyToSnapshotType(req.Frequency) + _, err := e.client.PublishSavedSearchNotificationEvent(ctx, gcpspanner.SavedSearchNotificationCreateRequest{ + SavedSearchID: req.SearchID, + SnapshotType: snapshotType, + Timestamp: req.GeneratedAt, + EventType: "", // TODO: Set appropriate event type + Reasons: convertWorktypeReasonsToSpanner(req.Reasons), + BlobPath: req.StateBlobPath, + DiffBlobPath: req.DiffBlobPath, + Summary: spanner.NullJSON{Value: map[string]any{"summary": summaryObj}, Valid: req.Summary != nil}, + }, + req.StateBlobPath, + req.EventID, + gcpspanner.WithID(req.EventID), + ) + if err != nil { + slog.ErrorContext(ctx, "unable to publish notification event", "error", err, "eventID", req.EventID) + + return err + } + + return nil +} diff --git a/lib/gcpspanner/spanneradapters/event_producer_test.go b/lib/gcpspanner/spanneradapters/event_producer_test.go new file mode 100644 index 000000000..24f77d080 --- /dev/null +++ b/lib/gcpspanner/spanneradapters/event_producer_test.go @@ -0,0 +1,655 @@ +// 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 spanneradapters + +import ( + "context" + "errors" + "testing" + "time" + + "cloud.google.com/go/spanner" + "github.com/GoogleChrome/webstatus.dev/lib/backendtypes" + "github.com/GoogleChrome/webstatus.dev/lib/gcpspanner" + "github.com/GoogleChrome/webstatus.dev/lib/gcpspanner/searchtypes" + "github.com/GoogleChrome/webstatus.dev/lib/gen/openapi/backend" + "github.com/GoogleChrome/webstatus.dev/lib/generic" + "github.com/GoogleChrome/webstatus.dev/lib/workertypes" + "github.com/google/go-cmp/cmp" +) + +// mockSpannerClient implements EventProducerSpannerClient for testing. +type mockSpannerClient struct { + tryAcquireLockCalled bool + acquireLockReq struct { + SavedSearchID string + SnapshotType gcpspanner.SavedSearchSnapshotType + WorkerID string + TTL time.Duration + } + acquireLockResp bool + acquireLockErr error + + releaseLockCalled bool + releaseLockReq struct { + SavedSearchID string + SnapshotType gcpspanner.SavedSearchSnapshotType + WorkerID string + } + releaseLockErr error + + publishEventCalled bool + publishEventReq struct { + Event gcpspanner.SavedSearchNotificationCreateRequest + NewStatePath string + WorkerID string + } + publishEventResp *string + publishEventErr error + + getLatestEventCalled bool + getLatestEventReq struct { + SavedSearchID string + SnapshotType gcpspanner.SavedSearchSnapshotType + } + getLatestEventResp *gcpspanner.SavedSearchNotificationEvent + getLatestEventErr error +} + +func (m *mockSpannerClient) TryAcquireSavedSearchStateWorkerLock( + _ context.Context, + savedSearchID string, + snapshotType gcpspanner.SavedSearchSnapshotType, + workerID string, + ttl time.Duration) (bool, error) { + m.tryAcquireLockCalled = true + m.acquireLockReq.SavedSearchID = savedSearchID + m.acquireLockReq.SnapshotType = snapshotType + m.acquireLockReq.WorkerID = workerID + m.acquireLockReq.TTL = ttl + + return m.acquireLockResp, m.acquireLockErr +} + +func (m *mockSpannerClient) PublishSavedSearchNotificationEvent(_ context.Context, + event gcpspanner.SavedSearchNotificationCreateRequest, newStatePath, workerID string, + _ ...gcpspanner.CreateOption) (*string, error) { + m.publishEventCalled = true + m.publishEventReq.Event = event + m.publishEventReq.NewStatePath = newStatePath + m.publishEventReq.WorkerID = workerID + + return m.publishEventResp, m.publishEventErr +} + +func (m *mockSpannerClient) GetLatestSavedSearchNotificationEvent( + _ context.Context, + savedSearchID string, + snapshotType gcpspanner.SavedSearchSnapshotType, +) (*gcpspanner.SavedSearchNotificationEvent, error) { + m.getLatestEventCalled = true + m.getLatestEventReq.SavedSearchID = savedSearchID + m.getLatestEventReq.SnapshotType = snapshotType + + return m.getLatestEventResp, m.getLatestEventErr +} + +func (m *mockSpannerClient) ReleaseSavedSearchStateWorkerLock( + _ context.Context, + savedSearchID string, + snapshotType gcpspanner.SavedSearchSnapshotType, + workerID string) error { + m.releaseLockCalled = true + m.releaseLockReq.SavedSearchID = savedSearchID + m.releaseLockReq.SnapshotType = snapshotType + m.releaseLockReq.WorkerID = workerID + + return m.releaseLockErr +} + +func TestEventProducer_AcquireLock(t *testing.T) { + tests := []struct { + name string + freq workertypes.JobFrequency + wantSnapshotType gcpspanner.SavedSearchSnapshotType + mockResp bool + mockErr error + wantErr bool + }{ + { + name: "Immediate maps to Immediate", + freq: workertypes.FrequencyImmediate, + wantSnapshotType: gcpspanner.SavedSearchSnapshotTypeImmediate, + mockResp: true, + mockErr: nil, + wantErr: false, + }, + { + name: "Weekly maps to Weekly", + freq: workertypes.FrequencyWeekly, + wantSnapshotType: gcpspanner.SavedSearchSnapshotTypeWeekly, + mockResp: true, + mockErr: nil, + wantErr: false, + }, + { + name: "Error propagation", + freq: workertypes.FrequencyMonthly, + wantSnapshotType: gcpspanner.SavedSearchSnapshotTypeMonthly, + mockResp: false, + mockErr: errors.New("spanner error"), + wantErr: true, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + mock := new(mockSpannerClient) + mock.acquireLockResp = tc.mockResp + mock.acquireLockErr = tc.mockErr + + adapter := NewEventProducer(mock) + + err := adapter.AcquireLock(context.Background(), "search-1", tc.freq, "worker-1", time.Minute) + + if (err != nil) != tc.wantErr { + t.Errorf("AcquireLock() error = %v, wantErr %v", err, tc.wantErr) + } + + if !mock.tryAcquireLockCalled { + t.Fatal("TryAcquireSavedSearchStateWorkerLock not called") + } + + expectedReq := struct { + SavedSearchID string + SnapshotType gcpspanner.SavedSearchSnapshotType + WorkerID string + TTL time.Duration + }{ + SavedSearchID: "search-1", + SnapshotType: tc.wantSnapshotType, + WorkerID: "worker-1", + TTL: time.Minute, + } + + if diff := cmp.Diff(expectedReq, mock.acquireLockReq); diff != "" { + t.Errorf("TryAcquireSavedSearchStateWorkerLock request mismatch (-want +got):\n%s", diff) + } + }) + } +} + +func TestEventProducer_ReleaseLock(t *testing.T) { + tests := []struct { + name string + freq workertypes.JobFrequency + wantSnapshotType gcpspanner.SavedSearchSnapshotType + mockErr error + wantErr bool + }{ + { + name: "Daily maps to Immediate (as per implementation)", + freq: workertypes.FrequencyDaily, + wantSnapshotType: gcpspanner.SavedSearchSnapshotTypeImmediate, + mockErr: nil, + wantErr: false, + }, + { + name: "Error propagation", + freq: workertypes.FrequencyWeekly, + wantSnapshotType: gcpspanner.SavedSearchSnapshotTypeWeekly, + mockErr: errors.New("lock lost"), + wantErr: true, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + mock := new(mockSpannerClient) + mock.releaseLockErr = tc.mockErr + + adapter := NewEventProducer(mock) + + err := adapter.ReleaseLock(context.Background(), "search-1", tc.freq, "worker-1") + + if (err != nil) != tc.wantErr { + t.Errorf("ReleaseLock() error = %v, wantErr %v", err, tc.wantErr) + } + + if !mock.releaseLockCalled { + t.Fatal("ReleaseSavedSearchStateWorkerLock not called") + } + + expectedReq := struct { + SavedSearchID string + SnapshotType gcpspanner.SavedSearchSnapshotType + WorkerID string + }{ + SavedSearchID: "search-1", + SnapshotType: tc.wantSnapshotType, + WorkerID: "worker-1", + } + + if diff := cmp.Diff(expectedReq, mock.releaseLockReq); diff != "" { + t.Errorf("ReleaseSavedSearchStateWorkerLock request mismatch (-want +got):\n%s", diff) + } + }) + } +} + +func TestEventProducer_PublishEvent(t *testing.T) { + generatedAt := time.Now() + summaryJSON := `{"added": 1, "removed": 2}` + + // Defined named struct for better type safety and comparison in tests + type expectedRequest struct { + Event gcpspanner.SavedSearchNotificationCreateRequest + NewStatePath string + WorkerID string + } + + tests := []struct { + name string + req workertypes.PublishEventRequest + mockPublishErr error + mockPublishResp *string + wantErr bool + expectCall bool + expectedReq *expectedRequest + }{ + { + name: "success", + req: workertypes.PublishEventRequest{ + EventID: "event-1", + SearchID: "search-1", + StateID: "state-1", + StateBlobPath: "gs://bucket/state", + DiffID: "diff-1", + DiffBlobPath: "gs://bucket/diff", + Summary: []byte(summaryJSON), + Reasons: []workertypes.Reason{workertypes.ReasonDataUpdated}, + Frequency: workertypes.FrequencyWeekly, + Query: "query", + GeneratedAt: generatedAt, + }, + mockPublishErr: nil, + mockPublishResp: generic.ValuePtr("new-event-id"), + wantErr: false, + expectCall: true, + expectedReq: &expectedRequest{ + Event: gcpspanner.SavedSearchNotificationCreateRequest{ + SavedSearchID: "search-1", + SnapshotType: gcpspanner.SavedSearchSnapshotTypeWeekly, + Timestamp: generatedAt, + EventType: "", // Matches TODO in implementation + Reasons: []string{"DATA_UPDATED"}, + BlobPath: "gs://bucket/state", + DiffBlobPath: "gs://bucket/diff", + Summary: spanner.NullJSON{Value: map[string]any{ + "summary": map[string]any{ + "added": float64(1), + "removed": float64(2), + }, + }, Valid: true}, + }, + NewStatePath: "gs://bucket/state", + WorkerID: "event-1", + }, + }, + { + name: "spanner publish error", + req: workertypes.PublishEventRequest{ + EventID: "event-1", + SearchID: "search-1", + StateID: "state-1", + StateBlobPath: "gs://bucket/state", + DiffID: "diff-1", + DiffBlobPath: "gs://bucket/diff", + Summary: []byte(summaryJSON), + Reasons: []workertypes.Reason{workertypes.ReasonDataUpdated}, + Frequency: workertypes.FrequencyWeekly, + Query: "query", + GeneratedAt: generatedAt, + }, + mockPublishErr: errors.New("spanner failure"), + mockPublishResp: nil, + wantErr: true, + expectCall: true, + expectedReq: &expectedRequest{ + Event: gcpspanner.SavedSearchNotificationCreateRequest{ + SavedSearchID: "search-1", + SnapshotType: gcpspanner.SavedSearchSnapshotTypeWeekly, + Timestamp: generatedAt, + EventType: "", + Reasons: []string{"DATA_UPDATED"}, + BlobPath: "gs://bucket/state", + DiffBlobPath: "gs://bucket/diff", + Summary: spanner.NullJSON{Value: map[string]any{ + "summary": map[string]any{ + "added": float64(1), + "removed": float64(2), + }, + }, Valid: true}, + }, + NewStatePath: "gs://bucket/state", + WorkerID: "event-1", + }, + }, + { + name: "invalid json summary", + req: workertypes.PublishEventRequest{ + EventID: "event-1", + SearchID: "search-1", + Summary: []byte("invalid-json"), + Frequency: workertypes.FrequencyWeekly, + StateID: "", + StateBlobPath: "", + DiffID: "", + DiffBlobPath: "", + Reasons: nil, + Query: "", + GeneratedAt: time.Time{}, + }, + mockPublishResp: nil, + mockPublishErr: nil, + wantErr: true, + expectCall: false, + expectedReq: nil, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + mock := new(mockSpannerClient) + mock.publishEventErr = tc.mockPublishErr + mock.publishEventResp = tc.mockPublishResp + + adapter := NewEventProducer(mock) + + err := adapter.PublishEvent(context.Background(), tc.req) + + if (err != nil) != tc.wantErr { + t.Errorf("PublishEvent() error = %v, wantErr %v", err, tc.wantErr) + } + + if mock.publishEventCalled != tc.expectCall { + t.Errorf("PublishSavedSearchNotificationEvent called = %v, expected %v", mock.publishEventCalled, tc.expectCall) + } + + if tc.expectCall && tc.expectedReq != nil { + // Verify mapping by converting mock data to our expected type for type-safe comparison + actualReq := expectedRequest{ + Event: mock.publishEventReq.Event, + NewStatePath: mock.publishEventReq.NewStatePath, + WorkerID: mock.publishEventReq.WorkerID, + } + + if diff := cmp.Diff(*tc.expectedReq, actualReq); diff != "" { + t.Errorf("PublishSavedSearchNotificationEvent request mismatch (-want +got):\n%s", diff) + } + } + }) + } +} + +func TestEventProducer_GetLatestEvent(t *testing.T) { + testEvent := new(gcpspanner.SavedSearchNotificationEvent) + testEvent.ID = "event-123" + testEvent.BlobPath = "gs://bucket/blob" + + tests := []struct { + name string + freq workertypes.JobFrequency + wantSnapshotType gcpspanner.SavedSearchSnapshotType + mockResp *gcpspanner.SavedSearchNotificationEvent + mockErr error + wantInfo *workertypes.LatestEventInfo + wantErr bool + }{ + { + name: "Found event", + freq: workertypes.FrequencyWeekly, + wantSnapshotType: gcpspanner.SavedSearchSnapshotTypeWeekly, + mockResp: testEvent, + mockErr: nil, + wantInfo: &workertypes.LatestEventInfo{ + EventID: "event-123", + StateBlobPath: "gs://bucket/blob", + }, + wantErr: false, + }, + { + name: "Spanner error", + freq: workertypes.FrequencyDaily, + wantSnapshotType: gcpspanner.SavedSearchSnapshotTypeImmediate, + mockResp: nil, + mockErr: errors.New("db error"), + wantInfo: nil, + wantErr: true, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + mock := new(mockSpannerClient) + mock.getLatestEventResp = tc.mockResp + mock.getLatestEventErr = tc.mockErr + + adapter := NewEventProducer(mock) + + info, err := adapter.GetLatestEvent(context.Background(), tc.freq, "search-1") + + if (err != nil) != tc.wantErr { + t.Errorf("GetLatestEvent() error = %v, wantErr %v", err, tc.wantErr) + } + + if !mock.getLatestEventCalled { + t.Fatal("GetLatestSavedSearchNotificationEvent not called") + } + + if mock.getLatestEventReq.SavedSearchID != "search-1" { + t.Errorf("GetLatestSavedSearchNotificationEvent called with wrong ID: got %q, want %q", + mock.getLatestEventReq.SavedSearchID, "search-1") + } + if mock.getLatestEventReq.SnapshotType != tc.wantSnapshotType { + t.Errorf("GetLatestSavedSearchNotificationEvent called with wrong SnapshotType: got %v, want %v", + mock.getLatestEventReq.SnapshotType, tc.wantSnapshotType) + } + + if diff := cmp.Diff(tc.wantInfo, info); diff != "" { + t.Errorf("GetLatestEvent() mismatch (-want +got):\n%s", diff) + } + }) + } +} + +type mockBackendAdapterForEventProducer struct { + BackendSpannerClient + + getFeatureCalled bool + GetFeatureReq struct { + FeatureID string + } + getFeatureResp *backendtypes.GetFeatureResult + getFeatureErr error + + featuresSearchCalled bool + FeaturesSearchReqs []struct { + PageToken *string + PageSize int + QueryNode *searchtypes.SearchNode + } + // A sequence of pages to return. + featuresSearchPages []*backend.FeaturePage + featuresSearchErr error + // Call count for search to iterate through pages + searchCallCount int +} + +func (m *mockBackendAdapterForEventProducer) GetFeature( + _ context.Context, + featureID string, + _ backend.WPTMetricView, + _ []backend.BrowserPathParam, +) (*backendtypes.GetFeatureResult, error) { + m.getFeatureCalled = true + m.GetFeatureReq.FeatureID = featureID + + return m.getFeatureResp, m.getFeatureErr +} + +func (m *mockBackendAdapterForEventProducer) FeaturesSearch( + _ context.Context, + pageToken *string, + pageSize int, + queryNode *searchtypes.SearchNode, + _ *backend.ListFeaturesParamsSort, + _ backend.WPTMetricView, + _ []backend.BrowserPathParam, +) (*backend.FeaturePage, error) { + m.featuresSearchCalled = true + m.FeaturesSearchReqs = append(m.FeaturesSearchReqs, struct { + PageToken *string + PageSize int + QueryNode *searchtypes.SearchNode + }{pageToken, pageSize, queryNode}) + + if m.featuresSearchErr != nil { + return nil, m.featuresSearchErr // Return nil for FeaturePage on error + } + + if m.searchCallCount < len(m.featuresSearchPages) { + page := m.featuresSearchPages[m.searchCallCount] + m.searchCallCount++ + + return page, nil + } + + return new(backend.FeaturePage), nil // End of results +} + +func TestEventProducerDiffer_GetFeature(t *testing.T) { + mock := new(mockBackendAdapterForEventProducer) + f := new(backend.Feature) + f.FeatureId = "fx" + f.Name = "Feature x" + mock.getFeatureResp = backendtypes.NewGetFeatureResult(backendtypes.NewRegularFeatureResult(f)) + + adapter := NewEventProducerDiffer(mock) + + res, err := adapter.GetFeature(context.Background(), "fx") + if err != nil { + t.Fatalf("GetFeature() unexpected error: %v", err) + } + + if !mock.getFeatureCalled { + t.Error("GetFeature on backend client not called") + } + if mock.GetFeatureReq.FeatureID != "fx" { + t.Errorf("GetFeature called with %q, want %q", mock.GetFeatureReq.FeatureID, "fx") + } + visitor := new(simpleRegularFeatureVisitor) + if err := res.Visit(context.Background(), visitor); err != nil { + t.Fatalf("Visit failed: %v", err) + } + + if visitor.feature == nil { + t.Fatal("Visitor did not receive a regular feature") + } + if visitor.feature.FeatureId != "fx" { + t.Errorf("Feature ID mismatch: got %q, want fx", visitor.feature.FeatureId) + } + if visitor.feature.Name != "Feature x" { + t.Errorf("Feature Name mismatch: got %q, want 'Feature x'", visitor.feature.Name) + } +} + +type simpleRegularFeatureVisitor struct { + feature *backend.Feature +} + +func (v *simpleRegularFeatureVisitor) VisitRegularFeature(_ context.Context, + res backendtypes.RegularFeatureResult) error { + v.feature = res.Feature() + + return nil +} +func (v *simpleRegularFeatureVisitor) VisitMovedFeature(_ context.Context, _ backendtypes.MovedFeatureResult) error { + return nil +} +func (v *simpleRegularFeatureVisitor) VisitSplitFeature(_ context.Context, _ backendtypes.SplitFeatureResult) error { + return nil +} + +func TestEventProducerDiffer_FetchFeatures(t *testing.T) { + mock := new(mockBackendAdapterForEventProducer) + feature1 := new(backend.Feature) + feature1.FeatureId = "f1" + feature1.Name = "Feature 1" + feature2 := new(backend.Feature) + feature2.FeatureId = "f2" + feature2.Name = "Feature 2" + mock.featuresSearchPages = []*backend.FeaturePage{ + // First page + { + Data: []backend.Feature{ + *feature1, + }, + Metadata: backend.PageMetadataWithTotal{ + NextPageToken: generic.ValuePtr("token-1"), + Total: 1000, + }, + }, + // Second page + { + Data: []backend.Feature{ + *feature2, + }, + Metadata: backend.PageMetadataWithTotal{ + Total: 1000, + NextPageToken: nil, // End of iteration + + }, + }, + } + + adapter := NewEventProducerDiffer(mock) + + // A simple query that parses successfully + query := "name:foo" + features, err := adapter.FetchFeatures(context.Background(), query) + if err != nil { + t.Fatalf("FetchFeatures() unexpected error: %v", err) + } + + if len(features) != 2 { + t.Errorf("Expected 2 features (across 2 pages), got %d", len(features)) + } + if features[0].FeatureId != "f1" || features[1].FeatureId != "f2" { + t.Error("Feature ID mismatch in results") + } + + if len(mock.FeaturesSearchReqs) != 2 { + t.Errorf("Expected 2 calls to FeaturesSearch, got %d", len(mock.FeaturesSearchReqs)) + } + // First call should have nil token + if mock.FeaturesSearchReqs[0].PageToken != nil { + t.Error("First page token should be nil") + } + // Second call should have token-1 + if mock.FeaturesSearchReqs[1].PageToken == nil || *mock.FeaturesSearchReqs[1].PageToken != "token-1" { + t.Error("Second page token mismatch") + } +} From 8473046beba271be337adbd3af2bceb109762119 Mon Sep 17 00:00:00 2001 From: James Scott Date: Fri, 26 Dec 2025 22:56:41 +0000 Subject: [PATCH 03/27] feat(event_producer): implement pubsub adapters Introduces the `gcppubsubadapters` package to connect the `EventProducer` domain logic to Google Cloud Pub/Sub. This includes: - `EventProducerSubscriberAdapter`: A high-level adapter that routes incoming Pub/Sub messages (RefreshSearch, BatchRefresh, ConfigurationChanged) to the appropriate `EventProducer` methods. - `EventProducerPublisherAdapter`: An adapter for publishing `FeatureDiffEvent` notifications back to Pub/Sub. - `RunGroup`: A concurrency utility for managing the lifecycle of multiple blocking subscribers. --- .../gcppubsubadapters/event_producer.go | 163 +++++++++ .../gcppubsubadapters/event_producer_test.go | 342 ++++++++++++++++++ lib/gcppubsub/gcppubsubadapters/utils.go | 58 +++ lib/gcppubsub/gcppubsubadapters/utils_test.go | 101 ++++++ 4 files changed, 664 insertions(+) create mode 100644 lib/gcppubsub/gcppubsubadapters/event_producer.go create mode 100644 lib/gcppubsub/gcppubsubadapters/event_producer_test.go create mode 100644 lib/gcppubsub/gcppubsubadapters/utils.go create mode 100644 lib/gcppubsub/gcppubsubadapters/utils_test.go diff --git a/lib/gcppubsub/gcppubsubadapters/event_producer.go b/lib/gcppubsub/gcppubsubadapters/event_producer.go new file mode 100644 index 000000000..33ed8db62 --- /dev/null +++ b/lib/gcppubsub/gcppubsubadapters/event_producer.go @@ -0,0 +1,163 @@ +// 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" + "log/slog" + + "github.com/GoogleChrome/webstatus.dev/lib/event" + batchrefreshv1 "github.com/GoogleChrome/webstatus.dev/lib/event/batchrefreshtrigger/v1" + featurediffv1 "github.com/GoogleChrome/webstatus.dev/lib/event/featurediff/v1" + refreshv1 "github.com/GoogleChrome/webstatus.dev/lib/event/refreshsearchcommand/v1" + searchconfigv1 "github.com/GoogleChrome/webstatus.dev/lib/event/searchconfigurationchanged/v1" + "github.com/GoogleChrome/webstatus.dev/lib/workertypes" +) + +type EventProducerSearchMessageHandler interface { + ProcessSearch(ctx context.Context, searchID string, query string, + frequency workertypes.JobFrequency, triggerID string) error +} + +type EventProducerBatchUpdateHandler interface { + ProcessBatchUpdate(ctx context.Context, triggerID string, frequency workertypes.JobFrequency) error +} + +type EventSubscriber interface { + Subscribe(ctx context.Context, subID string, + handler func(ctx context.Context, msgID string, data []byte) error) error +} + +type SubscriberConfig struct { + SearchSubscriptionID string + BatchUpdateSubscriptionID string +} + +type EventProducerSubscriberAdapter struct { + searchEventHandler EventProducerSearchMessageHandler + batchUpdateHandler EventProducerBatchUpdateHandler + eventSubscriber EventSubscriber + config SubscriberConfig + searchEventRouter *event.Router + batchUpdateRouter *event.Router +} + +func NewEventProducerSubscriberAdapter( + searchMessageHandler EventProducerSearchMessageHandler, + batchUpdateHandler EventProducerBatchUpdateHandler, + eventSubscriber EventSubscriber, + config SubscriberConfig, +) *EventProducerSubscriberAdapter { + searchEventRouter := event.NewRouter() + + batchUpdateRouter := event.NewRouter() + + ret := &EventProducerSubscriberAdapter{ + searchEventHandler: searchMessageHandler, + batchUpdateHandler: batchUpdateHandler, + eventSubscriber: eventSubscriber, + config: config, + searchEventRouter: searchEventRouter, + batchUpdateRouter: batchUpdateRouter, + } + + event.Register(searchEventRouter, ret.processRefreshSearchCommand) + event.Register(searchEventRouter, ret.processSearchConfigurationChangedEvent) + + event.Register(batchUpdateRouter, ret.processBatchUpdateCommand) + + return ret +} + +func (a *EventProducerSubscriberAdapter) processRefreshSearchCommand(ctx context.Context, + eventID string, event refreshv1.RefreshSearchCommand) error { + slog.InfoContext(ctx, "received refresh search command", "eventID", eventID, "event", event) + + return a.searchEventHandler.ProcessSearch(ctx, event.SearchID, event.Query, + event.Frequency.ToWorkerTypeJobFrequency(), eventID) +} + +func (a *EventProducerSubscriberAdapter) processSearchConfigurationChangedEvent(ctx context.Context, + eventID string, event searchconfigv1.SearchConfigurationChangedEvent) error { + slog.InfoContext(ctx, "received search configuration changed event", "eventID", eventID, "event", event) + + return a.searchEventHandler.ProcessSearch(ctx, event.SearchID, event.Query, + event.Frequency.ToWorkerTypeJobFrequency(), eventID) +} + +func (a *EventProducerSubscriberAdapter) Subscribe(ctx context.Context) error { + return RunGroup(ctx, + // Handler 1: Search + func(ctx context.Context) error { + return a.eventSubscriber.Subscribe(ctx, a.config.SearchSubscriptionID, + func(ctx context.Context, msgID string, data []byte) error { + return a.searchEventRouter.HandleMessage(ctx, msgID, data) + }) + }, + // Handler 2: Batch Update + func(ctx context.Context) error { + return a.eventSubscriber.Subscribe(ctx, a.config.BatchUpdateSubscriptionID, + func(ctx context.Context, msgID string, data []byte) error { + return a.batchUpdateRouter.HandleMessage(ctx, msgID, data) + }) + }, + ) +} + +func (a *EventProducerSubscriberAdapter) processBatchUpdateCommand(ctx context.Context, + eventID string, event batchrefreshv1.BatchRefreshTrigger) error { + slog.InfoContext(ctx, "received batch update command", "eventID", eventID, "event", event) + + return a.batchUpdateHandler.ProcessBatchUpdate(ctx, eventID, + event.Frequency.ToWorkerTypeJobFrequency()) +} + +type EventPublisher interface { + Publish(ctx context.Context, topicID string, data []byte) (string, error) +} + +type EventProducerPublisherAdapter struct { + eventPublisher EventPublisher + topicID string +} + +func NewEventProducerPublisherAdapter(eventPublisher EventPublisher, topicID string) *EventProducerPublisherAdapter { + return &EventProducerPublisherAdapter{ + eventPublisher: eventPublisher, + topicID: topicID, + } +} + +func (a *EventProducerPublisherAdapter) Publish(ctx context.Context, + req workertypes.PublishEventRequest) (string, error) { + b, err := event.New(featurediffv1.FeatureDiffEvent{ + EventID: req.EventID, + SearchID: req.SearchID, + Query: req.Query, + Summary: req.Summary, + StateID: req.StateID, + StateBlobPath: req.StateBlobPath, + DiffID: req.DiffID, + DiffBlobPath: req.DiffBlobPath, + GeneratedAt: req.GeneratedAt, + Frequency: featurediffv1.ToJobFrequency(req.Frequency), + Reasons: featurediffv1.ToReasons(req.Reasons), + }) + if err != nil { + return "", err + } + + return a.eventPublisher.Publish(ctx, a.topicID, b) +} diff --git a/lib/gcppubsub/gcppubsubadapters/event_producer_test.go b/lib/gcppubsub/gcppubsubadapters/event_producer_test.go new file mode 100644 index 000000000..f3264d6e0 --- /dev/null +++ b/lib/gcppubsub/gcppubsubadapters/event_producer_test.go @@ -0,0 +1,342 @@ +// 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/base64" + "encoding/json" + "sync" + "testing" + "time" + + batchrefreshv1 "github.com/GoogleChrome/webstatus.dev/lib/event/batchrefreshtrigger/v1" + refreshv1 "github.com/GoogleChrome/webstatus.dev/lib/event/refreshsearchcommand/v1" + "github.com/GoogleChrome/webstatus.dev/lib/workertypes" + "github.com/google/go-cmp/cmp" +) + +// --- Mocks --- + +type mockSearchHandler struct { + calls []searchCall + mu sync.Mutex + err error +} + +type searchCall struct { + SearchID string + Query string + Frequency workertypes.JobFrequency + TriggerID string +} + +func (m *mockSearchHandler) ProcessSearch(_ context.Context, searchID, query string, + freq workertypes.JobFrequency, triggerID string) error { + m.mu.Lock() + defer m.mu.Unlock() + m.calls = append(m.calls, searchCall{searchID, query, freq, triggerID}) + + return m.err +} + +type mockBatchHandler struct { + calls []batchCall + mu sync.Mutex + err error +} + +type batchCall struct { + TriggerID string + Frequency workertypes.JobFrequency +} + +func (m *mockBatchHandler) ProcessBatchUpdate(_ context.Context, triggerID string, + freq workertypes.JobFrequency) error { + m.mu.Lock() + defer m.mu.Unlock() + m.calls = append(m.calls, batchCall{triggerID, freq}) + + return m.err +} + +type mockSubscriber struct { + handlers map[string]func(context.Context, string, []byte) error + mu sync.Mutex + // block allows us to simulate a long-running Subscribe call so RunGroup doesn't exit immediately + block chan struct{} +} + +func (m *mockSubscriber) Subscribe(ctx context.Context, subID string, + handler func(context.Context, string, []byte) error) error { + m.mu.Lock() + if m.handlers == nil { + m.handlers = make(map[string]func(context.Context, string, []byte) error) + } + m.handlers[subID] = handler + m.mu.Unlock() + + // Simulate blocking behavior of a real subscriber logic + if m.block != nil { + select { + case <-m.block: + return nil + case <-ctx.Done(): + return ctx.Err() + } + } + + return nil +} + +type mockPublisher struct { + publishedData []byte + publishedTopic string + err error +} + +func (m *mockPublisher) Publish(_ context.Context, topicID string, data []byte) (string, error) { + m.publishedData = data + m.publishedTopic = topicID + + return "msg-id", m.err +} + +// --- Tests --- + +type testEnv struct { + searchHandler *mockSearchHandler + batchHandler *mockBatchHandler + subscriber *mockSubscriber + adapter *EventProducerSubscriberAdapter + searchFn func(context.Context, string, []byte) error + batchFn func(context.Context, string, []byte) error + stop func() +} + +func setupTestAdapter(t *testing.T) *testEnv { + t.Helper() + searchHandler := new(mockSearchHandler) + batchHandler := new(mockBatchHandler) + subscriber := &mockSubscriber{block: make(chan struct{}), mu: sync.Mutex{}, handlers: nil} + config := SubscriberConfig{ + SearchSubscriptionID: "search-sub", + BatchUpdateSubscriptionID: "batch-sub", + } + + adapter := NewEventProducerSubscriberAdapter(searchHandler, batchHandler, subscriber, config) + + // Run Subscribe in a goroutine because it blocks + ctx, cancel := context.WithCancel(context.Background()) + + errChan := make(chan error) + go func() { + errChan <- adapter.Subscribe(ctx) + }() + + // Wait briefly for RunGroup to start and handlers to be registered + time.Sleep(50 * time.Millisecond) + + subscriber.mu.Lock() + searchFn := subscriber.handlers["search-sub"] + batchFn := subscriber.handlers["batch-sub"] + subscriber.mu.Unlock() + + if searchFn == nil || batchFn == nil { + cancel() + t.Fatal("Subscribe did not register handlers for both subscriptions") + } + + return &testEnv{ + searchHandler: searchHandler, + batchHandler: batchHandler, + subscriber: subscriber, + adapter: adapter, + searchFn: searchFn, + batchFn: batchFn, + stop: func() { + close(subscriber.block) // Unblock the subscriber + cancel() // Cancel the context + <-errChan // Wait for adapter.Subscribe to return + }, + } +} + +func TestSubscribe_RoutesRefreshSearchCommand(t *testing.T) { + env := setupTestAdapter(t) + defer env.stop() + + refreshCmd := refreshv1.RefreshSearchCommand{ + SearchID: "s1", + Query: "q1", + Frequency: "DAILY", + Timestamp: time.Time{}, + } + ceWrapper := map[string]interface{}{ + "apiVersion": "v1", + "kind": "RefreshSearchCommand", + "data": refreshCmd, + } + ceBytes, _ := json.Marshal(ceWrapper) + + if err := env.searchFn(context.Background(), "msg-1", ceBytes); err != nil { + t.Errorf("searchFn failed: %v", err) + } + + if len(env.searchHandler.calls) != 1 { + t.Fatalf("Expected 1 search call, got %d", len(env.searchHandler.calls)) + } + + expectedCall := searchCall{ + SearchID: "s1", + Query: "q1", + Frequency: workertypes.FrequencyDaily, + TriggerID: "msg-1", + } + + if diff := cmp.Diff(expectedCall, env.searchHandler.calls[0]); diff != "" { + t.Errorf("Search call mismatch (-want +got):\n%s", diff) + } +} + +func TestSubscribe_RoutesBatchUpdate(t *testing.T) { + env := setupTestAdapter(t) + defer env.stop() + + batchTrig := batchrefreshv1.BatchRefreshTrigger{ + Frequency: "WEEKLY", + } + ceWrapperBatch := map[string]interface{}{ + "apiVersion": "v1", + "kind": "BatchRefreshTrigger", + "data": batchTrig, + } + ceBytesBatch, _ := json.Marshal(ceWrapperBatch) + + if err := env.batchFn(context.Background(), "msg-2", ceBytesBatch); err != nil { + t.Errorf("batchFn failed: %v", err) + } + + if len(env.batchHandler.calls) != 1 { + t.Fatalf("Expected 1 batch call, got %d", len(env.batchHandler.calls)) + } + + expectedCall := batchCall{ + TriggerID: "msg-2", + Frequency: workertypes.FrequencyWeekly, + } + + if diff := cmp.Diff(expectedCall, env.batchHandler.calls[0]); diff != "" { + t.Errorf("Batch call mismatch (-want +got):\n%s", diff) + } +} + +func TestSubscribe_RoutesSearchConfigurationChanged(t *testing.T) { + env := setupTestAdapter(t) + defer env.stop() + + // We construct the payload manually for the test execution + configEventPayload := map[string]interface{}{ + "search_id": "s2", + "query": "q2", + "user_id": "user-1", + "timestamp": "0001-01-01T00:00:00Z", + "is_creation": false, + "frequency": "IMMEDIATE", + } + + ceWrapperConfig := map[string]interface{}{ + "apiVersion": "v1", + "kind": "SearchConfigurationChangedEvent", + "data": configEventPayload, + } + ceBytesConfig, _ := json.Marshal(ceWrapperConfig) + + if err := env.searchFn(context.Background(), "msg-3", ceBytesConfig); err != nil { + t.Errorf("searchFn (config event) failed: %v", err) + } + + if len(env.searchHandler.calls) != 1 { + t.Fatalf("Expected 1 search call, got %d", len(env.searchHandler.calls)) + } + + expectedCall := searchCall{ + SearchID: "s2", + Query: "q2", + Frequency: workertypes.FrequencyImmediate, + TriggerID: "msg-3", + } + + if diff := cmp.Diff(expectedCall, env.searchHandler.calls[0]); diff != "" { + t.Errorf("Search call mismatch (-want +got):\n%s", diff) + } +} + +func TestPublisher_Publish(t *testing.T) { + publisher := new(mockPublisher) + adapter := NewEventProducerPublisherAdapter(publisher, "topic-1") + now := time.Date(2025, 1, 1, 12, 0, 0, 0, time.UTC) + + req := workertypes.PublishEventRequest{ + EventID: "evt-1", + SearchID: "search-1", + Query: "query-1", + Frequency: "DAILY", + Reasons: []workertypes.Reason{workertypes.ReasonDataUpdated}, + Summary: []byte(`{"added": 1}`), + StateID: "state-id-1", + DiffID: "diff-id-1", + StateBlobPath: "gs://bucket/state-blob", + DiffBlobPath: "gs://bucket/diff-blob", + GeneratedAt: now, + } + + _, err := adapter.Publish(context.Background(), req) + if err != nil { + t.Fatalf("Publish failed: %v", err) + } + + if publisher.publishedTopic != "topic-1" { + t.Errorf("Topic mismatch: got %s, want topic-1", publisher.publishedTopic) + } + + var actualEnvelope map[string]interface{} + if err := json.Unmarshal(publisher.publishedData, &actualEnvelope); err != nil { + t.Fatalf("Failed to unmarshal published data: %v", err) + } + + expectedEnvelope := map[string]interface{}{ + "apiVersion": "v1", + "kind": "FeatureDiffEvent", + "data": map[string]interface{}{ + "event_id": "evt-1", + "search_id": "search-1", + "query": "query-1", + // go encodes/decodes []byte as base64 strings + "summary": base64.StdEncoding.EncodeToString([]byte(`{"added": 1}`)), + "state_id": "state-id-1", + "diff_id": "diff-id-1", + "state_blob_path": "gs://bucket/state-blob", + "diff_blob_path": "gs://bucket/diff-blob", + "reasons": []interface{}{"DATA_UPDATED"}, + "generated_at": now.Format(time.RFC3339), + "frequency": "DAILY", + }, + } + + if diff := cmp.Diff(expectedEnvelope, actualEnvelope); diff != "" { + t.Errorf("Payload mismatch (-want +got):\n%s", diff) + } +} diff --git a/lib/gcppubsub/gcppubsubadapters/utils.go b/lib/gcppubsub/gcppubsubadapters/utils.go new file mode 100644 index 000000000..8dfe2bbf6 --- /dev/null +++ b/lib/gcppubsub/gcppubsubadapters/utils.go @@ -0,0 +1,58 @@ +// 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" + "sync" +) + +// RunGroup runs multiple blocking functions concurrently. +// - It returns the first error encountered. +// - If one function fails, it cancels the context for the others. +// - It waits for all functions to exit before returning. +func RunGroup(ctx context.Context, fns ...func(ctx context.Context) error) error { + // 1. Create a derived context so we can signal cancellation to all siblings + ctx, cancel := context.WithCancel(ctx) + defer cancel() + + var wg sync.WaitGroup + errChan := make(chan error, len(fns)) + + for _, fn := range fns { + wg.Add(1) + // Capture fn in the loop scope + go func(f func(context.Context) error) { + defer wg.Done() + + // Pass the cancellable context to the function + if err := f(ctx); err != nil { + // Try to push the error; if channel is full, we already have an error + select { + case errChan <- err: + // Signal other routines to stop + cancel() + default: + } + } + }(fn) + } + + wg.Wait() + close(errChan) + + // Return the first error (if any) + return <-errChan +} diff --git a/lib/gcppubsub/gcppubsubadapters/utils_test.go b/lib/gcppubsub/gcppubsubadapters/utils_test.go new file mode 100644 index 000000000..32a5e518b --- /dev/null +++ b/lib/gcppubsub/gcppubsubadapters/utils_test.go @@ -0,0 +1,101 @@ +// 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" + "errors" + "testing" + "time" +) + +func TestRunGroup(t *testing.T) { + tests := []struct { + name string + fns []func(context.Context) error + expectedErr error + }{ + { + name: "All functions succeed", + fns: []func(context.Context) error{ + func(_ context.Context) error { return nil }, + func(_ context.Context) error { return nil }, + }, + expectedErr: nil, + }, + { + name: "One function fails immediately", + fns: []func(context.Context) error{ + func(_ context.Context) error { return errors.New("fail") }, + func(ctx context.Context) error { + // Simulate work + select { + case <-ctx.Done(): + return ctx.Err() + case <-time.After(100 * time.Millisecond): + return nil + } + }, + }, + expectedErr: errors.New("fail"), + }, + { + name: "Multiple failures return first error", + fns: []func(context.Context) error{ + func(_ context.Context) error { return errors.New("error 1") }, + func(_ context.Context) error { + time.Sleep(10 * time.Millisecond) // Ensure this happens slightly later + + return errors.New("error 2") + }, + }, + expectedErr: errors.New("error 1"), + }, + { + name: "Cancellation propagates", + fns: []func(context.Context) error{ + func(_ context.Context) error { + return errors.New("trigger cancel") + }, + func(ctx context.Context) error { + select { + case <-ctx.Done(): + // Correct behavior: context was cancelled + return nil + case <-time.After(1 * time.Second): + return errors.New("timeout: context was not cancelled") + } + }, + }, + expectedErr: errors.New("trigger cancel"), + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + err := RunGroup(context.Background(), tc.fns...) + + if tc.expectedErr == nil { + if err != nil { + t.Errorf("RunGroup() unexpected error: %v", err) + } + } else { + if err == nil || err.Error() != tc.expectedErr.Error() { + t.Errorf("RunGroup() error = %v, want %v", err, tc.expectedErr) + } + } + }) + } +} From 57acf80f9949e756bee04e04078b05f24e7044ea Mon Sep 17 00:00:00 2001 From: James Scott Date: Fri, 26 Dec 2025 23:20:06 +0000 Subject: [PATCH 04/27] feat(event_producer): implement gcs adapters Introduces the `gcpgcsadapters` package to connect the `EventProducer` domain logic to Google Cloud Storage. This includes: - `EventProducer`: An adapter that implements the `BlobStorage` interface, handling path construction (bucket/dirs/key) and delegating read/write operations to the underlying GCS client. - Support for setting content-type ("application/json") on uploads. --- lib/gcpgcs/gcpgcsadapters/event_producer.go | 57 +++++ .../gcpgcsadapters/event_producer_test.go | 203 ++++++++++++++++++ 2 files changed, 260 insertions(+) create mode 100644 lib/gcpgcs/gcpgcsadapters/event_producer.go create mode 100644 lib/gcpgcs/gcpgcsadapters/event_producer_test.go diff --git a/lib/gcpgcs/gcpgcsadapters/event_producer.go b/lib/gcpgcs/gcpgcsadapters/event_producer.go new file mode 100644 index 000000000..46729cb7b --- /dev/null +++ b/lib/gcpgcs/gcpgcsadapters/event_producer.go @@ -0,0 +1,57 @@ +// 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 gcpgcsadapters + +import ( + "context" + "path" + + "github.com/GoogleChrome/webstatus.dev/lib/blobtypes" +) + +type EventProducerBlobStorageClient interface { + WriteBlob(ctx context.Context, path string, data []byte, opts ...blobtypes.WriteOption) error + ReadBlob(ctx context.Context, path string, opts ...blobtypes.ReadOption) (*blobtypes.Blob, error) +} + +type EventProducer struct { + client EventProducerBlobStorageClient + bucketName string +} + +func NewEventProducer(client EventProducerBlobStorageClient, bucketName string) *EventProducer { + return &EventProducer{client: client, bucketName: bucketName} +} + +func (e *EventProducer) Store(ctx context.Context, dirs []string, key string, data []byte) (string, error) { + filepath := append([]string{e.bucketName}, dirs...) + // Add the key as the final element. + filepath = append(filepath, key) + path := path.Join(filepath...) + if err := e.client.WriteBlob(ctx, path, data, blobtypes.WithContentType("application/json")); err != nil { + return "", err + } + + return path, nil +} + +func (e *EventProducer) Get(ctx context.Context, fullpath string) ([]byte, error) { + blob, err := e.client.ReadBlob(ctx, fullpath) + if err != nil { + return nil, err + } + + return blob.Data, nil +} diff --git a/lib/gcpgcs/gcpgcsadapters/event_producer_test.go b/lib/gcpgcs/gcpgcsadapters/event_producer_test.go new file mode 100644 index 000000000..3a9968da9 --- /dev/null +++ b/lib/gcpgcs/gcpgcsadapters/event_producer_test.go @@ -0,0 +1,203 @@ +// 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 gcpgcsadapters + +import ( + "context" + "errors" + "testing" + + "github.com/GoogleChrome/webstatus.dev/lib/blobtypes" +) + +type mockBlobStorageClient struct { + writeBlobCalled bool + writeBlobReq struct { + path string + data []byte + opts []blobtypes.WriteOption + } + writeBlobErr error + + readBlobCalled bool + readBlobReq struct { + path string + } + readBlobResp *blobtypes.Blob + readBlobErr error +} + +func (m *mockBlobStorageClient) WriteBlob(_ context.Context, path string, data []byte, + opts ...blobtypes.WriteOption) error { + m.writeBlobCalled = true + m.writeBlobReq.path = path + m.writeBlobReq.data = data + m.writeBlobReq.opts = opts + + return m.writeBlobErr +} + +func (m *mockBlobStorageClient) ReadBlob(_ context.Context, path string, + _ ...blobtypes.ReadOption) (*blobtypes.Blob, error) { + m.readBlobCalled = true + m.readBlobReq.path = path + + return m.readBlobResp, m.readBlobErr +} + +func TestStore(t *testing.T) { + bucketName := "test-bucket" + data := []byte("test-data") + + tests := []struct { + name string + dirs []string + key string + mockErr error + expectedPath string + wantErr bool + }{ + { + name: "root directory", + dirs: []string{}, + key: "file.json", + mockErr: nil, + expectedPath: "test-bucket/file.json", + wantErr: false, + }, + { + name: "nested directory", + dirs: []string{"folder1", "folder2"}, + key: "file.json", + mockErr: nil, + expectedPath: "test-bucket/folder1/folder2/file.json", + wantErr: false, + }, + { + name: "write error", + dirs: []string{"folder"}, + key: "file.json", + mockErr: errors.New("gcs error"), + expectedPath: "test-bucket/folder/file.json", + wantErr: true, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + mock := new(mockBlobStorageClient) + mock.writeBlobErr = tc.mockErr + adapter := NewEventProducer(mock, bucketName) + + path, err := adapter.Store(context.Background(), tc.dirs, tc.key, data) + + if (err != nil) != tc.wantErr { + t.Errorf("Store() error = %v, wantErr %v", err, tc.wantErr) + } + + if !mock.writeBlobCalled { + t.Fatal("WriteBlob not called") + } + + if mock.writeBlobReq.path != tc.expectedPath { + t.Errorf("path mismatch: got %q, want %q", mock.writeBlobReq.path, tc.expectedPath) + } + if string(mock.writeBlobReq.data) != string(data) { + t.Errorf("data mismatch") + } + + // Verify returned path matches the full path sent to GCS + if err == nil && path != tc.expectedPath { + t.Errorf("returned path mismatch: got %q, want %q", path, tc.expectedPath) + } + + // Verify the options include the correct content type + foundContentType := false + for _, opt := range mock.writeBlobReq.opts { + var config blobtypes.WriteSettings + opt(&config) + if config.ContentType != nil && *config.ContentType == "application/json" { + foundContentType = true + + break + } + } + if !foundContentType { + t.Error("content type option not set to application/json") + } + }) + } +} + +func TestGet(t *testing.T) { + bucketName := "test-bucket" + fullPath := "test-bucket/folder/file.json" + data := []byte("test-data") + + tests := []struct { + name string + mockResp *blobtypes.Blob + mockErr error + wantData []byte + wantErr bool + }{ + { + name: "success", + mockResp: &blobtypes.Blob{ + Data: data, + ContentType: "application/json", + Metadata: nil, + Generation: 1, + }, + mockErr: nil, + wantData: data, + wantErr: false, + }, + { + name: "read error", + mockResp: nil, + mockErr: errors.New("gcs error"), + wantData: nil, + wantErr: true, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + mock := new(mockBlobStorageClient) + mock.readBlobResp = tc.mockResp + mock.readBlobErr = tc.mockErr + adapter := NewEventProducer(mock, bucketName) + + gotData, err := adapter.Get(context.Background(), fullPath) + + if (err != nil) != tc.wantErr { + t.Errorf("Get() error = %v, wantErr %v", err, tc.wantErr) + } + + if !mock.readBlobCalled { + t.Fatal("ReadBlob not called") + } + + if mock.readBlobReq.path != fullPath { + t.Errorf("path mismatch: got %q, want %q", mock.readBlobReq.path, fullPath) + } + + if err == nil && string(gotData) != string(tc.wantData) { + t.Errorf("data mismatch: got %q, want %q", gotData, tc.wantData) + } + }) + } +} From fe1744a0317a590824b1b8a076bedbcb232be10f Mon Sep 17 00:00:00 2001 From: James Scott Date: Sat, 27 Dec 2025 00:34:53 +0000 Subject: [PATCH 05/27] feat(event_producer): implement batch event producer pubsub and spanner adapters Introduces the necessary adapters to support the batch fan-out process for scheduled refreshes. This includes: - **Pub/Sub Adapter (`gcppubsubadapters`)**: Added `BatchFanOutPublisherAdapter` which converts `workertypes.RefreshSearchCommand` into a `refreshsearchcommand/v1` CloudEvent and publishes it to the ingestion topic. - **Spanner Adapter (`spanneradapters`)**: Added `BatchEventProducer` and its `ListAllSavedSearches` method to fetch all saved searches for processing. - **Spanner Library (`gcpspanner`)**: Updated `ListAllSavedSearches` to return `SavedSearchBriefDetails` (ID and Query) instead of just IDs, allowing the batch handler to populate the command fully without downstream lookups. - **Worker Types**: Added `RefreshSearchCommand` and `SearchJob` struct definitions. --- .../gcppubsubadapters/batch_event_producer.go | 54 ++++++++ .../batch_event_producer_test.go | 106 ++++++++++++++ lib/gcpspanner/list_user_saved_searches.go | 23 +--- .../list_user_saved_searches_test.go | 23 ++-- .../spanneradapters/event_producer.go | 27 ++++ .../spanneradapters/event_producer_test.go | 69 ++++++++++ lib/workertypes/types.go | 12 ++ .../pkg/producer/batch_handler.go | 88 ++++++++++++ .../pkg/producer/batch_handler_test.go | 130 ++++++++++++++++++ 9 files changed, 506 insertions(+), 26 deletions(-) create mode 100644 lib/gcppubsub/gcppubsubadapters/batch_event_producer.go create mode 100644 lib/gcppubsub/gcppubsubadapters/batch_event_producer_test.go create mode 100644 workers/event_producer/pkg/producer/batch_handler.go create mode 100644 workers/event_producer/pkg/producer/batch_handler_test.go diff --git a/lib/gcppubsub/gcppubsubadapters/batch_event_producer.go b/lib/gcppubsub/gcppubsubadapters/batch_event_producer.go new file mode 100644 index 000000000..f6aeb709c --- /dev/null +++ b/lib/gcppubsub/gcppubsubadapters/batch_event_producer.go @@ -0,0 +1,54 @@ +// 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" + "fmt" + + "github.com/GoogleChrome/webstatus.dev/lib/event" + refreshv1 "github.com/GoogleChrome/webstatus.dev/lib/event/refreshsearchcommand/v1" + "github.com/GoogleChrome/webstatus.dev/lib/workertypes" +) + +type BatchFanOutPublisherAdapter struct { + client EventPublisher + topicID string +} + +func NewBatchFanOutPublisherAdapter(client EventPublisher, topicID string) *BatchFanOutPublisherAdapter { + return &BatchFanOutPublisherAdapter{client: client, topicID: topicID} +} + +func (a *BatchFanOutPublisherAdapter) PublishRefreshCommand(ctx context.Context, + cmd workertypes.RefreshSearchCommand) error { + evt := refreshv1.RefreshSearchCommand{ + SearchID: cmd.SearchID, + Query: cmd.Query, + Frequency: refreshv1.JobFrequency(cmd.Frequency), + Timestamp: cmd.Timestamp, + } + + msg, err := event.New(evt) + if err != nil { + return fmt.Errorf("failed to create event: %w", err) + } + + if _, err := a.client.Publish(ctx, a.topicID, msg); err != nil { + return fmt.Errorf("failed to publish message: %w", err) + } + + return nil +} diff --git a/lib/gcppubsub/gcppubsubadapters/batch_event_producer_test.go b/lib/gcppubsub/gcppubsubadapters/batch_event_producer_test.go new file mode 100644 index 000000000..5d5fb4e98 --- /dev/null +++ b/lib/gcppubsub/gcppubsubadapters/batch_event_producer_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 gcppubsubadapters + +import ( + "context" + "encoding/json" + "errors" + "testing" + "time" + + "github.com/GoogleChrome/webstatus.dev/lib/workertypes" + "github.com/google/go-cmp/cmp" +) + +func TestBatchFanOutPublisherAdapter_PublishRefreshCommand(t *testing.T) { + tests := []struct { + name string + cmd workertypes.RefreshSearchCommand + publishErr error + wantErr bool + expectedJSON string + }{ + { + name: "success", + cmd: workertypes.RefreshSearchCommand{ + SearchID: "search-123", + Query: "query=abc", + Frequency: workertypes.FrequencyDaily, + Timestamp: time.Date(2025, 1, 1, 0, 0, 0, 0, time.UTC), + }, + publishErr: nil, + wantErr: false, + expectedJSON: `{ + "apiVersion": "v1", + "kind": "RefreshSearchCommand", + "data": { + "search_id": "search-123", + "query": "query=abc", + "frequency": "DAILY", + "timestamp": "2025-01-01T00:00:00Z" + } + }`, + }, + { + name: "publish error", + cmd: workertypes.RefreshSearchCommand{ + SearchID: "search-123", + Query: "query=abc", + Frequency: workertypes.FrequencyDaily, + Timestamp: time.Date(2025, 1, 1, 0, 0, 0, 0, time.UTC), + }, + 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 := NewBatchFanOutPublisherAdapter(publisher, "test-topic") + + err := adapter.PublishRefreshCommand(context.Background(), tc.cmd) + + if (err != nil) != tc.wantErr { + t.Errorf("PublishRefreshCommand() error = %v, wantErr %v", err, tc.wantErr) + } + + if !tc.wantErr { + verifyJSONPayload(t, publisher.publishedData, tc.expectedJSON) + } + }) + } +} + +func verifyJSONPayload(t *testing.T, actualBytes []byte, expectedJSON string) { + t.Helper() + + var actual interface{} + if err := json.Unmarshal(actualBytes, &actual); err != nil { + t.Fatalf("failed to unmarshal actual data: %v", err) + } + + var expected interface{} + if err := json.Unmarshal([]byte(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/list_user_saved_searches.go b/lib/gcpspanner/list_user_saved_searches.go index c10ccc712..34da59a5a 100644 --- a/lib/gcpspanner/list_user_saved_searches.go +++ b/lib/gcpspanner/list_user_saved_searches.go @@ -168,29 +168,20 @@ func (c *Client) ListUserSavedSearches( }, nil } -type savedSearchIDContainer struct { - ID string `spanner:"ID"` +type SavedSearchBriefDetails struct { + ID string `spanner:"ID"` + Query string `spanner:"Query"` } func (m userSavedSearchListerMapper) SelectAll() spanner.Statement { return spanner.Statement{ - SQL: "SELECT ID FROM SavedSearches", + SQL: "SELECT ID, Query FROM SavedSearches", Params: nil, } } // Used by the Cloud Scheduler batch job to find all entities to process. -func (c *Client) ListAllSavedSearchIDs( - ctx context.Context) ([]string, error) { - ret, err := newAllEntityReader[userSavedSearchListerMapper, savedSearchIDContainer](c).readAll(ctx) - if err != nil { - return nil, err - } - - ids := make([]string, 0, len(ret)) - for _, r := range ret { - ids = append(ids, r.ID) - } - - return ids, nil +func (c *Client) ListAllSavedSearches( + ctx context.Context) ([]SavedSearchBriefDetails, error) { + return newAllEntityReader[userSavedSearchListerMapper, SavedSearchBriefDetails](c).readAll(ctx) } diff --git a/lib/gcpspanner/list_user_saved_searches_test.go b/lib/gcpspanner/list_user_saved_searches_test.go index 87b7473c3..0850a59ec 100644 --- a/lib/gcpspanner/list_user_saved_searches_test.go +++ b/lib/gcpspanner/list_user_saved_searches_test.go @@ -281,30 +281,33 @@ func userSavedSearchesPageEquality(left, right *UserSavedSearchesPage) bool { }) } -func TestListAllSavedSearchIDs(t *testing.T) { +func TestListAllSavedSearches(t *testing.T) { restartDatabaseContainer(t) searches := loadFakeSavedSearches(t) t.Run("list all saved search IDs", func(t *testing.T) { - ids, err := spannerClient.ListAllSavedSearchIDs(context.Background()) + details, err := spannerClient.ListAllSavedSearches(context.Background()) if err != nil { t.Errorf("expected nil error. received %s", err) } - if len(ids) != len(searches) { - t.Errorf("expected %d results. received %d", len(searches), len(ids)) + if len(details) != len(searches) { + t.Errorf("expected %d results. received %d", len(searches), len(details)) } - expectedIDs := make([]string, len(searches)) + // Build expected details list from created searches (which have generated IDs) + expectedDetails := make([]SavedSearchBriefDetails, len(searches)) for idx, search := range searches { - expectedIDs[idx] = search.ID + expectedDetails[idx] = SavedSearchBriefDetails{ID: search.ID, Query: search.Query} } - slices.Sort(ids) - slices.Sort(expectedIDs) + slices.SortFunc(expectedDetails, sortSavedSearchBriefDetails) + slices.SortFunc(details, sortSavedSearchBriefDetails) - if !slices.Equal(ids, expectedIDs) { - t.Errorf("expected IDs %v but received %v", expectedIDs, ids) + if !slices.Equal(details, expectedDetails) { + t.Errorf("expected IDs %v but received %v", expectedDetails, details) } }) } + +func sortSavedSearchBriefDetails(a, b SavedSearchBriefDetails) int { return cmp.Compare(a.ID, b.ID) } diff --git a/lib/gcpspanner/spanneradapters/event_producer.go b/lib/gcpspanner/spanneradapters/event_producer.go index cf9c1160b..03cea2cb6 100644 --- a/lib/gcpspanner/spanneradapters/event_producer.go +++ b/lib/gcpspanner/spanneradapters/event_producer.go @@ -224,3 +224,30 @@ func (e *EventProducer) PublishEvent(ctx context.Context, req workertypes.Publis return nil } + +type BatchEventProducerSpannerClient interface { + ListAllSavedSearches( + ctx context.Context) ([]gcpspanner.SavedSearchBriefDetails, error) +} + +type BatchEventProducer struct { + client BatchEventProducerSpannerClient +} + +func NewBatchEventProducer(client BatchEventProducerSpannerClient) *BatchEventProducer { + return &BatchEventProducer{client: client} +} + +func (b *BatchEventProducer) ListAllSavedSearches(ctx context.Context) ([]workertypes.SearchJob, error) { + details, err := b.client.ListAllSavedSearches(ctx) + if err != nil { + return nil, err + } + + jobs := make([]workertypes.SearchJob, 0, len(details)) + for _, detail := range details { + jobs = append(jobs, workertypes.SearchJob{ID: detail.ID, Query: detail.Query}) + } + + return jobs, nil +} diff --git a/lib/gcpspanner/spanneradapters/event_producer_test.go b/lib/gcpspanner/spanneradapters/event_producer_test.go index 24f77d080..3de8fdbfb 100644 --- a/lib/gcpspanner/spanneradapters/event_producer_test.go +++ b/lib/gcpspanner/spanneradapters/event_producer_test.go @@ -653,3 +653,72 @@ func TestEventProducerDiffer_FetchFeatures(t *testing.T) { t.Error("Second page token mismatch") } } + +type mockBatchEventProducerSpannerClient struct { + listAllSavedSearchesCalled bool + listAllSavedSearchesResp []gcpspanner.SavedSearchBriefDetails + listAllSavedSearchesErr error +} + +func (m *mockBatchEventProducerSpannerClient) ListAllSavedSearches( + _ context.Context) ([]gcpspanner.SavedSearchBriefDetails, error) { + m.listAllSavedSearchesCalled = true + + return m.listAllSavedSearchesResp, m.listAllSavedSearchesErr +} + +func TestBatchEventProducer_ListAllSavedSearches(t *testing.T) { + tests := []struct { + name string + mockResp []gcpspanner.SavedSearchBriefDetails + mockErr error + wantSearchJobs []workertypes.SearchJob + wantErr bool + }{ + { + name: "success with searches", + mockResp: []gcpspanner.SavedSearchBriefDetails{{ID: "s1", Query: "q1"}, {ID: "s2", Query: "q2"}}, + mockErr: nil, + wantSearchJobs: []workertypes.SearchJob{{ID: "s1", Query: "q1"}, {ID: "s2", Query: "q2"}}, + wantErr: false, + }, + { + name: "success empty list", + mockResp: []gcpspanner.SavedSearchBriefDetails{}, + mockErr: nil, + wantSearchJobs: []workertypes.SearchJob{}, + wantErr: false, + }, + { + name: "lister error", + mockResp: nil, + mockErr: errors.New("db error"), + wantSearchJobs: nil, + wantErr: true, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + mock := new(mockBatchEventProducerSpannerClient) + mock.listAllSavedSearchesResp = tc.mockResp + mock.listAllSavedSearchesErr = tc.mockErr + + adapter := NewBatchEventProducer(mock) + + searchJobs, err := adapter.ListAllSavedSearches(context.Background()) + + if (err != nil) != tc.wantErr { + t.Errorf("ListAllSavedSearchIDs() error = %v, wantErr %v", err, tc.wantErr) + } + + if !mock.listAllSavedSearchesCalled { + t.Fatal("ListAllSavedSearchIDs not called") + } + + if diff := cmp.Diff(tc.wantSearchJobs, searchJobs); diff != "" { + t.Errorf("ListAllSavedSearchIDs() mismatch (-want +got):\n%s", diff) + } + }) + } +} diff --git a/lib/workertypes/types.go b/lib/workertypes/types.go index 2d0277cba..a79b2cd15 100644 --- a/lib/workertypes/types.go +++ b/lib/workertypes/types.go @@ -213,3 +213,15 @@ const ( FrequencyWeekly JobFrequency = "WEEKLY" FrequencyMonthly JobFrequency = "MONTHLY" ) + +type RefreshSearchCommand struct { + SearchID string + Query string + Frequency JobFrequency + Timestamp time.Time +} + +type SearchJob struct { + ID string + Query string +} diff --git a/workers/event_producer/pkg/producer/batch_handler.go b/workers/event_producer/pkg/producer/batch_handler.go new file mode 100644 index 000000000..652ee4e20 --- /dev/null +++ b/workers/event_producer/pkg/producer/batch_handler.go @@ -0,0 +1,88 @@ +// 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 producer + +import ( + "context" + "fmt" + "log/slog" + "time" + + "github.com/GoogleChrome/webstatus.dev/lib/event" + "github.com/GoogleChrome/webstatus.dev/lib/workertypes" +) + +type SearchLister interface { + ListAllSavedSearches(ctx context.Context) ([]workertypes.SearchJob, error) +} + +type CommandPublisher interface { + PublishRefreshCommand(ctx context.Context, cmd workertypes.RefreshSearchCommand) error +} + +type BatchUpdateHandler struct { + lister SearchLister + publisher CommandPublisher + now func() time.Time +} + +func NewBatchUpdateHandler(lister SearchLister, publisher CommandPublisher) *BatchUpdateHandler { + return &BatchUpdateHandler{ + lister: lister, + publisher: publisher, + now: time.Now, + } +} + +func (h *BatchUpdateHandler) ProcessBatchUpdate(ctx context.Context, triggerID string, + frequency workertypes.JobFrequency) error { + slog.InfoContext(ctx, "starting batch update fan-out", "trigger_id", triggerID, "frequency", frequency) + + // 1. List all Saved Searches + searches, err := h.lister.ListAllSavedSearches(ctx) + if err != nil { + // Transient db error should be retried + return fmt.Errorf("%w: failed to list saved searches: %w", event.ErrTransientFailure, err) + } + + slog.InfoContext(ctx, "found saved searches to refresh", "count", len(searches)) + + // 2. Fan-out + for _, search := range searches { + cmd := workertypes.RefreshSearchCommand{ + SearchID: search.ID, + Query: search.Query, + Frequency: frequency, + Timestamp: h.now(), + } + + if err := h.publisher.PublishRefreshCommand(ctx, cmd); err != nil { + // If we fail to publish one, we should probably fail the whole batch so it retries. + // But we don't want to re-publish successfully published ones if possible. + // Pub/Sub doesn't support transactional batch publishes across messages easily. + // Ideally, we just return error and let the handler retry. + // Idempotency in ProcessSearch handles the duplicates. + slog.ErrorContext(ctx, "failed to publish refresh command", "search_id", search.ID, "error", err, + "trigger_id", triggerID, "frequency", frequency) + + return fmt.Errorf("%w: failed to publish refresh command for search %s: %w", + event.ErrTransientFailure, search.ID, err) + } + } + + slog.InfoContext(ctx, "batch update fan-out complete", "trigger_id", triggerID) + + return nil +} diff --git a/workers/event_producer/pkg/producer/batch_handler_test.go b/workers/event_producer/pkg/producer/batch_handler_test.go new file mode 100644 index 000000000..7e89fe90c --- /dev/null +++ b/workers/event_producer/pkg/producer/batch_handler_test.go @@ -0,0 +1,130 @@ +// 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 producer + +import ( + "context" + "errors" + "testing" + + "github.com/GoogleChrome/webstatus.dev/lib/event" + "github.com/GoogleChrome/webstatus.dev/lib/workertypes" +) + +type mockSearchLister struct { + searches []workertypes.SearchJob + err error +} + +func (m *mockSearchLister) ListAllSavedSearches(_ context.Context) ([]workertypes.SearchJob, error) { + return m.searches, m.err +} + +type mockCommandPublisher struct { + commands []workertypes.RefreshSearchCommand + err error +} + +func (m *mockCommandPublisher) PublishRefreshCommand(_ context.Context, cmd workertypes.RefreshSearchCommand) error { + m.commands = append(m.commands, cmd) + + return m.err +} + +func TestProcessBatchUpdate(t *testing.T) { + tests := []struct { + name string + searches []workertypes.SearchJob + listerErr error + pubErr error + expectPubCalls int + wantErr bool + transient bool + }{ + { + name: "success with searches", + searches: []workertypes.SearchJob{ + {ID: "s1", Query: "q=1"}, + {ID: "s2", Query: "q=2"}, + }, + listerErr: nil, + pubErr: nil, + expectPubCalls: 2, + wantErr: false, + transient: false, + }, + { + name: "success empty list", + searches: []workertypes.SearchJob{}, + expectPubCalls: 0, + listerErr: nil, + pubErr: nil, + wantErr: false, + transient: false, + }, + { + name: "lister error", + listerErr: errors.New("db error"), + wantErr: true, + transient: true, + searches: nil, + pubErr: nil, + expectPubCalls: 0, + }, + { + name: "publisher error", + listerErr: nil, + searches: []workertypes.SearchJob{{ID: "s1", Query: "q=1"}}, + pubErr: errors.New("pub error"), + wantErr: true, + transient: true, + expectPubCalls: 1, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + lister := &mockSearchLister{searches: tc.searches, err: tc.listerErr} + pub := &mockCommandPublisher{err: tc.pubErr, commands: nil} + handler := NewBatchUpdateHandler(lister, pub) + + err := handler.ProcessBatchUpdate(context.Background(), "trigger-1", workertypes.FrequencyDaily) + + if (err != nil) != tc.wantErr { + t.Errorf("ProcessBatchUpdate() error = %v, wantErr %v", err, tc.wantErr) + } + + if tc.wantErr && tc.transient { + if !errors.Is(err, event.ErrTransientFailure) { + t.Errorf("Expected error to be transient") + } + } + + if len(pub.commands) != tc.expectPubCalls { + t.Errorf("Expected %d publish calls, got %d", tc.expectPubCalls, len(pub.commands)) + } + + // Verify command content for success case + if !tc.wantErr && len(tc.searches) > 0 { + if pub.commands[0].SearchID != "s1" { + t.Errorf("Command data mismatch") + } + if pub.commands[0].Frequency != workertypes.FrequencyDaily { + t.Errorf("Frequency mismatch") + } + } + }) + } +} From 9b95286560ca26431dd55d73c5e262771b533943 Mon Sep 17 00:00:00 2001 From: James Scott Date: Sat, 27 Dec 2025 03:21:51 +0000 Subject: [PATCH 06/27] feat(event_producer): wire up main application and infrastructure Connects all the adapters and logic components in the `event_producer` main entry point (`cmd/job/main.go`). This enables the worker to: 1. Subscribe to ingestion events (search refresh requests). 2. Subscribe to batch update triggers (fan-out requests). 3. Publish notifications to the downstream topic. 4. Interact with Spanner (metadata) and GCS (blobs). Infrastructure updates: - Updated `setup_pubsub.sh` to create Dead Letter Queues (DLQs) for ingestion, notification, and delivery topics, providing robust error handling. - Updated `DEVELOPMENT.md` with the correct local Pub/Sub port. - Updated `pod.yaml` with necessary environment variables for the new subscriptions. --- .dev/pubsub/run.sh | 47 ++++++++++++++++++++--- DEVELOPMENT.md | 2 +- lib/gcppubsub/client.go | 1 + workers/event_producer/cmd/job/main.go | 41 +++++++++++++++++--- workers/event_producer/go.mod | 15 ++++++++ workers/event_producer/go.sum | 7 ++++ workers/event_producer/manifests/pod.yaml | 6 ++- 7 files changed, 107 insertions(+), 12 deletions(-) diff --git a/.dev/pubsub/run.sh b/.dev/pubsub/run.sh index 03f96c72d..2c9bf8332 100755 --- a/.dev/pubsub/run.sh +++ b/.dev/pubsub/run.sh @@ -32,9 +32,11 @@ create_topic() { } # Function to create a Subscription (Pull). gcloud does not support subscription creation in the emulator, so we use curl. +# Usage: create_subscription [dlq_topic_name] create_subscription() { local topic_name=$1 local sub_name=$2 + local dlq_topic_name=$3 if [[ -z "$topic_name" || -z "$sub_name" ]]; then echo "Error: Topic name and Subscription name required." @@ -46,25 +48,60 @@ create_subscription() { # The emulator requires the full path to the topic in the JSON body local topic_path="projects/${PROJECT_ID}/topics/${topic_name}" + # Base JSON payload + local json_payload="{\"topic\": \"$topic_path\"" + + # If a DLQ topic is provided, add the deadLetterPolicy + if [[ -n "$dlq_topic_name" ]]; then + local dlq_topic_path="projects/${PROJECT_ID}/topics/${dlq_topic_name}" + # MaxDeliveryAttempts is set to 5 as a standard default + json_payload="${json_payload}, \"deadLetterPolicy\": {\"deadLetterTopic\": \"${dlq_topic_path}\", \"maxDeliveryAttempts\": 5}" + echo " -> With Dead Letter Queue: ${dlq_topic_name}" + fi + + # Close the JSON object + json_payload="${json_payload}}" + curl -s -X PUT "http://0.0.0.0:${PORT}/v1/projects/${PROJECT_ID}/subscriptions/${sub_name}" \ -H "Content-Type: application/json" \ - -d "{\"topic\": \"$topic_path\"}" + -d "$json_payload" echo -e "\nSubscription ${sub_name} for topic: ${topic_name} created." } gcloud beta emulators pubsub start --project="$PROJECT_ID" --host-port="0.0.0.0:$PORT" & -while ! curl -s -o /dev/null "localhost:$PORT"; do +while ! curl -s -f "http://0.0.0.0:${PORT}/v1/projects/${PROJECT_ID}/topics"; do sleep 1 # Wait 1 second before checking again echo "waiting until pubsub emulator responds before finishing setup" done +# --- 1. Dead Letter Queues (Create these first so main subs can reference them) --- + +# Ingestion DLQ +create_topic "ingestion-jobs-dead-letter-topic-id" +create_subscription "ingestion-jobs-dead-letter-topic-id" "ingestion-jobs-dead-letter-sub-id" + +# Notification/Fan-out DLQ +create_topic "notification-events-dead-letter-topic-id" +create_subscription "notification-events-dead-letter-topic-id" "notification-events-dead-letter-sub-id" + +# Delivery DLQ +create_topic "delivery-dead-letter-topic-id" +create_subscription "delivery-dead-letter-topic-id" "delivery-dead-letter-sub-id" + + +# --- 2. Main Topics and Subscriptions --- +create_topic "batch-updates-topic-id" +create_subscription "batch-updates-topic-id" "batch-updates-sub-id" "ingestion-jobs-dead-letter-topic-id" + create_topic "ingestion-jobs-topic-id" -create_subscription "ingestion-jobs-topic-id" "ingestion-jobs-sub-id" +create_subscription "ingestion-jobs-topic-id" "ingestion-jobs-sub-id" "ingestion-jobs-dead-letter-topic-id" + create_topic "notification-events-topic-id" -create_subscription "notification-events-topic-id" "notification-events-sub-id" +create_subscription "notification-events-topic-id" "notification-events-sub-id" "notification-events-dead-letter-topic-id" + create_topic "chime-delivery-topic-id" -create_subscription "chime-delivery-topic-id" "chime-delivery-sub-id" +create_subscription "chime-delivery-topic-id" "chime-delivery-sub-id" "delivery-dead-letter-topic-id" echo "Pub/Sub setup for webstatus.dev finished" diff --git a/DEVELOPMENT.md b/DEVELOPMENT.md index ecc7fa7d9..55689c9d8 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:8086 | +| pubsub | Pub/Sub Emulator | N/A | 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/lib/gcppubsub/client.go b/lib/gcppubsub/client.go index 88c29f55f..99ea9b74c 100644 --- a/lib/gcppubsub/client.go +++ b/lib/gcppubsub/client.go @@ -73,6 +73,7 @@ func (c *Client) Subscribe(ctx context.Context, subID string, msg.Ack() } else if errors.Is(workErr, event.ErrTransientFailure) { // NACK: Retry later + slog.WarnContext(ctx, "transient failure, will retry", "error", workErr) msg.Nack() } else { // ACK: Permanent failure or unknown error, do not retry diff --git a/workers/event_producer/cmd/job/main.go b/workers/event_producer/cmd/job/main.go index 449f0459d..24f83636f 100644 --- a/workers/event_producer/cmd/job/main.go +++ b/workers/event_producer/cmd/job/main.go @@ -20,8 +20,12 @@ import ( "os" "github.com/GoogleChrome/webstatus.dev/lib/gcpgcs" + "github.com/GoogleChrome/webstatus.dev/lib/gcpgcs/gcpgcsadapters" "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/workers/event_producer/pkg/producer" ) func main() { @@ -56,6 +60,20 @@ func main() { os.Exit(1) } + // For publishing to ingestion events to fan-out + ingestionTopicID := os.Getenv("INGESTION_TOPIC_ID") + if ingestionTopicID == "" { + slog.ErrorContext(ctx, "INGESTION_TOPIC_ID is not set. exiting...") + os.Exit(1) + } + + // For subscribing to batch events + batchSubID := os.Getenv("BATCH_UPDATE_SUBSCRIPTION_ID") + if batchSubID == "" { + slog.ErrorContext(ctx, "BATCH_UPDATE_SUBSCRIPTION_ID is not set. exiting...") + os.Exit(1) + } + // For publishing to notification events notificationTopicID := os.Getenv("NOTIFICATION_TOPIC_ID") if notificationTopicID == "" { @@ -75,17 +93,30 @@ func main() { os.Exit(1) } - _, err = gcpgcs.NewClient(ctx, stateBlobBucket) + blobClient, err := gcpgcs.NewClient(ctx, stateBlobBucket) if err != nil { slog.ErrorContext(ctx, "unable to create gcs client", "error", err) os.Exit(1) } + blobAdapter := gcpgcsadapters.NewEventProducer(blobClient, stateBlobBucket) - // TODO: https://github.com/GoogleChrome/webstatus.dev/issues/1848 - // Nil handler for now. Will fix later - err = queueClient.Subscribe(ctx, ingestionSubID, nil) + p := producer.NewEventProducer( + producer.NewDiffer(spanneradapters.NewEventProducerDiffer(spanneradapters.NewBackend(spannerClient))), + blobAdapter, spanneradapters.NewEventProducer(spannerClient), + gcppubsubadapters.NewEventProducerPublisherAdapter(queueClient, notificationTopicID)) + batch := producer.NewBatchUpdateHandler(spanneradapters.NewBatchEventProducer(spannerClient), + gcppubsubadapters.NewBatchFanOutPublisherAdapter(queueClient, ingestionTopicID)) + listener := gcppubsubadapters.NewEventProducerSubscriberAdapter( + p, batch, queueClient, gcppubsubadapters.SubscriberConfig{ + SearchSubscriptionID: ingestionSubID, + BatchUpdateSubscriptionID: batchSubID, + }) + // Start listening to ingestion events + slog.InfoContext(ctx, "starting event producer subscriber for ingestion events") + err = listener.Subscribe(ctx) if err != nil { - slog.ErrorContext(ctx, "unable to connect to subscription", "error", err) + slog.ErrorContext(ctx, "unable to start subscriber", "error", err) os.Exit(1) } + } diff --git a/workers/event_producer/go.mod b/workers/event_producer/go.mod index 52cfcc2ec..9059db8fc 100644 --- a/workers/event_producer/go.mod +++ b/workers/event_producer/go.mod @@ -19,10 +19,15 @@ require ( cloud.google.com/go v0.123.0 // indirect cloud.google.com/go/auth v0.17.0 // indirect cloud.google.com/go/auth/oauth2adapt v0.2.8 // indirect + cloud.google.com/go/cloudtasks v1.13.7 // indirect cloud.google.com/go/compute/metadata v0.9.0 // indirect + cloud.google.com/go/datastore v1.21.0 // indirect cloud.google.com/go/iam v1.5.3 // indirect + 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 github.com/GoogleCloudPlatform/grpc-gcp-go/grpcgcp v1.5.3 // indirect @@ -33,6 +38,7 @@ require ( github.com/apapsch/go-jsonmerge/v2 v2.0.0 // indirect github.com/cespare/xxhash/v2 v2.3.0 // indirect github.com/cncf/xds/go v0.0.0-20251110193048-8bfbf64dc13e // indirect + github.com/deckarep/golang-set v1.8.0 // indirect github.com/envoyproxy/go-control-plane/envoy v1.36.0 // indirect github.com/envoyproxy/protoc-gen-validate v1.2.1 // indirect github.com/felixge/httpsnoop v1.0.4 // indirect @@ -43,9 +49,15 @@ require ( github.com/go-openapi/jsonpointer v0.22.3 // indirect github.com/go-openapi/swag/jsonname v0.25.3 // indirect github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 // indirect + github.com/gomodule/redigo v1.9.3 // indirect + github.com/google/go-github/v77 v77.0.0 // indirect + github.com/google/go-querystring v1.1.0 // indirect github.com/google/s2a-go v0.1.9 // indirect github.com/googleapis/enterprise-certificate-proxy v0.3.7 // indirect github.com/googleapis/gax-go/v2 v2.15.0 // indirect + github.com/gorilla/handlers v1.5.2 // indirect + github.com/gorilla/mux v1.8.1 // indirect + github.com/gorilla/securecookie v1.1.2 // indirect github.com/josharian/intern v1.0.0 // indirect github.com/mailru/easyjson v0.9.1 // indirect github.com/mohae/deepcopy v0.0.0-20170929034955-c48cc78d4826 // indirect @@ -53,7 +65,9 @@ require ( github.com/oasdiff/yaml3 v0.0.0-20250309153720-d2182401db90 // indirect github.com/perimeterx/marshmallow v1.1.5 // indirect github.com/planetscale/vtprotobuf v0.6.1-0.20240319094008-0393e58bdf10 // indirect + github.com/sirupsen/logrus v1.9.3 // indirect github.com/spiffe/go-spiffe/v2 v2.6.0 // indirect + github.com/web-platform-tests/wpt.fyi v0.0.0-20251118162843-54f805c8a632 // indirect github.com/woodsbury/decimal128 v1.4.0 // indirect go.opencensus.io v0.24.0 // indirect go.opentelemetry.io/auto/sdk v1.2.1 // indirect @@ -79,4 +93,5 @@ require ( google.golang.org/genproto/googleapis/rpc v0.0.0-20251111163417-95abcf5c77ba // indirect google.golang.org/grpc v1.77.0 // indirect google.golang.org/protobuf v1.36.10 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect ) diff --git a/workers/event_producer/go.sum b/workers/event_producer/go.sum index dfb79e895..304967e9e 100644 --- a/workers/event_producer/go.sum +++ b/workers/event_producer/go.sum @@ -852,6 +852,8 @@ github.com/google/go-github/v77 v77.0.0 h1:9DsKKbZqil5y/4Z9mNpZDQnpli6PJbqipSuuN github.com/google/go-github/v77 v77.0.0/go.mod h1:c8VmGXRUmaZUqbctUcGEDWYnMrtzZfJhDSylEf1wfmA= github.com/google/go-querystring v1.1.0 h1:AnCroh3fv4ZBgVIf1Iwtovgjaw/GiKJo8M8yD/fhyJ8= github.com/google/go-querystring v1.1.0/go.mod h1:Kcdr2DB4koayq7X8pmAG4sNG59So17icRSOU623lUBU= +github.com/google/gofuzz v1.2.0 h1:xRy4A+RhZaiKjJ1bPfwQ8sedCA+YS2YcCHW6ec7JMi0= +github.com/google/gofuzz v1.2.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= github.com/google/martian v2.1.0+incompatible h1:/CP5g8u/VJHijgedC/Legn3BAbAaWPgecwXBIDzw5no= github.com/google/martian v2.1.0+incompatible/go.mod h1:9I4somxYTbIHy5NJKHRl3wXiIaQGbYVAs8BPL6v8lEs= github.com/google/martian/v3 v3.0.0/go.mod h1:y5Zk1BBys9G+gd6Jrk0W3cC1+ELVxBWuIGO+w/tUAp0= @@ -986,6 +988,8 @@ github.com/opencontainers/image-spec v1.1.1 h1:y0fUlFfIZhPF1W537XOLg0/fcx6zcHCJw github.com/opencontainers/image-spec v1.1.1/go.mod h1:qpqAh3Dmcf36wStyyWU+kCeDgrGnAve2nCC8+7h8Q0M= github.com/perimeterx/marshmallow v1.1.5 h1:a2LALqQ1BlHM8PZblsDdidgv1mWi1DgC2UmX50IvK2s= github.com/perimeterx/marshmallow v1.1.5/go.mod h1:dsXbUu8CRzfYP5a87xpp0xq9S3u0Vchtcl8we9tYaXw= +github.com/phayes/freeport v0.0.0-20220201140144-74d24b5ae9f5 h1:Ii+DKncOVM8Cu1Hc+ETb5K+23HdAMvESYE3ZJ5b5cMI= +github.com/phayes/freeport v0.0.0-20220201140144-74d24b5ae9f5/go.mod h1:iIss55rKnNBTvrwdmkUpLnDpZoAHvWaiq5+iMmen4AE= github.com/phpdave11/gofpdf v1.4.2/go.mod h1:zpO6xFn9yxo3YLyMvW8HcKWVdbNqgIfOOp2dXMnm1mY= github.com/phpdave11/gofpdi v1.0.12/go.mod h1:vBmVV0Do6hSBHC8uKUQ71JGW+ZGQq74llk/7bXwjDoI= github.com/phpdave11/gofpdi v1.0.13/go.mod h1:vBmVV0Do6hSBHC8uKUQ71JGW+ZGQq74llk/7bXwjDoI= @@ -1098,6 +1102,8 @@ go.opentelemetry.io/otel/trace v1.38.0/go.mod h1:j1P9ivuFsTceSWe1oY+EeW3sc+Pp42s go.opentelemetry.io/proto/otlp v0.7.0/go.mod h1:PqfVotwruBrMGOCsRd/89rSnXhoiJIqeYNgFYFoEGnI= go.opentelemetry.io/proto/otlp v0.15.0/go.mod h1:H7XAot3MsfNsj7EXtrA2q5xSNQ10UqI405h3+duxN4U= go.opentelemetry.io/proto/otlp v0.19.0/go.mod h1:H7XAot3MsfNsj7EXtrA2q5xSNQ10UqI405h3+duxN4U= +go.uber.org/mock v0.6.0 h1:hyF9dfmbgIX5EfOdasqLsWD6xqpNZlXblLB/Dbnwv3Y= +go.uber.org/mock v0.6.0/go.mod h1:KiVJ4BqZJaMj4svdfmHM0AUx4NJYO8ZNpPnZn1Z+BBU= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= golang.org/x/crypto v0.0.0-20190510104115-cbcb75029529/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= golang.org/x/crypto v0.0.0-20190605123033-f99c8df09eb5/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= @@ -1343,6 +1349,7 @@ golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBc golang.org/x/sys v0.0.0-20220610221304-9f5ed59c137d/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220615213510-4f61da869c0c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220624220833-87e55d714810/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220728004956-3c1f35247d10/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= diff --git a/workers/event_producer/manifests/pod.yaml b/workers/event_producer/manifests/pod.yaml index df7c8f41f..4a7fc3500 100644 --- a/workers/event_producer/manifests/pod.yaml +++ b/workers/event_producer/manifests/pod.yaml @@ -33,7 +33,7 @@ spec: - name: SPANNER_EMULATOR_HOST value: 'spanner:9010' - name: PUBSUB_EMULATOR_HOST - value: 'http://pubsub:8086' + value: 'pubsub:8060' - name: INGESTION_SUBSCRIPTION_ID value: 'ingestion-jobs-sub-id' - name: NOTIFICATION_TOPIC_ID @@ -42,6 +42,10 @@ spec: value: 'state-bucket' - name: STORAGE_EMULATOR_HOST value: 'http://gcs:4443' + - name: BATCH_UPDATE_SUBSCRIPTION_ID + value: 'batch-updates-sub-id' + - name: INGESTION_TOPIC_ID + value: 'ingestion-jobs-topic-id' resources: limits: cpu: 250m From 0ed7b721838662f70e1a2e3884fdf99f55363ec1 Mon Sep 17 00:00:00 2001 From: James Scott Date: Sun, 28 Dec 2025 03:06:37 +0000 Subject: [PATCH 07/27] structure event summary for type-safety and extensibility Overhauls the `EventSummary` structure in `workertypes` to provide strongly-typed, structured data for downstream processing. Key changes: - Introduced generic `Change[T]` type and strongly-typed value structs (`BaselineValue`, `BrowserValue`, `FeatureRef`) to replace raw strings in the summary. Note, there is an existing Change struct that we may want to eventually consolidate with this one. - Added `SummaryHighlightType` and `BrowserName` enums for type safety. - Switched `browser_changes` to a map keyed by `BrowserName`. - Add `MaxHighlights` variable and set it to 10,000 to make the Spanner summary the authoritative "hot" data source (as opposed to the non truncated blob in GCS). Note: We only anticipate 2k-3k features total so this is a very high limit. - Updated `FeatureDiffV1SummaryGenerator` to populate these structured fields. --- lib/workertypes/types.go | 409 ++++++++++++++++++++++++++++++++-- lib/workertypes/types_test.go | 136 +++++++++-- 2 files changed, 504 insertions(+), 41 deletions(-) diff --git a/lib/workertypes/types.go b/lib/workertypes/types.go index a79b2cd15..78c8e632f 100644 --- a/lib/workertypes/types.go +++ b/lib/workertypes/types.go @@ -32,6 +32,17 @@ var ( const ( // VersionEventSummaryV1 defines the schema version for v1 of the EventSummary. VersionEventSummaryV1 = "v1" + // MaxHighlights caps the number of detailed items stored in Spanner (The full highlights are stored in GCS). + // Spanner's 10MB limit can easily accommodate this. + // Calculation details: + // A typical highlight contains: + // - Feature info (ID, Name): ~50-80 bytes + // - 2 DocLinks (URL, Title, Slug): ~250 bytes + // - Changes metadata: ~50 bytes + // - JSON structure overhead: ~50 bytes + // Total ≈ 450-500 bytes. + // 10,000 highlights * 500 bytes = 5MB, which is 50% of the 10MB column limit. + MaxHighlights = 10000 ) type SavedSearchState struct { @@ -77,9 +88,97 @@ type SummaryCategories struct { // EventSummary matches the JSON structure stored in the database 'Summary' column. type EventSummary struct { - SchemaVersion string `json:"schemaVersion"` - Text string `json:"text"` - Categories SummaryCategories `json:"categories,omitzero"` + SchemaVersion string `json:"schemaVersion"` + Text string `json:"text"` + Categories SummaryCategories `json:"categories,omitzero"` + Truncated bool `json:"truncated"` + Highlights []SummaryHighlight `json:"highlights"` +} + +// Change represents a value transition from Old to New. +type Change[T any] struct { + From T `json:"from"` + To T `json:"to"` +} + +type FeatureRef struct { + ID string `json:"id"` + Name string `json:"name"` +} + +type DocLink struct { + URL string `json:"url"` + Title *string `json:"title,omitempty"` + Slug *string `json:"slug,omitempty"` +} + +type BaselineStatus string + +const ( + BaselineStatusLimited BaselineStatus = "limited" + BaselineStatusNewly BaselineStatus = "newly" + BaselineStatusWidely BaselineStatus = "widely" + BaselineStatusUnknown BaselineStatus = "unknown" +) + +type BaselineValue struct { + Status BaselineStatus `json:"status"` + LowDate *time.Time `json:"low_date,omitempty"` + HighDate *time.Time `json:"high_date,omitempty"` +} + +type BrowserStatus string + +const ( + BrowserStatusAvailable BrowserStatus = "available" + BrowserStatusUnavailable BrowserStatus = "unavailable" + BrowserStatusUnknown BrowserStatus = "" +) + +type BrowserValue struct { + Status BrowserStatus `json:"status"` + Version *string `json:"version,omitempty"` +} + +type BrowserName string + +const ( + BrowserChrome BrowserName = "chrome" + BrowserChromeAndroid BrowserName = "chrome_android" + BrowserEdge BrowserName = "edge" + BrowserFirefox BrowserName = "firefox" + BrowserFirefoxAndroid BrowserName = "firefox_android" + BrowserSafari BrowserName = "safari" + BrowserSafariIos BrowserName = "safari_ios" +) + +type SummaryHighlightType string + +const ( + SummaryHighlightTypeAdded SummaryHighlightType = "Added" + SummaryHighlightTypeRemoved SummaryHighlightType = "Removed" + SummaryHighlightTypeChanged SummaryHighlightType = "Changed" + SummaryHighlightTypeMoved SummaryHighlightType = "Moved" + SummaryHighlightTypeSplit SummaryHighlightType = "Split" +) + +type SplitChange struct { + From FeatureRef `json:"from"` + To []FeatureRef `json:"to"` +} + +type SummaryHighlight struct { + Type SummaryHighlightType `json:"type"` + FeatureID string `json:"feature_id"` + FeatureName string `json:"feature_name"` + DocLinks []DocLink `json:"doc_links,omitempty"` + + // Strongly typed change fields to support i18n and avoid interface{} + NameChange *Change[string] `json:"name_change,omitempty"` + BaselineChange *Change[BaselineValue] `json:"baseline_change,omitempty"` + BrowserChanges map[BrowserName]Change[BrowserValue] `json:"browser_changes,omitempty"` + Moved *Change[FeatureRef] `json:"moved,omitempty"` + Split *SplitChange `json:"split,omitempty"` } // SummaryVisitor defines the contract for consuming immutable Event Summaries. @@ -122,59 +221,323 @@ func (g FeatureDiffV1SummaryGenerator) GenerateJSONSummary( d v1.FeatureDiff) ([]byte, error) { var s EventSummary s.SchemaVersion = VersionEventSummaryV1 + + s.Categories, s.Text = g.calculateCategoriesAndText(d) + s.Highlights, s.Truncated = g.generateHighlights(d) + + b, err := json.Marshal(s) + if err != nil { + return nil, fmt.Errorf("%w: %w", ErrFailedToSerializeSummary, err) + } + + return b, nil +} + +func (g FeatureDiffV1SummaryGenerator) calculateCategoriesAndText(d v1.FeatureDiff) (SummaryCategories, string) { + var c SummaryCategories var parts []string + // 1. Populate Counts (Categories) if d.QueryChanged { parts = append(parts, "Search criteria updated") - s.Categories.QueryChanged = 1 + c.QueryChanged = 1 } - if len(d.Added) > 0 { parts = append(parts, fmt.Sprintf("%d features added", len(d.Added))) - s.Categories.Added = len(d.Added) + c.Added = len(d.Added) } if len(d.Removed) > 0 { parts = append(parts, fmt.Sprintf("%d features removed", len(d.Removed))) - s.Categories.Removed = len(d.Removed) + c.Removed = len(d.Removed) } if len(d.Moves) > 0 { parts = append(parts, fmt.Sprintf("%d features moved/renamed", len(d.Moves))) - s.Categories.Moved = len(d.Moves) + c.Moved = len(d.Moves) } if len(d.Splits) > 0 { parts = append(parts, fmt.Sprintf("%d features split", len(d.Splits))) - s.Categories.Split = len(d.Splits) + c.Split = len(d.Splits) } - if len(d.Modified) > 0 { parts = append(parts, fmt.Sprintf("%d features updated", len(d.Modified))) - s.Categories.Updated = len(d.Modified) - + c.Updated = len(d.Modified) for _, m := range d.Modified { if len(m.BrowserChanges) > 0 { - s.Categories.UpdatedImpl++ + c.UpdatedImpl++ } if m.NameChange != nil { - s.Categories.UpdatedRename++ + c.UpdatedRename++ } if m.BaselineChange != nil { - s.Categories.UpdatedBaseline++ + c.UpdatedBaseline++ } } } - if len(parts) == 0 { - s.Text = "No changes detected" - } else { - s.Text = strings.Join(parts, ", ") + text := "No changes detected" + if len(parts) > 0 { + text = strings.Join(parts, ", ") } - b, err := json.Marshal(s) - if err != nil { - return nil, fmt.Errorf("%w: %w", ErrFailedToSerializeSummary, err) + return c, text +} + +func (g FeatureDiffV1SummaryGenerator) generateHighlights(d v1.FeatureDiff) ([]SummaryHighlight, bool) { + var highlights []SummaryHighlight + var truncated bool + + if highlights, truncated = g.processModified(highlights, d.Modified); truncated { + return highlights, true } - return b, nil + if highlights, truncated = g.processAdded(highlights, d.Added); truncated { + return highlights, true + } + + if highlights, truncated = g.processRemoved(highlights, d.Removed); truncated { + return highlights, true + } + + if highlights, truncated = g.processMoves(highlights, d.Moves); truncated { + return highlights, true + } + + if highlights, truncated = g.processSplits(highlights, d.Splits); truncated { + return highlights, true + } + + return highlights, false +} + +func (g FeatureDiffV1SummaryGenerator) processModified(highlights []SummaryHighlight, + modified []v1.FeatureModified) ([]SummaryHighlight, bool) { + for _, m := range modified { + if len(highlights) >= MaxHighlights { + return highlights, true + } + + h := SummaryHighlight{ + Type: SummaryHighlightTypeChanged, + FeatureID: m.ID, + FeatureName: m.Name, + DocLinks: toDocLinks(m.Docs), + NameChange: nil, + BaselineChange: nil, + BrowserChanges: nil, + Moved: nil, + Split: nil, + } + + if m.BaselineChange != nil { + h.BaselineChange = &Change[BaselineValue]{ + From: toBaselineValue(m.BaselineChange.From), + To: toBaselineValue(m.BaselineChange.To), + } + } + if m.NameChange != nil { + h.NameChange = &Change[string]{ + From: m.NameChange.From, + To: m.NameChange.To, + } + } + + if len(m.BrowserChanges) > 0 { + h.BrowserChanges = make(map[BrowserName]Change[BrowserValue]) + for b, c := range m.BrowserChanges { + if c == nil { + continue + } + var key BrowserName + switch b { + case v1.Chrome: + key = BrowserChrome + case v1.ChromeAndroid: + key = BrowserChromeAndroid + case v1.Edge: + key = BrowserEdge + case v1.Firefox: + key = BrowserFirefox + case v1.FirefoxAndroid: + key = BrowserFirefoxAndroid + case v1.Safari: + key = BrowserSafari + case v1.SafariIos: + key = BrowserSafariIos + default: + continue + } + h.BrowserChanges[key] = Change[BrowserValue]{ + From: toBrowserValue(c.From), + To: toBrowserValue(c.To), + } + } + } + + highlights = append(highlights, h) + } + + return highlights, false +} + +func (g FeatureDiffV1SummaryGenerator) processAdded(highlights []SummaryHighlight, + added []v1.FeatureAdded) ([]SummaryHighlight, bool) { + for _, a := range added { + if len(highlights) >= MaxHighlights { + return highlights, true + } + highlights = append(highlights, SummaryHighlight{ + Type: SummaryHighlightTypeAdded, + FeatureID: a.ID, + FeatureName: a.Name, + DocLinks: toDocLinks(a.Docs), + NameChange: nil, + BaselineChange: nil, + BrowserChanges: nil, + Moved: nil, + Split: nil, + }) + } + + return highlights, false +} + +func (g FeatureDiffV1SummaryGenerator) processRemoved(highlights []SummaryHighlight, + removed []v1.FeatureRemoved) ([]SummaryHighlight, bool) { + for _, r := range removed { + if len(highlights) >= MaxHighlights { + return highlights, true + } + highlights = append(highlights, SummaryHighlight{ + Type: SummaryHighlightTypeRemoved, + FeatureID: r.ID, + FeatureName: r.Name, + DocLinks: nil, + Moved: nil, + Split: nil, + BaselineChange: nil, + NameChange: nil, + BrowserChanges: nil, + }) + } + + return highlights, false +} + +func (g FeatureDiffV1SummaryGenerator) processMoves(highlights []SummaryHighlight, + moves []v1.FeatureMoved) ([]SummaryHighlight, bool) { + for _, m := range moves { + if len(highlights) >= MaxHighlights { + return highlights, true + } + highlights = append(highlights, SummaryHighlight{ + Type: SummaryHighlightTypeMoved, + FeatureID: m.ToID, // Use new ID after move + FeatureName: m.ToName, + Moved: &Change[FeatureRef]{ + From: FeatureRef{ID: m.FromID, Name: m.FromName}, + To: FeatureRef{ID: m.ToID, Name: m.ToName}, + }, + BrowserChanges: nil, + BaselineChange: nil, + NameChange: nil, + DocLinks: nil, + Split: nil, + }) + } + + return highlights, false +} + +func (g FeatureDiffV1SummaryGenerator) processSplits(highlights []SummaryHighlight, + splits []v1.FeatureSplit) ([]SummaryHighlight, bool) { + for _, split := range splits { + if len(highlights) >= MaxHighlights { + return highlights, true + } + var to []FeatureRef + for _, t := range split.To { + to = append(to, FeatureRef{ID: t.ID, Name: t.Name}) + } + highlights = append(highlights, SummaryHighlight{ + Type: SummaryHighlightTypeSplit, + FeatureID: split.FromID, + FeatureName: split.FromName, + Split: &SplitChange{ + From: FeatureRef{ID: split.FromID, Name: split.FromName}, + To: to, + }, + Moved: nil, + BrowserChanges: nil, + BaselineChange: nil, + NameChange: nil, + DocLinks: nil, + }) + } + + return highlights, false +} + +func toDocLinks(docs *v1.Docs) []DocLink { + if docs == nil { + return nil + } + links := make([]DocLink, 0, len(docs.MdnDocs)) + for _, d := range docs.MdnDocs { + links = append(links, DocLink{ + URL: d.URL, + Title: d.Title, + Slug: d.Slug, + }) + } + + return links +} + +func toBaselineValue(s v1.BaselineState) BaselineValue { + val := BaselineValue{ + Status: BaselineStatusUnknown, + LowDate: nil, + HighDate: nil, + } + if s.Status.IsSet { + switch s.Status.Value { + case v1.Limited: + val.Status = BaselineStatusLimited + case v1.Newly: + val.Status = BaselineStatusNewly + case v1.Widely: + val.Status = BaselineStatusWidely + } + } + + if s.LowDate.IsSet { + val.LowDate = s.LowDate.Value + } + if s.HighDate.IsSet { + val.HighDate = s.HighDate.Value + } + + return val +} + +func toBrowserValue(s v1.BrowserState) BrowserValue { + val := BrowserValue{ + Status: BrowserStatusUnknown, + Version: nil, + } + if s.Status.IsSet { + switch s.Status.Value { + case v1.Available: + val.Status = BrowserStatusAvailable + case v1.Unavailable: + val.Status = BrowserStatusUnavailable + } + } + if s.Version.IsSet { + val.Version = s.Version.Value + } + + return val } type Reason string diff --git a/lib/workertypes/types_test.go b/lib/workertypes/types_test.go index ea2ad6f99..3263072bb 100644 --- a/lib/workertypes/types_test.go +++ b/lib/workertypes/types_test.go @@ -67,6 +67,8 @@ func TestParseEventSummary(t *testing.T) { UpdatedRename: 0, UpdatedBaseline: 0, }, + Truncated: false, + Highlights: nil, }, wantErr: false, }, @@ -118,6 +120,7 @@ func TestParseEventSummary(t *testing.T) { } func TestGenerateJSONSummaryFeatureDiffV1(t *testing.T) { + newlyDate := time.Date(2025, 1, 1, 0, 0, 0, 0, time.UTC) tests := []struct { name string diff v1.FeatureDiff @@ -134,7 +137,7 @@ func TestGenerateJSONSummaryFeatureDiffV1(t *testing.T) { Moves: nil, Splits: nil, }, - expected: `{"schemaVersion":"v1","text":"No changes detected"}`, + expected: `{"schemaVersion":"v1","text":"No changes detected","truncated":false,"highlights":null}`, expectedError: nil, }, { @@ -143,7 +146,9 @@ func TestGenerateJSONSummaryFeatureDiffV1(t *testing.T) { QueryChanged: true, Added: []v1.FeatureAdded{ {ID: "1", Name: "A", Reason: v1.ReasonNewMatch, Docs: nil}, - {ID: "2", Name: "B", Reason: v1.ReasonNewMatch, Docs: nil}, + {ID: "2", Name: "B", Reason: v1.ReasonNewMatch, Docs: &v1.Docs{ + MdnDocs: []v1.MdnDoc{{URL: "https://mdn.io/B", Title: generic.ValuePtr("B"), Slug: generic.ValuePtr("slug-b")}}, + }}, }, Removed: []v1.FeatureRemoved{ {ID: "3", Name: "C", Reason: v1.ReasonUnmatched}, @@ -167,7 +172,7 @@ func TestGenerateJSONSummaryFeatureDiffV1(t *testing.T) { }, To: v1.BaselineState{ Status: generic.SetOpt(v1.Newly), - LowDate: generic.UnsetOpt[*time.Time](), + LowDate: generic.SetOpt(&newlyDate), HighDate: generic.UnsetOpt[*time.Time](), }, }, @@ -188,7 +193,7 @@ func TestGenerateJSONSummaryFeatureDiffV1(t *testing.T) { }, To: v1.BrowserState{ Status: generic.SetOpt(v1.Available), Date: generic.UnsetOpt[*time.Time](), - Version: generic.UnsetOpt[*string](), + Version: generic.SetOpt(generic.ValuePtr("123")), }}, v1.ChromeAndroid: nil, v1.Edge: nil, @@ -213,21 +218,116 @@ func TestGenerateJSONSummaryFeatureDiffV1(t *testing.T) { }, expected: `{ -"schemaVersion":"v1", -"text":"Search criteria updated, 2 features added, 1 features removed, ` + + "schemaVersion": "v1", + "text": "Search criteria updated, 2 features added, 1 features removed, ` + `1 features moved/renamed, 1 features split, 3 features updated", -"categories": - { - "query_changed":1, - "added":2, - "removed":1, - "moved":1, - "split":1, - "updated":3, - "updated_impl":1, - "updated_rename":1, - "updated_baseline":1 - } + "categories": { + "query_changed": 1, + "added": 2, + "removed": 1, + "moved": 1, + "split": 1, + "updated": 3, + "updated_impl": 1, + "updated_rename": 1, + "updated_baseline": 1 + }, + "truncated": false, + "highlights": [ + { + "type": "Changed", + "feature_id": "8", + "feature_name": "H", + "baseline_change": { + "from": { + "status": "limited" + }, + "to": { + "status": "newly", + "low_date": "2025-01-01T00:00:00Z" + } + } + }, + { + "type": "Changed", + "feature_id": "9", + "feature_name": "I", + "browser_changes": { + "chrome": { + "from": { + "status": "unavailable" + }, + "to": { + "status": "available", + "version": "123" + } + } + } + }, + { + "type": "Changed", + "feature_id": "10", + "feature_name": "J", + "name_change": { + "from": "Old", + "to": "New" + } + }, + { + "type": "Added", + "feature_id": "1", + "feature_name": "A" + }, + { + "type": "Added", + "feature_id": "2", + "feature_name": "B", + "doc_links": [ + { + "url": "https://mdn.io/B", + "title": "B", + "slug": "slug-b" + } + ] + }, + { + "type": "Removed", + "feature_id": "3", + "feature_name": "C" + }, + { + "type": "Moved", + "feature_id": "5", + "feature_name": "E", + "moved": { + "from": { + "id": "4", + "name": "D" + }, + "to": { + "id": "5", + "name": "E" + } + } + }, + { + "type": "Split", + "feature_id": "6", + "feature_name": "F", + "split": { + "from": { + "id": "6", + "name": "F" + }, + "to": [ + { + "id": "7", + "name": "G" + } + ] + } + } + ] }`, expectedError: nil, }, From de0679070662425a679b75c1982fffaa8be748da Mon Sep 17 00:00:00 2001 From: James Scott Date: Sun, 28 Dec 2025 18:12:44 +0000 Subject: [PATCH 08/27] feat(push_delivery): implement dispatcher logic and worker types Introduces the core logic for the Push Delivery Worker, including the `Dispatcher` which orchestrates event processing, subscriber filtering, and delivery job queuing. This includes: - **Worker Types**: Added `EmailSubscriber`, `SubscriberSet`, `DeliveryMetadata`, and `EmailDeliveryJob` to `lib/workertypes/types.go` as shared domain types. - **Event Types**: Added `ToWorkertypes` helper method to `FeatureDiffEvent`. - **Dispatcher Logic**: Implemented `Dispatcher.ProcessEvent` using a visitor pattern (`deliveryJobGenerator`) to parse the event summary, find subscribers, filter based on triggers, and queue delivery jobs. - **Interfaces**: Defined `SubscriptionFinder`, `DeliveryPublisher`, and `SummaryParser` interfaces in `pkg/dispatcher`. --- lib/event/featurediff/v1/types.go | 17 + lib/workertypes/types.go | 42 ++ workers/push_delivery/go.mod | 17 +- workers/push_delivery/go.sum | 41 ++ .../pkg/dispatcher/dispatcher.go | 187 +++++++++ .../pkg/dispatcher/dispatcher_test.go | 385 ++++++++++++++++++ 6 files changed, 688 insertions(+), 1 deletion(-) create mode 100644 workers/push_delivery/pkg/dispatcher/dispatcher.go create mode 100644 workers/push_delivery/pkg/dispatcher/dispatcher_test.go diff --git a/lib/event/featurediff/v1/types.go b/lib/event/featurediff/v1/types.go index 5782f15cf..b84e3ecc2 100644 --- a/lib/event/featurediff/v1/types.go +++ b/lib/event/featurediff/v1/types.go @@ -69,6 +69,23 @@ type FeatureDiffEvent struct { func (FeatureDiffEvent) Kind() string { return "FeatureDiffEvent" } func (FeatureDiffEvent) APIVersion() string { return "v1" } +func (f JobFrequency) ToWorkertypes() workertypes.JobFrequency { + switch f { + case FrequencyImmediate: + return workertypes.FrequencyImmediate + case FrequencyDaily: + return workertypes.FrequencyDaily + case FrequencyWeekly: + return workertypes.FrequencyWeekly + case FrequencyMonthly: + return workertypes.FrequencyMonthly + case FrequencyUnknown: + return workertypes.FrequencyUnknown + } + + return workertypes.FrequencyUnknown +} + func ToJobFrequency(freq workertypes.JobFrequency) JobFrequency { switch freq { case workertypes.FrequencyImmediate: diff --git a/lib/workertypes/types.go b/lib/workertypes/types.go index 78c8e632f..7bd6470de 100644 --- a/lib/workertypes/types.go +++ b/lib/workertypes/types.go @@ -588,3 +588,45 @@ type SearchJob struct { ID string Query string } + +// EmailSubscriber represents a subscriber using an Email channel. +type EmailSubscriber struct { + SubscriptionID string + UserID string + Triggers []string + EmailAddress string +} + +// SubscriberSet groups subscribers by channel type to avoid runtime type assertions. +type SubscriberSet struct { + Emails []EmailSubscriber + // Future channel types (e.g. Webhook) can be added here. +} + +// DeliveryMetadata contains the necessary context from the original event +// required for rendering notifications (e.g. generating links), decoupled from the upstream event format. +type DeliveryMetadata struct { + EventID string + SearchID string + Query string + Frequency JobFrequency + GeneratedAt time.Time +} + +type DispatchEventMetadata struct { + EventID string + SearchID string + Frequency JobFrequency + Query string + GeneratedAt time.Time +} + +// EmailDeliveryJob represents a task to send an email. +type EmailDeliveryJob struct { + SubscriptionID string + RecipientEmail string + // SummaryRaw is the opaque JSON payload describing the event. + SummaryRaw []byte + // Metadata contains context for links and tracking. + Metadata DeliveryMetadata +} diff --git a/workers/push_delivery/go.mod b/workers/push_delivery/go.mod index f41db9c1f..a9e832968 100644 --- a/workers/push_delivery/go.mod +++ b/workers/push_delivery/go.mod @@ -6,7 +6,10 @@ replace github.com/GoogleChrome/webstatus.dev/lib => ../../lib replace github.com/GoogleChrome/webstatus.dev/lib/gen => ../../lib/gen -require github.com/GoogleChrome/webstatus.dev/lib v0.0.0-00010101000000-000000000000 +require ( + github.com/GoogleChrome/webstatus.dev/lib v0.0.0-00010101000000-000000000000 + github.com/google/go-cmp v0.7.0 +) require ( cel.dev/expr v0.25.1 // indirect @@ -25,21 +28,33 @@ require ( github.com/GoogleCloudPlatform/opentelemetry-operations-go/exporter/metric v0.54.0 // indirect github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/resourcemapping v0.54.0 // indirect github.com/antlr4-go/antlr/v4 v4.13.1 // indirect + github.com/apapsch/go-jsonmerge/v2 v2.0.0 // indirect github.com/cespare/xxhash/v2 v2.3.0 // indirect github.com/cncf/xds/go v0.0.0-20251110193048-8bfbf64dc13e // indirect github.com/envoyproxy/go-control-plane/envoy v1.36.0 // indirect github.com/envoyproxy/protoc-gen-validate v1.2.1 // indirect github.com/felixge/httpsnoop v1.0.4 // indirect + github.com/getkin/kin-openapi v0.133.0 // indirect github.com/go-jose/go-jose/v4 v4.1.3 // indirect github.com/go-logr/logr v1.4.3 // indirect github.com/go-logr/stdr v1.2.2 // indirect + github.com/go-openapi/jsonpointer v0.22.3 // indirect + github.com/go-openapi/swag/jsonname v0.25.3 // indirect github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 // indirect github.com/google/s2a-go v0.1.9 // indirect github.com/google/uuid v1.6.0 // indirect github.com/googleapis/enterprise-certificate-proxy v0.3.7 // indirect github.com/googleapis/gax-go/v2 v2.15.0 // indirect + github.com/josharian/intern v1.0.0 // indirect + github.com/mailru/easyjson v0.9.1 // indirect + github.com/mohae/deepcopy v0.0.0-20170929034955-c48cc78d4826 // indirect + github.com/oapi-codegen/runtime v1.1.2 // indirect + github.com/oasdiff/yaml v0.0.0-20250309154309-f31be36b4037 // indirect + github.com/oasdiff/yaml3 v0.0.0-20250309153720-d2182401db90 // indirect + github.com/perimeterx/marshmallow v1.1.5 // indirect github.com/planetscale/vtprotobuf v0.6.1-0.20240319094008-0393e58bdf10 // indirect github.com/spiffe/go-spiffe/v2 v2.6.0 // indirect + github.com/woodsbury/decimal128 v1.4.0 // indirect go.opencensus.io v0.24.0 // indirect go.opentelemetry.io/auto/sdk v1.2.1 // indirect go.opentelemetry.io/contrib/detectors/gcp v1.38.0 // indirect diff --git a/workers/push_delivery/go.sum b/workers/push_delivery/go.sum index 181f6edc7..dfb79e895 100644 --- a/workers/push_delivery/go.sum +++ b/workers/push_delivery/go.sum @@ -647,6 +647,7 @@ github.com/JohnCGriffin/overflow v0.0.0-20211019200055-46fa312c352c/go.mod h1:X0 github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY= github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU= github.com/OneOfOne/xxhash v1.2.2/go.mod h1:HSdplMjZKSmBqAxg5vPj2TmRDmfkzw+cTzAElWljhcU= +github.com/RaveNoX/go-jsoncommentstrip v1.0.0/go.mod h1:78ihd09MekBnJnxpICcwzCMzGrKSKYe4AqU6PDYYpjk= github.com/ajstarks/deck v0.0.0-20200831202436-30c9fc6549a9/go.mod h1:JynElWSGnm/4RlzPXRlREEwqTHAN3T56Bv2ITsFT3gY= github.com/ajstarks/deck/generate v0.0.0-20210309230005-c3f852c02e19/go.mod h1:T13YZdzov6OU0A1+RfKZiZN9ca6VeKdBdyDV+BY97Tk= github.com/ajstarks/svgo v0.0.0-20180226025133-644b8db467af/go.mod h1:K08gAheRH3/J6wwsYMMT4xOr94bZjxIelGM0+d/wbFw= @@ -658,6 +659,9 @@ github.com/antlr4-go/antlr/v4 v4.13.1/go.mod h1:GKmUxMtwp6ZgGwZSva4eWPC5mS6vUAmO github.com/apache/arrow/go/v10 v10.0.1/go.mod h1:YvhnlEePVnBS4+0z3fhPfUy7W1Ikj0Ih0vcRo/gZ1M0= github.com/apache/arrow/go/v11 v11.0.0/go.mod h1:Eg5OsL5H+e299f7u5ssuXsuHQVEGC4xei5aX110hRiI= github.com/apache/thrift v0.16.0/go.mod h1:PHK3hniurgQaNMZYaCLEqXKsYK8upmhPbmdP2FXSqgU= +github.com/apapsch/go-jsonmerge/v2 v2.0.0 h1:axGnT1gRIfimI7gJifB699GoE/oq+F2MU7Dml6nw9rQ= +github.com/apapsch/go-jsonmerge/v2 v2.0.0/go.mod h1:lvDnEdqiQrp0O42VQGgmlKpxL1AP2+08jFMw88y4klk= +github.com/bmatcuk/doublestar v1.1.1/go.mod h1:UD6OnuiIn0yFxxA2le/rnRU1G4RaI4UvFv1sNto9p6w= github.com/boombuler/barcode v1.0.0/go.mod h1:paBWMcWSl3LHKBqUq+rly7CNSldXjb2rDl3JlRe0mD8= github.com/boombuler/barcode v1.0.1/go.mod h1:paBWMcWSl3LHKBqUq+rly7CNSldXjb2rDl3JlRe0mD8= github.com/cenkalti/backoff/v4 v4.3.0 h1:MyRJ/UdXutAwSAT+s3wNd7MfTIcy71VQueUuFK343L8= @@ -745,6 +749,8 @@ github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2 github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U= github.com/fogleman/gg v1.2.1-0.20190220221249-0403632d5b90/go.mod h1:R/bRT+9gY/C5z7JzPU0zXsXHKM4/ayA+zqcVNZzPa1k= github.com/fogleman/gg v1.3.0/go.mod h1:R/bRT+9gY/C5z7JzPU0zXsXHKM4/ayA+zqcVNZzPa1k= +github.com/getkin/kin-openapi v0.133.0 h1:pJdmNohVIJ97r4AUFtEXRXwESr8b0bD721u/Tz6k8PQ= +github.com/getkin/kin-openapi v0.133.0/go.mod h1:boAciF6cXk5FhPqe/NQeBTeenbjqU4LhWBf09ILVvWE= github.com/ghodss/yaml v1.0.0/go.mod h1:4dBDuWmgqj2HViK6kFavaiC9ZROes6MMH2rRYeMEF04= github.com/go-fonts/dejavu v0.1.0/go.mod h1:4Wt4I4OU2Nq9asgDCteaAaWZOV24E+0/Pwo0gppep4g= github.com/go-fonts/latin-modern v0.2.0/go.mod h1:rQVLdDMK+mK1xscDwsqM5J8U2jrRa3T0ecnM9pNujks= @@ -765,8 +771,16 @@ github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= github.com/go-ole/go-ole v1.2.6 h1:/Fpf6oFPoeFik9ty7siob0G6Ke8QvQEuVcuChpwXzpY= github.com/go-ole/go-ole v1.2.6/go.mod h1:pprOEPIfldk/42T2oK7lQ4v4JSDwmV0As9GaiUsvbm0= +github.com/go-openapi/jsonpointer v0.22.3 h1:dKMwfV4fmt6Ah90zloTbUKWMD+0he+12XYAsPotrkn8= +github.com/go-openapi/jsonpointer v0.22.3/go.mod h1:0lBbqeRsQ5lIanv3LHZBrmRGHLHcQoOXQnf88fHlGWo= +github.com/go-openapi/swag/jsonname v0.25.3 h1:U20VKDS74HiPaLV7UZkztpyVOw3JNVsit+w+gTXRj0A= +github.com/go-openapi/swag/jsonname v0.25.3/go.mod h1:GPVEk9CWVhNvWhZgrnvRA6utbAltopbKwDu8mXNUMag= +github.com/go-openapi/testify/v2 v2.0.2 h1:X999g3jeLcoY8qctY/c/Z8iBHTbwLz7R2WXd6Ub6wls= +github.com/go-openapi/testify/v2 v2.0.2/go.mod h1:HCPmvFFnheKK2BuwSA0TbbdxJ3I16pjwMkYkP4Ywn54= github.com/go-pdf/fpdf v0.5.0/go.mod h1:HzcnA+A23uwogo0tp9yU+l3V+KXhiESpt1PMayhOh5M= github.com/go-pdf/fpdf v0.6.0/go.mod h1:HzcnA+A23uwogo0tp9yU+l3V+KXhiESpt1PMayhOh5M= +github.com/go-test/deep v1.0.8 h1:TDsG77qcSprGbC6vTN8OuXp5g+J+b5Pcguhf7Zt61VM= +github.com/go-test/deep v1.0.8/go.mod h1:5C2ZWiW0ErCdrYzpqxLbTX7MG14M9iiw8DgHncVwcsE= github.com/goccy/go-json v0.9.11/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I= github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q= github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q= @@ -904,8 +918,11 @@ github.com/hashicorp/golang-lru v0.5.1/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ github.com/iancoleman/strcase v0.2.0/go.mod h1:iwCmte+B7n89clKwxIoIXy/HfoL7AsD47ZCWhYzw7ho= github.com/ianlancetaylor/demangle v0.0.0-20181102032728-5e5cf60278f6/go.mod h1:aSSvb/t6k1mPoxDqO4vJh6VOCGPwU4O0C2/Eqndh1Sc= github.com/ianlancetaylor/demangle v0.0.0-20200824232613-28f6c0f3b639/go.mod h1:aSSvb/t6k1mPoxDqO4vJh6VOCGPwU4O0C2/Eqndh1Sc= +github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8HmY= +github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y= github.com/jstemmer/go-junit-report v0.0.0-20190106144839-af01ea7f8024/go.mod h1:6v2b51hI/fHJwM22ozAgKL4VKDeJcHhJFhtBdhmNjmU= github.com/jstemmer/go-junit-report v0.9.1/go.mod h1:Brl9GWCQeLvo8nXZwPNNblvFj/XSXhF0NWZEnDohbsk= +github.com/juju/gnuflag v0.0.0-20171113085948-2ce1bb71843d/go.mod h1:2PavIy+JPciBPrBUjwbNvtwB6RQlve+hkpll6QSNmOE= github.com/jung-kurt/gofpdf v1.0.0/go.mod h1:7Id9E/uU8ce6rXgefFLlgrJj/GYY22cpxn+r32jIOes= github.com/jung-kurt/gofpdf v1.0.3-0.20190309125859-24315acbbda5/go.mod h1:7Id9E/uU8ce6rXgefFLlgrJj/GYY22cpxn+r32jIOes= github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51/go.mod h1:CzGEWj7cYgsdH8dAjBGEr58BoE7ScuLd+fwFZ44+/x8= @@ -919,8 +936,11 @@ github.com/kr/fs v0.1.0/go.mod h1:FFnZGqtBN9Gxj7eW1uZ42v5BccTP0vu6NEaFoC2HwRg= github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI= github.com/kr/pretty v0.3.0/go.mod h1:640gp4NfQd8pI5XOwp5fnNeVWj67G7CFk/SaSQn7NBk= +github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= +github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= +github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0 h1:6E+4a0GO5zZEnZ81pIr0yLvtUWk2if982qA3F3QD6H4= github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0/go.mod h1:zJYVVT2jmtg6P3p1VtQj7WsuWi/y4VnjVBn7F8KPB3I= @@ -929,6 +949,8 @@ github.com/lyft/protoc-gen-star v0.6.1/go.mod h1:TGAoBVkt8w7MPG72TrKIu85MIdXwDuz github.com/lyft/protoc-gen-star/v2 v2.0.1/go.mod h1:RcCdONR2ScXaYnQC5tUzxzlpA3WVYF7/opLeUgcQs/o= github.com/magiconair/properties v1.8.10 h1:s31yESBquKXCV9a/ScB3ESkOjUYYv+X0rg8SYxI99mE= github.com/magiconair/properties v1.8.10/go.mod h1:Dhd985XPs7jluiymwWYZ0G4Z61jb3vdS329zhj2hYo0= +github.com/mailru/easyjson v0.9.1 h1:LbtsOm5WAswyWbvTEOqhypdPeZzHavpZx96/n553mR8= +github.com/mailru/easyjson v0.9.1/go.mod h1:1+xMtQp2MRNVL/V1bOzuP3aP8VNwRW55fQUto+XFtTU= github.com/mattn/go-isatty v0.0.12/go.mod h1:cbi8OIDigv2wuxKPP5vlRcQ1OAZbq2CE4Kysco4FUpU= github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= github.com/mattn/go-sqlite3 v1.14.14/go.mod h1:NyWgC/yNuGj7Q9rpYnZvas74GogHl5/Z4A/KQRfk6bU= @@ -948,12 +970,22 @@ github.com/moby/sys/userns v0.1.0 h1:tVLXkFOxVu9A64/yh59slHVv9ahO9UIev4JZusOLG/g github.com/moby/sys/userns v0.1.0/go.mod h1:IHUYgu/kao6N8YZlp9Cf444ySSvCmDlmzUcYfDHOl28= github.com/moby/term v0.5.0 h1:xt8Q1nalod/v7BqbG21f8mQPqH+xAaC9C3N3wfWbVP0= github.com/moby/term v0.5.0/go.mod h1:8FzsFHVUBGZdbDsJw/ot+X+d5HLUbvklYLJ9uGfcI3Y= +github.com/mohae/deepcopy v0.0.0-20170929034955-c48cc78d4826 h1:RWengNIwukTxcDr9M+97sNutRR1RKhG96O6jWumTTnw= +github.com/mohae/deepcopy v0.0.0-20170929034955-c48cc78d4826/go.mod h1:TaXosZuwdSHYgviHp1DAtfrULt5eUgsSMsZf+YrPgl8= github.com/morikuni/aec v1.0.0 h1:nP9CBfwrvYnBRgY6qfDQkygYDmYwOilePFkwzv4dU8A= github.com/morikuni/aec v1.0.0/go.mod h1:BbKIizmSmc5MMPqRYbxO4ZU0S0+P200+tUnFx7PXmsc= +github.com/oapi-codegen/runtime v1.1.2 h1:P2+CubHq8fO4Q6fV1tqDBZHCwpVpvPg7oKiYzQgXIyI= +github.com/oapi-codegen/runtime v1.1.2/go.mod h1:SK9X900oXmPWilYR5/WKPzt3Kqxn/uS/+lbpREv+eCg= +github.com/oasdiff/yaml v0.0.0-20250309154309-f31be36b4037 h1:G7ERwszslrBzRxj//JalHPu/3yz+De2J+4aLtSRlHiY= +github.com/oasdiff/yaml v0.0.0-20250309154309-f31be36b4037/go.mod h1:2bpvgLBZEtENV5scfDFEtB/5+1M4hkQhDQrccEJ/qGw= +github.com/oasdiff/yaml3 v0.0.0-20250309153720-d2182401db90 h1:bQx3WeLcUWy+RletIKwUIt4x3t8n2SxavmoclizMb8c= +github.com/oasdiff/yaml3 v0.0.0-20250309153720-d2182401db90/go.mod h1:y5+oSEHCPT/DGrS++Wc/479ERge0zTFxaF8PbGKcg2o= github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U= github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM= github.com/opencontainers/image-spec v1.1.1 h1:y0fUlFfIZhPF1W537XOLg0/fcx6zcHCJwooC2xJA040= github.com/opencontainers/image-spec v1.1.1/go.mod h1:qpqAh3Dmcf36wStyyWU+kCeDgrGnAve2nCC8+7h8Q0M= +github.com/perimeterx/marshmallow v1.1.5 h1:a2LALqQ1BlHM8PZblsDdidgv1mWi1DgC2UmX50IvK2s= +github.com/perimeterx/marshmallow v1.1.5/go.mod h1:dsXbUu8CRzfYP5a87xpp0xq9S3u0Vchtcl8we9tYaXw= github.com/phpdave11/gofpdf v1.4.2/go.mod h1:zpO6xFn9yxo3YLyMvW8HcKWVdbNqgIfOOp2dXMnm1mY= github.com/phpdave11/gofpdi v1.0.12/go.mod h1:vBmVV0Do6hSBHC8uKUQ71JGW+ZGQq74llk/7bXwjDoI= github.com/phpdave11/gofpdi v1.0.13/go.mod h1:vBmVV0Do6hSBHC8uKUQ71JGW+ZGQq74llk/7bXwjDoI= @@ -979,6 +1011,8 @@ github.com/rogpeppe/fastuuid v1.2.0/go.mod h1:jVj6XXZzXRy/MSR5jhDC/2q6DgLz+nrA6L github.com/rogpeppe/go-internal v1.3.0/go.mod h1:M8bDsm7K2OlrFYOpmOWEs/qY81heoFRclV5y23lUDJ4= github.com/rogpeppe/go-internal v1.6.1/go.mod h1:xXDCJY+GAPziupqXw64V24skbSoqbTEfhy4qGm1nDQc= github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs= +github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= +github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= github.com/ruudk/golang-pdf417 v0.0.0-20181029194003-1af4ab5afa58/go.mod h1:6lfFZQK844Gfx8o5WFuvpxWRwnSoipWe/p622j1v06w= github.com/ruudk/golang-pdf417 v0.0.0-20201230142125-a7e3863a1245/go.mod h1:pQAZKsJ8yyVxGRWYNEm9oFB8ieLgKFnamEyDmSA0BRk= github.com/shirou/gopsutil/v4 v4.25.6 h1:kLysI2JsKorfaFPcYmcJqbzROzsBWEOAtw6A7dIfqXs= @@ -991,10 +1025,12 @@ github.com/spf13/afero v1.6.0/go.mod h1:Ai8FlHk4v/PARR026UzYexafAt9roJ7LcLMAmO6Z github.com/spf13/afero v1.9.2/go.mod h1:iUV7ddyEEZPO5gA3zD4fJt6iStLlL+Lg4m2cihcDf8Y= github.com/spiffe/go-spiffe/v2 v2.6.0 h1:l+DolpxNWYgruGQVV0xsfeya3CsC7m8iBzDnMpsbLuo= github.com/spiffe/go-spiffe/v2 v2.6.0/go.mod h1:gm2SeUoMZEtpnzPNs2Csc0D/gX33k1xIx7lEzqblHEs= +github.com/spkg/bom v0.0.0-20160624110644-59b7046e48ad/go.mod h1:qLr4V1qq6nMqFKkMo8ZTx3f+BZEkzsRUY10Xsm2mwU0= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= +github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= github.com/stretchr/testify v1.5.1/go.mod h1:5W2xD1RspED5o8YsWQXVCued0rvSQ+mT+I5cxcmMvtA= github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= @@ -1011,8 +1047,12 @@ github.com/tklauser/go-sysconf v0.3.12 h1:0QaGUFOdQaIVdPgfITYzaTegZvdCjmYO52cSFA github.com/tklauser/go-sysconf v0.3.12/go.mod h1:Ho14jnntGE1fpdOqQEEaiKRpvIavV0hSfmBq8nJbHYI= github.com/tklauser/numcpus v0.6.1 h1:ng9scYS7az0Bk4OZLvrNXNSAO2Pxr1XXRAPyjhIx+Fk= github.com/tklauser/numcpus v0.6.1/go.mod h1:1XfjsgE2zo8GVw7POkMbHENHzVg3GzmoZ9fESEdAacY= +github.com/ugorji/go/codec v1.2.11 h1:BMaWp1Bb6fHwEtbplGBGJ498wD+LKlNSl25MjdZY4dU= +github.com/ugorji/go/codec v1.2.11/go.mod h1:UNopzCgEMSXjBc6AOMqYvWC1ktqTAfzJZUZgYf6w6lg= github.com/web-platform-tests/wpt.fyi v0.0.0-20251118162843-54f805c8a632 h1:9t4b2caqsFRUWK4k9+lM/PkxLTCvo46ZQPVaoHPaCxY= github.com/web-platform-tests/wpt.fyi v0.0.0-20251118162843-54f805c8a632/go.mod h1:YpCvJq5JKA+aa4+jwK1S2uQ7r0horYVl6DHcl/G9S3s= +github.com/woodsbury/decimal128 v1.4.0 h1:xJATj7lLu4f2oObouMt2tgGiElE5gO6mSWUjQsBgUlc= +github.com/woodsbury/decimal128 v1.4.0/go.mod h1:BP46FUrVjVhdTbKT+XuQh2xfQaGki9LMIRJSFuh6THU= github.com/yuin/goldmark v1.1.25/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= github.com/yuin/goldmark v1.1.32/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= @@ -1697,6 +1737,7 @@ google.golang.org/protobuf v1.36.10 h1:AYd7cD/uASjIL6Q9LiTjz8JLcrh/88q5UObnmY3aO google.golang.org/protobuf v1.36.10/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= gopkg.in/errgo.v2 v2.1.0/go.mod h1:hNsd1EY+bozCKY1Ytp96fpM3vjJbqLJn88ws8XvfDNI= gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= diff --git a/workers/push_delivery/pkg/dispatcher/dispatcher.go b/workers/push_delivery/pkg/dispatcher/dispatcher.go new file mode 100644 index 000000000..6c81e1449 --- /dev/null +++ b/workers/push_delivery/pkg/dispatcher/dispatcher.go @@ -0,0 +1,187 @@ +// 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 dispatcher + +import ( + "context" + "fmt" + "log/slog" + + "github.com/GoogleChrome/webstatus.dev/lib/workertypes" +) + +type SubscriptionFinder interface { + // FindSubscribers retrieves all active subscriptions for the given search and frequency. + // The adapter is responsible for unmarshalling channel configs and sorting them into the set. + FindSubscribers(ctx context.Context, searchID string, + frequency workertypes.JobFrequency) (*workertypes.SubscriberSet, error) +} + +type DeliveryPublisher interface { + PublishEmailJob(ctx context.Context, job workertypes.EmailDeliveryJob) error +} + +// SummaryParser abstracts the logic for parsing the event summary blob. +type SummaryParser func(data []byte, v workertypes.SummaryVisitor) error + +type Dispatcher struct { + finder SubscriptionFinder + publisher DeliveryPublisher + parser SummaryParser +} + +func NewDispatcher(finder SubscriptionFinder, publisher DeliveryPublisher) *Dispatcher { + return &Dispatcher{ + finder: finder, + publisher: publisher, + parser: workertypes.ParseEventSummary, + } +} + +// ProcessEvent is the main entry point for the worker. +// It handles the "Fan-Out" logic: One Event -> Many Delivery Jobs. +func (d *Dispatcher) ProcessEvent(ctx context.Context, + metadata workertypes.DispatchEventMetadata, summary []byte) error { + slog.InfoContext(ctx, "processing event", "event_id", metadata.EventID, "search_id", metadata.SearchID) + + // 1. Generate Delivery Jobs from Event Summary + gen := &deliveryJobGenerator{ + finder: d.finder, + metadata: metadata, + // We pass the raw summary bytes down so it can be attached to the jobs + // without needing to re-marshal the struct. + rawSummary: summary, + emailJobs: nil, + } + + if err := d.parser(gen.rawSummary, gen); err != nil { + return fmt.Errorf("failed to parse event summary: %w", err) + } + + totalJobs := gen.JobCount() + if totalJobs == 0 { + slog.InfoContext(ctx, "no delivery jobs generated", "event_id", metadata.EventID) + + return nil + } + + slog.InfoContext(ctx, "dispatching jobs", "count", totalJobs) + + // 2. Publish Delivery Jobs + successCount := 0 + failCount := 0 + + // Publish Email Jobs + for _, job := range gen.emailJobs { + if err := d.publisher.PublishEmailJob(ctx, job); err != nil { + slog.ErrorContext(ctx, "failed to publish email job", + "subscription_id", job.SubscriptionID, "error", err) + failCount++ + } else { + successCount++ + } + } + + // TODO: Webhook jobs would be published here similarly + // https://github.com/GoogleChrome/webstatus.dev/issues/1859 + + slog.InfoContext(ctx, "dispatch complete", + "event_id", metadata.EventID, + "sent", successCount, + "failed", failCount, + "total_candidates", totalJobs) + + if failCount > 0 { + return fmt.Errorf("partial failure: %d/%d jobs failed to publish", failCount, totalJobs) + } + + return nil +} + +// deliveryJobGenerator implements workertypes.SummaryVisitor to generate jobs from V1 summaries. +type deliveryJobGenerator struct { + finder SubscriptionFinder + metadata workertypes.DispatchEventMetadata + rawSummary []byte + emailJobs []workertypes.EmailDeliveryJob +} + +func (g *deliveryJobGenerator) VisitV1(s workertypes.EventSummary) error { + // 1. Find Subscribers + subscribers, err := g.finder.FindSubscribers( + // TODO: modify Visitor to pass context down + // https://github.com/GoogleChrome/webstatus.dev/issues/2132 + context.TODO(), + g.metadata.SearchID, + g.metadata.Frequency) + if err != nil { + return fmt.Errorf("failed to find subscribers: %w", err) + } + + if subscribers == nil { + return nil + } + + deliveryMetadata := workertypes.DeliveryMetadata{ + EventID: g.metadata.EventID, + SearchID: g.metadata.SearchID, + Query: g.metadata.Query, + Frequency: g.metadata.Frequency, + GeneratedAt: g.metadata.GeneratedAt, + } + + // 2. Filter & Create Jobs + // Iterate Emails + for _, sub := range subscribers.Emails { + if !shouldNotifyV1(sub.Triggers, s) { + continue + } + g.emailJobs = append(g.emailJobs, workertypes.EmailDeliveryJob{ + SubscriptionID: sub.SubscriptionID, + RecipientEmail: sub.EmailAddress, + SummaryRaw: g.rawSummary, + Metadata: deliveryMetadata, + }) + } + + // TODO: Iterate Webhooks when supported. + // https://github.com/GoogleChrome/webstatus.dev/issues/1859 + + return nil +} + +// JobCount returns the total number of delivery jobs generated. +func (g *deliveryJobGenerator) JobCount() int { + // TODO: When we add Webhook jobs, sum them here too. + + return len(g.emailJobs) +} + +// shouldNotifyV1 determines if the V1 event summary matches any of the user's triggers. +func shouldNotifyV1(triggers []string, summary workertypes.EventSummary) bool { + hasChanges := summary.Categories.Added > 0 || + summary.Categories.Removed > 0 || + summary.Categories.Updated > 0 || + summary.Categories.Moved > 0 || + summary.Categories.Split > 0 || + summary.Categories.QueryChanged > 0 + + if !hasChanges { + return false + } + + // For V1, if the user has ANY triggers, and there ARE changes, we notify. + return len(triggers) > 0 +} diff --git a/workers/push_delivery/pkg/dispatcher/dispatcher_test.go b/workers/push_delivery/pkg/dispatcher/dispatcher_test.go new file mode 100644 index 000000000..738530837 --- /dev/null +++ b/workers/push_delivery/pkg/dispatcher/dispatcher_test.go @@ -0,0 +1,385 @@ +// 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 dispatcher + +import ( + "context" + "errors" + "testing" + "time" + + "github.com/GoogleChrome/webstatus.dev/lib/generic" + "github.com/GoogleChrome/webstatus.dev/lib/workertypes" + "github.com/google/go-cmp/cmp" +) + +// --- Mocks --- + +type findSubscribersReq struct { + SearchID string + Frequency string +} + +type mockSubscriptionFinder struct { + findCalledWith *findSubscribersReq + findReturnSet *workertypes.SubscriberSet + findReturnErr error +} + +func (m *mockSubscriptionFinder) FindSubscribers(_ context.Context, searchID string, + frequency workertypes.JobFrequency) (*workertypes.SubscriberSet, error) { + m.findCalledWith = &findSubscribersReq{ + SearchID: searchID, + Frequency: string(frequency), + } + + return m.findReturnSet, m.findReturnErr +} + +type mockDeliveryPublisher struct { + emailJobs []workertypes.EmailDeliveryJob + emailJobErr func(job workertypes.EmailDeliveryJob) error +} + +func (m *mockDeliveryPublisher) PublishEmailJob(_ context.Context, job workertypes.EmailDeliveryJob) error { + if m.emailJobErr != nil { + if err := m.emailJobErr(job); err != nil { + return err + } + } + m.emailJobs = append(m.emailJobs, job) + + return nil +} + +// --- Test Helpers --- + +// createTestSummary returns a populated EventSummary for testing. +func createTestSummary(hasChanges bool) workertypes.EventSummary { + categories := workertypes.SummaryCategories{ + QueryChanged: 0, + Added: 0, + Removed: 0, + Moved: 0, + Split: 0, + Updated: 0, + UpdatedImpl: 0, + UpdatedRename: 0, + UpdatedBaseline: 0, + } + + if hasChanges { + categories.Added = 1 + } + + return workertypes.EventSummary{ + SchemaVersion: "v1", + Text: "Test Summary", + Categories: categories, + Truncated: false, + Highlights: nil, + } +} + +// mockParserFactory creates a SummaryParser that injects the given summary directly. +func mockParserFactory(summary workertypes.EventSummary, err error) SummaryParser { + return func(_ []byte, v workertypes.SummaryVisitor) error { + if err != nil { + return err + } + + return v.VisitV1(summary) + } +} + +// --- Tests --- + +func emptyFinderReq() findSubscribersReq { + return findSubscribersReq{ + SearchID: "", + Frequency: "", + } +} + +func TestProcessEvent_Success(t *testing.T) { + ctx := context.Background() + eventID := "evt-123" + searchID := "search-abc" + frequency := workertypes.FrequencyImmediate + generatedAt := time.Date(2025, 1, 1, 0, 0, 0, 0, time.UTC) + summaryBytes := []byte("{}") + + metadata := workertypes.DispatchEventMetadata{ + EventID: eventID, + SearchID: searchID, + Query: "q=test", + Frequency: frequency, + GeneratedAt: generatedAt, + } + + // Two subscribers: one matching trigger, one not. + subSet := &workertypes.SubscriberSet{ + Emails: []workertypes.EmailSubscriber{ + { + SubscriptionID: "sub-1", + UserID: "user-1", + Triggers: []string{"any_change"}, // Matches logic in shouldNotifyV1 + EmailAddress: "user1@example.com", + }, + { + SubscriptionID: "sub-2", + UserID: "user-2", + Triggers: []string{}, // Empty triggers = no notify + EmailAddress: "user2@example.com", + }, + }, + } + + finder := &mockSubscriptionFinder{ + findReturnSet: subSet, + findReturnErr: nil, + findCalledWith: nil, + } + publisher := &mockDeliveryPublisher{ + emailJobs: nil, + emailJobErr: nil, + } + + // Create a summary that HAS changes so notification logic proceeds. + summary := createTestSummary(true) + parser := mockParserFactory(summary, nil) + + d := NewDispatcher(finder, publisher) + d.parser = parser + + if err := d.ProcessEvent(ctx, metadata, summaryBytes); err != nil { + t.Fatalf("ProcessEvent unexpected error: %v", err) + } + + // Assertions + expectedFinderReq := findSubscribersReq{ + SearchID: searchID, + Frequency: string(frequency), + } + assertFindSubscribersCalledWith(t, finder, &expectedFinderReq) + + if len(publisher.emailJobs) != 1 { + t.Fatalf("Expected 1 email job, got %d", len(publisher.emailJobs)) + } + + job := publisher.emailJobs[0] + expectedJob := workertypes.EmailDeliveryJob{ + SubscriptionID: "sub-1", + RecipientEmail: "user1@example.com", + SummaryRaw: summaryBytes, + Metadata: workertypes.DeliveryMetadata{ + EventID: eventID, + SearchID: searchID, + Query: "q=test", + Frequency: frequency, + GeneratedAt: generatedAt, + }, + } + + if diff := cmp.Diff(expectedJob, job); diff != "" { + t.Errorf("Job mismatch (-want +got):\n%s", diff) + } +} + +func assertFindSubscribersCalledWith(t *testing.T, finder *mockSubscriptionFinder, expected *findSubscribersReq) { + t.Helper() + if diff := cmp.Diff(expected, finder.findCalledWith); diff != "" { + t.Errorf("FindSubscribers called with mismatch (-want +got):\n%s", diff) + } +} + +func TestProcessEvent_NoChanges_FiltersAll(t *testing.T) { + ctx := context.Background() + metadata := workertypes.DispatchEventMetadata{ + EventID: "evt-1", + SearchID: "search-1", + Frequency: workertypes.FrequencyImmediate, + Query: "", + GeneratedAt: time.Time{}, + } + + subSet := &workertypes.SubscriberSet{ + Emails: []workertypes.EmailSubscriber{ + { + SubscriptionID: "sub-1", + UserID: "user-1", + Triggers: []string{"any_change"}, + EmailAddress: "user1@example.com", + }, + }, + } + + finder := &mockSubscriptionFinder{ + findReturnSet: subSet, + findReturnErr: nil, + findCalledWith: nil, + } + publisher := &mockDeliveryPublisher{ + emailJobs: nil, + emailJobErr: nil, + } + + // Summary with NO changes + summary := createTestSummary(false) + parser := mockParserFactory(summary, nil) + + d := NewDispatcher(finder, publisher) + d.parser = parser + + if err := d.ProcessEvent(ctx, metadata, []byte("{}")); err != nil { + t.Fatalf("ProcessEvent unexpected error: %v", err) + } + + if len(publisher.emailJobs) != 0 { + t.Errorf("Expected 0 jobs due to no changes, got %d", len(publisher.emailJobs)) + } +} + +func TestProcessEvent_ParserError(t *testing.T) { + d := NewDispatcher(nil, nil) + var summary workertypes.EventSummary + d.parser = mockParserFactory(summary, errors.New("parse error")) + + metadata := workertypes.DispatchEventMetadata{ + EventID: "", + SearchID: "", + Query: "", + Frequency: workertypes.FrequencyImmediate, + GeneratedAt: time.Time{}, + } + + err := d.ProcessEvent(context.Background(), metadata, []byte("{}")) + if err == nil { + t.Error("Expected error from parser, got nil") + } +} + +func TestProcessEvent_FinderError(t *testing.T) { + finder := &mockSubscriptionFinder{ + findReturnSet: nil, + findReturnErr: errors.New("db error"), + findCalledWith: nil, + } + + d := NewDispatcher(finder, nil) + // Provide a valid summary struct so parser succeeds + var summary workertypes.EventSummary + d.parser = mockParserFactory(summary, nil) + + metadata := workertypes.DispatchEventMetadata{ + EventID: "", + SearchID: "", + Query: "", + Frequency: "", + GeneratedAt: time.Time{}, + } + + err := d.ProcessEvent(context.Background(), metadata, []byte("{}")) + if err == nil { + t.Error("Expected error from finder, got nil") + } + assertFindSubscribersCalledWith(t, finder, generic.ValuePtr(emptyFinderReq())) +} + +func TestProcessEvent_PublisherPartialFailure(t *testing.T) { + ctx := context.Background() + // Two subscribers + subSet := &workertypes.SubscriberSet{ + Emails: []workertypes.EmailSubscriber{ + {SubscriptionID: "sub-1", Triggers: []string{"change"}, UserID: "u1", EmailAddress: "e1"}, + {SubscriptionID: "sub-2", Triggers: []string{"change"}, UserID: "u2", EmailAddress: "e2"}, + }, + } + + finder := &mockSubscriptionFinder{ + findReturnSet: subSet, + findReturnErr: nil, + findCalledWith: nil, + } + + // Publisher returns error for first job, success for second + publisher := &mockDeliveryPublisher{ + emailJobs: nil, + emailJobErr: func(job workertypes.EmailDeliveryJob) error { + if job.SubscriptionID == "sub-1" { + return errors.New("queue full") + } + + return nil + }, + } + + d := NewDispatcher(finder, publisher) + d.parser = mockParserFactory(createTestSummary(true), nil) + + metadata := workertypes.DispatchEventMetadata{ + EventID: "", + SearchID: "", + Query: "", + Frequency: "", + GeneratedAt: time.Time{}, + } + + err := d.ProcessEvent(ctx, metadata, []byte("{}")) + if err == nil { + t.Error("Expected error due to partial publish failure") + } + + if len(publisher.emailJobs) != 1 { + t.Errorf("Expected 1 successful job recorded, got %d", len(publisher.emailJobs)) + } + if publisher.emailJobs[0].SubscriptionID != "sub-2" { + t.Errorf("Expected sub-2 to succeed, got %s", publisher.emailJobs[0].SubscriptionID) + } + assertFindSubscribersCalledWith(t, finder, generic.ValuePtr(emptyFinderReq())) +} + +func TestProcessEvent_JobCount(t *testing.T) { + // Verify that if no jobs are generated (e.g. no matching triggers), ProcessEvent returns early/cleanly. + subSet := &workertypes.SubscriberSet{ + Emails: []workertypes.EmailSubscriber{ + {SubscriptionID: "sub-1", Triggers: []string{}, EmailAddress: "e1", UserID: "u1"}, // No match + }, + } + finder := &mockSubscriptionFinder{ + findReturnSet: subSet, + findReturnErr: nil, + findCalledWith: nil, + } + publisher := new(mockDeliveryPublisher) + d := NewDispatcher(finder, publisher) + d.parser = mockParserFactory(createTestSummary(true), nil) + + metadata := workertypes.DispatchEventMetadata{ + EventID: "", + SearchID: "", + Query: "", + Frequency: "", + GeneratedAt: time.Time{}, + } + + if err := d.ProcessEvent(context.Background(), metadata, []byte("{}")); err != nil { + t.Errorf("Expected no error for 0 jobs, got %v", err) + } + if len(publisher.emailJobs) != 0 { + t.Error("Expected 0 jobs") + } + assertFindSubscribersCalledWith(t, finder, generic.ValuePtr(emptyFinderReq())) +} From 4a35852d0882aad12912fccdc65dabcfc20bc1c9 Mon Sep 17 00:00:00 2001 From: James Scott Date: Sun, 28 Dec 2025 22:03:06 +0000 Subject: [PATCH 09/27] feat(api): update subscription triggers and frequencies Updates the OpenAPI definition, internal storage types, and worker tests to align with the V1 notification capabilities, specifically removing `DAILY` frequency (as it's not in scope for user selection yet) and standardizing on `IMMEDIATE`, `WEEKLY`, and `MONTHLY`. Changes: - **API**: - Removed `DAILY` frequency. - Added `IMMEDIATE`, `WEEKLY`, and `MONTHLY` frequencies. - Renamed and expanded subscription triggers (e.g. `feature_baseline_to_widely`). - **Storage**: - Updated `SavedSearchSubscription` struct to use typed `SubscriptionTrigger` and `JobFrequency` enums. - Updated Spanner adapters to convert between backend and storage types. - **Workers**: - Updated `EventProducer` logic to map `IMMEDIATE` jobs correctly. - Updated `Dispatcher` tests to use `IMMEDIATE` frequency instead of `DAILY`. - **Backend**: - Updated validation logic and tests to match the new enums. --- backend/pkg/httpserver/create_subscription.go | 11 +- .../httpserver/create_subscription_test.go | 52 +++---- .../httpserver/update_subscription_test.go | 28 ++-- lib/event/batchrefreshtrigger/v1/types.go | 3 - lib/event/featurediff/v1/types.go | 5 - lib/event/refreshsearchcommand/v1/types.go | 3 - .../searchconfigurationchanged/v1/types.go | 3 - .../batch_event_producer_test.go | 6 +- .../gcppubsubadapters/event_producer_test.go | 8 +- lib/gcpspanner/saved_search_state.go | 1 + lib/gcpspanner/saved_search_subscription.go | 37 +++-- .../saved_search_subscription_test.go | 52 ++++--- lib/gcpspanner/spanneradapters/backend.go | 104 +++++++++++--- .../spanneradapters/backend_test.go | 130 +++++++++--------- .../spanneradapters/event_producer.go | 7 +- .../spanneradapters/event_producer_test.go | 6 +- lib/workertypes/types.go | 1 - openapi/backend/openapi.yaml | 24 ++-- .../pkg/producer/batch_handler_test.go | 4 +- 19 files changed, 288 insertions(+), 197 deletions(-) diff --git a/backend/pkg/httpserver/create_subscription.go b/backend/pkg/httpserver/create_subscription.go index 502d229ec..4a8633896 100644 --- a/backend/pkg/httpserver/create_subscription.go +++ b/backend/pkg/httpserver/create_subscription.go @@ -29,9 +29,10 @@ import ( // The exhaustive linter is configured to check that this map is complete. func getAllSubscriptionTriggersSet() map[backend.SubscriptionTriggerWritable]any { return map[backend.SubscriptionTriggerWritable]any{ - backend.SubscriptionTriggerFeatureAnyBrowserImplementationComplete: nil, - backend.SubscriptionTriggerFeatureBaselineLimitedToNewly: nil, - backend.SubscriptionTriggerFeatureBaselineRegressionNewlyToLimited: nil, + backend.SubscriptionTriggerFeatureBaselineToNewly: nil, + backend.SubscriptionTriggerFeatureBaselineToWidely: nil, + backend.SubscriptionTriggerFeatureBrowserImplementationAnyComplete: nil, + backend.SubscriptionTriggerFeatureBaselineRegressionToLimited: nil, } } @@ -76,7 +77,9 @@ func validateSubscriptionTrigger(trigger *[]backend.SubscriptionTriggerWritable, // The exhaustive linter is configured to check that this map is complete. func getAllSubscriptionFrequenciesSet() map[backend.SubscriptionFrequency]any { return map[backend.SubscriptionFrequency]any{ - backend.SubscriptionFrequencyDaily: nil, + backend.SubscriptionFrequencyImmediate: nil, + backend.SubscriptionFrequencyWeekly: nil, + backend.SubscriptionFrequencyMonthly: nil, } } diff --git a/backend/pkg/httpserver/create_subscription_test.go b/backend/pkg/httpserver/create_subscription_test.go index 083bd3e8c..dc38321eb 100644 --- a/backend/pkg/httpserver/create_subscription_test.go +++ b/backend/pkg/httpserver/create_subscription_test.go @@ -51,8 +51,8 @@ func TestCreateSubscription(t *testing.T) { ChannelId: "channel-id", SavedSearchId: "search-id", Triggers: []backend.SubscriptionTriggerWritable{ - backend.SubscriptionTriggerFeatureAnyBrowserImplementationComplete}, - Frequency: "daily", + backend.SubscriptionTriggerFeatureBrowserImplementationAnyComplete}, + Frequency: "immediate", }, output: &backend.SubscriptionResponse{ Id: "sub-id", @@ -61,11 +61,11 @@ func TestCreateSubscription(t *testing.T) { Triggers: []backend.SubscriptionTriggerResponseItem{ { Value: backendtypes.AttemptToStoreSubscriptionTrigger( - backend.SubscriptionTriggerFeatureAnyBrowserImplementationComplete), + backend.SubscriptionTriggerFeatureBrowserImplementationAnyComplete), RawValue: nil, }, }, - Frequency: "daily", + Frequency: "immediate", CreatedAt: now, UpdatedAt: now, }, @@ -79,16 +79,16 @@ func TestCreateSubscription(t *testing.T) { strings.NewReader(`{ "channel_id": "channel-id", "saved_search_id": "search-id", - "triggers": ["feature_any_browser_implementation_complete"], - "frequency": "daily" + "triggers": ["feature_browser_implementation_any_complete"], + "frequency": "immediate" }`), ), expectedResponse: testJSONResponse(http.StatusCreated, `{ "id":"sub-id", "channel_id":"channel-id", "saved_search_id":"search-id", - "triggers": [{"value":"feature_any_browser_implementation_complete"}], - "frequency":"daily", + "triggers": [{"value":"feature_browser_implementation_any_complete"}], + "frequency":"immediate", "created_at":"`+now.Format(time.RFC3339Nano)+`", "updated_at":"`+now.Format(time.RFC3339Nano)+`" }`), @@ -103,8 +103,8 @@ func TestCreateSubscription(t *testing.T) { "/v1/users/me/subscriptions", strings.NewReader(`{ "saved_search_id": "search-id", - "triggers": ["feature_any_browser_implementation_complete"], - "frequency": "daily" + "triggers": ["feature_browser_implementation_any_complete"], + "frequency": "immediate" }`), ), expectedResponse: testJSONResponse(http.StatusBadRequest, ` @@ -124,8 +124,8 @@ func TestCreateSubscription(t *testing.T) { ChannelId: "channel-id", SavedSearchId: "search-id", Triggers: []backend.SubscriptionTriggerWritable{ - backend.SubscriptionTriggerFeatureAnyBrowserImplementationComplete}, - Frequency: "daily", + backend.SubscriptionTriggerFeatureBrowserImplementationAnyComplete}, + Frequency: "immediate", }, output: nil, err: backendtypes.ErrUserNotAuthorizedForAction, @@ -138,8 +138,8 @@ func TestCreateSubscription(t *testing.T) { strings.NewReader(`{ "channel_id": "channel-id", "saved_search_id": "search-id", - "triggers": ["feature_any_browser_implementation_complete"], - "frequency": "daily" + "triggers": ["feature_browser_implementation_any_complete"], + "frequency": "immediate" }`)), expectedResponse: testJSONResponse(http.StatusForbidden, `{ "code":403, @@ -154,8 +154,8 @@ func TestCreateSubscription(t *testing.T) { ChannelId: "channel-id", SavedSearchId: "search-id", Triggers: []backend.SubscriptionTriggerWritable{ - backend.SubscriptionTriggerFeatureAnyBrowserImplementationComplete}, - Frequency: "daily", + backend.SubscriptionTriggerFeatureBrowserImplementationAnyComplete}, + Frequency: "immediate", }, output: nil, err: fmt.Errorf("database error"), @@ -168,8 +168,8 @@ func TestCreateSubscription(t *testing.T) { strings.NewReader(`{ "channel_id": "channel-id", "saved_search_id": "search-id", - "triggers": ["feature_any_browser_implementation_complete"], - "frequency": "daily" + "triggers": ["feature_browser_implementation_any_complete"], + "frequency": "immediate" }`)), expectedResponse: testJSONResponse(http.StatusInternalServerError, `{ "code":500, @@ -214,8 +214,8 @@ func TestValidateSubscriptionCreation(t *testing.T) { ChannelId: "channel-id", SavedSearchId: "search-id", Triggers: []backend.SubscriptionTriggerWritable{ - backend.SubscriptionTriggerFeatureAnyBrowserImplementationComplete}, - Frequency: backend.SubscriptionFrequencyDaily, + backend.SubscriptionTriggerFeatureBrowserImplementationAnyComplete}, + Frequency: backend.SubscriptionFrequencyImmediate, }, want: nil, }, @@ -225,8 +225,8 @@ func TestValidateSubscriptionCreation(t *testing.T) { ChannelId: "", SavedSearchId: "searchid", Triggers: []backend.SubscriptionTriggerWritable{ - backend.SubscriptionTriggerFeatureAnyBrowserImplementationComplete}, - Frequency: backend.SubscriptionFrequencyDaily, + backend.SubscriptionTriggerFeatureBrowserImplementationAnyComplete}, + Frequency: backend.SubscriptionFrequencyImmediate, }, want: &fieldValidationErrors{ fieldErrorMap: map[string]string{ @@ -240,8 +240,8 @@ func TestValidateSubscriptionCreation(t *testing.T) { ChannelId: "channelid", SavedSearchId: "", Triggers: []backend.SubscriptionTriggerWritable{ - backend.SubscriptionTriggerFeatureAnyBrowserImplementationComplete}, - Frequency: backend.SubscriptionFrequencyDaily, + backend.SubscriptionTriggerFeatureBrowserImplementationAnyComplete}, + Frequency: backend.SubscriptionFrequencyImmediate, }, want: &fieldValidationErrors{ fieldErrorMap: map[string]string{ @@ -256,7 +256,7 @@ func TestValidateSubscriptionCreation(t *testing.T) { SavedSearchId: "searchid", Triggers: []backend.SubscriptionTriggerWritable{ "invalid_trigger"}, - Frequency: backend.SubscriptionFrequencyDaily, + Frequency: backend.SubscriptionFrequencyImmediate, }, want: &fieldValidationErrors{ fieldErrorMap: map[string]string{ @@ -270,7 +270,7 @@ func TestValidateSubscriptionCreation(t *testing.T) { ChannelId: "channelid", SavedSearchId: "searchid", Triggers: []backend.SubscriptionTriggerWritable{ - backend.SubscriptionTriggerFeatureAnyBrowserImplementationComplete}, + backend.SubscriptionTriggerFeatureBrowserImplementationAnyComplete}, Frequency: "invalid_frequency", }, want: &fieldValidationErrors{ diff --git a/backend/pkg/httpserver/update_subscription_test.go b/backend/pkg/httpserver/update_subscription_test.go index f7d0a3b14..4c6e16c9b 100644 --- a/backend/pkg/httpserver/update_subscription_test.go +++ b/backend/pkg/httpserver/update_subscription_test.go @@ -50,7 +50,7 @@ func TestUpdateSubscription(t *testing.T) { expectedSubscriptionID: "sub-id", expectedUpdateRequest: backend.UpdateSubscriptionRequest{ Triggers: &[]backend.SubscriptionTriggerWritable{ - backend.SubscriptionTriggerFeatureAnyBrowserImplementationComplete}, + backend.SubscriptionTriggerFeatureBrowserImplementationAnyComplete}, UpdateMask: []backend.UpdateSubscriptionRequestUpdateMask{ backend.UpdateSubscriptionRequestMaskTriggers}, Frequency: nil, @@ -62,7 +62,7 @@ func TestUpdateSubscription(t *testing.T) { Triggers: []backend.SubscriptionTriggerResponseItem{ { Value: backendtypes.AttemptToStoreSubscriptionTrigger( - backend.SubscriptionTriggerFeatureAnyBrowserImplementationComplete), + backend.SubscriptionTriggerFeatureBrowserImplementationAnyComplete), RawValue: nil, }, }, @@ -79,7 +79,7 @@ func TestUpdateSubscription(t *testing.T) { strings.NewReader(` { "triggers": - ["feature_any_browser_implementation_complete"], + ["feature_browser_implementation_any_complete"], "update_mask": ["triggers"] }`)), expectedResponse: testJSONResponse(http.StatusOK, @@ -87,7 +87,7 @@ func TestUpdateSubscription(t *testing.T) { "id":"sub-id", "channel_id":"channel-id", "saved_search_id":"search-id", - "triggers": [{"value":"feature_any_browser_implementation_complete"}], + "triggers": [{"value":"feature_browser_implementation_any_complete"}], "frequency":"daily", "created_at":"`+now.Format(time.RFC3339Nano)+`", "updated_at":"`+now.Format(time.RFC3339Nano)+`" @@ -101,7 +101,7 @@ func TestUpdateSubscription(t *testing.T) { expectedSubscriptionID: "sub-id", expectedUpdateRequest: backend.UpdateSubscriptionRequest{ Triggers: &[]backend.SubscriptionTriggerWritable{ - backend.SubscriptionTriggerFeatureAnyBrowserImplementationComplete, + backend.SubscriptionTriggerFeatureBrowserImplementationAnyComplete, }, Frequency: nil, UpdateMask: []backend.UpdateSubscriptionRequestUpdateMask{ @@ -116,7 +116,7 @@ func TestUpdateSubscription(t *testing.T) { "/v1/users/me/subscriptions/sub-id", strings.NewReader(` { - "triggers": ["feature_any_browser_implementation_complete"], + "triggers": ["feature_browser_implementation_any_complete"], "update_mask": ["triggers"] }`)), expectedResponse: testJSONResponse(http.StatusNotFound, ` @@ -134,7 +134,7 @@ func TestUpdateSubscription(t *testing.T) { "/v1/users/me/subscriptions/sub-id", strings.NewReader(` { - "triggers": ["feature_any_browser_implementation_complete"], + "triggers": ["feature_browser_implementation_any_complete"], "update_mask": ["invalid_field"] }`)), expectedResponse: testJSONResponse(http.StatusBadRequest, ` @@ -155,7 +155,7 @@ func TestUpdateSubscription(t *testing.T) { expectedSubscriptionID: "sub-id", expectedUpdateRequest: backend.UpdateSubscriptionRequest{ Triggers: &[]backend.SubscriptionTriggerWritable{ - backend.SubscriptionTriggerFeatureAnyBrowserImplementationComplete}, + backend.SubscriptionTriggerFeatureBrowserImplementationAnyComplete}, UpdateMask: []backend.UpdateSubscriptionRequestUpdateMask{ backend.UpdateSubscriptionRequestMaskTriggers}, Frequency: nil, @@ -169,7 +169,7 @@ func TestUpdateSubscription(t *testing.T) { "/v1/users/me/subscriptions/sub-id", strings.NewReader(` { - "triggers": ["feature_any_browser_implementation_complete"], + "triggers": ["feature_browser_implementation_any_complete"], "update_mask": ["triggers"] }`)), expectedResponse: testJSONResponse(http.StatusForbidden, ` @@ -186,7 +186,7 @@ func TestUpdateSubscription(t *testing.T) { expectedSubscriptionID: "sub-id", expectedUpdateRequest: backend.UpdateSubscriptionRequest{ Triggers: &[]backend.SubscriptionTriggerWritable{ - backend.SubscriptionTriggerFeatureAnyBrowserImplementationComplete}, + backend.SubscriptionTriggerFeatureBrowserImplementationAnyComplete}, UpdateMask: []backend.UpdateSubscriptionRequestUpdateMask{ backend.UpdateSubscriptionRequestMaskTriggers}, Frequency: nil, @@ -200,7 +200,7 @@ func TestUpdateSubscription(t *testing.T) { "/v1/users/me/subscriptions/sub-id", strings.NewReader(` { - "triggers": ["feature_any_browser_implementation_complete"], + "triggers": ["feature_browser_implementation_any_complete"], "update_mask": ["triggers"] }`)), expectedResponse: testJSONResponse(http.StatusInternalServerError, ` @@ -250,7 +250,7 @@ func TestValidateSubscriptionUpdate(t *testing.T) { name: "valid update", input: &backend.UpdateSubscriptionRequest{ Triggers: &[]backend.SubscriptionTriggerWritable{ - backend.SubscriptionTriggerFeatureAnyBrowserImplementationComplete}, + backend.SubscriptionTriggerFeatureBrowserImplementationAnyComplete}, UpdateMask: []backend.UpdateSubscriptionRequestUpdateMask{ backend.UpdateSubscriptionRequestMaskTriggers}, Frequency: nil, @@ -261,7 +261,7 @@ func TestValidateSubscriptionUpdate(t *testing.T) { name: "invalid update mask", input: &backend.UpdateSubscriptionRequest{ Triggers: &[]backend.SubscriptionTriggerWritable{ - backend.SubscriptionTriggerFeatureAnyBrowserImplementationComplete}, + backend.SubscriptionTriggerFeatureBrowserImplementationAnyComplete}, UpdateMask: []backend.UpdateSubscriptionRequestUpdateMask{ "invalid_field"}, Frequency: nil, @@ -290,7 +290,7 @@ func TestValidateSubscriptionUpdate(t *testing.T) { name: "nil update mask", input: &backend.UpdateSubscriptionRequest{ Triggers: &[]backend.SubscriptionTriggerWritable{ - backend.SubscriptionTriggerFeatureAnyBrowserImplementationComplete}, + backend.SubscriptionTriggerFeatureBrowserImplementationAnyComplete}, UpdateMask: nil, Frequency: nil, }, diff --git a/lib/event/batchrefreshtrigger/v1/types.go b/lib/event/batchrefreshtrigger/v1/types.go index aba9f421d..f943a46bb 100644 --- a/lib/event/batchrefreshtrigger/v1/types.go +++ b/lib/event/batchrefreshtrigger/v1/types.go @@ -22,7 +22,6 @@ type JobFrequency string const ( FrequencyUnknown JobFrequency = "UNKNOWN" FrequencyImmediate JobFrequency = "IMMEDIATE" - FrequencyDaily JobFrequency = "DAILY" FrequencyWeekly JobFrequency = "WEEKLY" FrequencyMonthly JobFrequency = "MONTHLY" ) @@ -31,8 +30,6 @@ func (f JobFrequency) ToWorkerTypeJobFrequency() workertypes.JobFrequency { switch f { case FrequencyImmediate: return workertypes.FrequencyImmediate - case FrequencyDaily: - return workertypes.FrequencyDaily case FrequencyWeekly: return workertypes.FrequencyWeekly case FrequencyMonthly: diff --git a/lib/event/featurediff/v1/types.go b/lib/event/featurediff/v1/types.go index b84e3ecc2..4df9d7633 100644 --- a/lib/event/featurediff/v1/types.go +++ b/lib/event/featurediff/v1/types.go @@ -25,7 +25,6 @@ type JobFrequency string const ( FrequencyUnknown JobFrequency = "UNKNOWN" FrequencyImmediate JobFrequency = "IMMEDIATE" - FrequencyDaily JobFrequency = "DAILY" FrequencyWeekly JobFrequency = "WEEKLY" FrequencyMonthly JobFrequency = "MONTHLY" ) @@ -73,8 +72,6 @@ func (f JobFrequency) ToWorkertypes() workertypes.JobFrequency { switch f { case FrequencyImmediate: return workertypes.FrequencyImmediate - case FrequencyDaily: - return workertypes.FrequencyDaily case FrequencyWeekly: return workertypes.FrequencyWeekly case FrequencyMonthly: @@ -90,8 +87,6 @@ func ToJobFrequency(freq workertypes.JobFrequency) JobFrequency { switch freq { case workertypes.FrequencyImmediate: return FrequencyImmediate - case workertypes.FrequencyDaily: - return FrequencyDaily case workertypes.FrequencyWeekly: return FrequencyWeekly case workertypes.FrequencyMonthly: diff --git a/lib/event/refreshsearchcommand/v1/types.go b/lib/event/refreshsearchcommand/v1/types.go index 1a23ceeb4..5d03b3686 100644 --- a/lib/event/refreshsearchcommand/v1/types.go +++ b/lib/event/refreshsearchcommand/v1/types.go @@ -26,7 +26,6 @@ type JobFrequency string const ( FrequencyUnknown JobFrequency = "UNKNOWN" FrequencyImmediate JobFrequency = "IMMEDIATE" - FrequencyDaily JobFrequency = "DAILY" FrequencyWeekly JobFrequency = "WEEKLY" FrequencyMonthly JobFrequency = "MONTHLY" ) @@ -35,8 +34,6 @@ func (f JobFrequency) ToWorkerTypeJobFrequency() workertypes.JobFrequency { switch f { case FrequencyImmediate: return workertypes.FrequencyImmediate - case FrequencyDaily: - return workertypes.FrequencyDaily case FrequencyWeekly: return workertypes.FrequencyWeekly case FrequencyMonthly: diff --git a/lib/event/searchconfigurationchanged/v1/types.go b/lib/event/searchconfigurationchanged/v1/types.go index 24849399b..b26a55134 100644 --- a/lib/event/searchconfigurationchanged/v1/types.go +++ b/lib/event/searchconfigurationchanged/v1/types.go @@ -26,7 +26,6 @@ type JobFrequency string const ( FrequencyUnknown JobFrequency = "UNKNOWN" FrequencyImmediate JobFrequency = "IMMEDIATE" - FrequencyDaily JobFrequency = "DAILY" FrequencyWeekly JobFrequency = "WEEKLY" FrequencyMonthly JobFrequency = "MONTHLY" ) @@ -35,8 +34,6 @@ func (f JobFrequency) ToWorkerTypeJobFrequency() workertypes.JobFrequency { switch f { case FrequencyImmediate: return workertypes.FrequencyImmediate - case FrequencyDaily: - return workertypes.FrequencyDaily case FrequencyWeekly: return workertypes.FrequencyWeekly case FrequencyMonthly: diff --git a/lib/gcppubsub/gcppubsubadapters/batch_event_producer_test.go b/lib/gcppubsub/gcppubsubadapters/batch_event_producer_test.go index 5d5fb4e98..b357ec6c0 100644 --- a/lib/gcppubsub/gcppubsubadapters/batch_event_producer_test.go +++ b/lib/gcppubsub/gcppubsubadapters/batch_event_producer_test.go @@ -38,7 +38,7 @@ func TestBatchFanOutPublisherAdapter_PublishRefreshCommand(t *testing.T) { cmd: workertypes.RefreshSearchCommand{ SearchID: "search-123", Query: "query=abc", - Frequency: workertypes.FrequencyDaily, + Frequency: workertypes.FrequencyImmediate, Timestamp: time.Date(2025, 1, 1, 0, 0, 0, 0, time.UTC), }, publishErr: nil, @@ -49,7 +49,7 @@ func TestBatchFanOutPublisherAdapter_PublishRefreshCommand(t *testing.T) { "data": { "search_id": "search-123", "query": "query=abc", - "frequency": "DAILY", + "frequency": "IMMEDIATE", "timestamp": "2025-01-01T00:00:00Z" } }`, @@ -59,7 +59,7 @@ func TestBatchFanOutPublisherAdapter_PublishRefreshCommand(t *testing.T) { cmd: workertypes.RefreshSearchCommand{ SearchID: "search-123", Query: "query=abc", - Frequency: workertypes.FrequencyDaily, + Frequency: workertypes.FrequencyImmediate, Timestamp: time.Date(2025, 1, 1, 0, 0, 0, 0, time.UTC), }, publishErr: errors.New("pubsub error"), diff --git a/lib/gcppubsub/gcppubsubadapters/event_producer_test.go b/lib/gcppubsub/gcppubsubadapters/event_producer_test.go index f3264d6e0..72bc5e33a 100644 --- a/lib/gcppubsub/gcppubsubadapters/event_producer_test.go +++ b/lib/gcppubsub/gcppubsubadapters/event_producer_test.go @@ -181,7 +181,7 @@ func TestSubscribe_RoutesRefreshSearchCommand(t *testing.T) { refreshCmd := refreshv1.RefreshSearchCommand{ SearchID: "s1", Query: "q1", - Frequency: "DAILY", + Frequency: "IMMEDIATE", Timestamp: time.Time{}, } ceWrapper := map[string]interface{}{ @@ -202,7 +202,7 @@ func TestSubscribe_RoutesRefreshSearchCommand(t *testing.T) { expectedCall := searchCall{ SearchID: "s1", Query: "q1", - Frequency: workertypes.FrequencyDaily, + Frequency: workertypes.FrequencyImmediate, TriggerID: "msg-1", } @@ -293,7 +293,7 @@ func TestPublisher_Publish(t *testing.T) { EventID: "evt-1", SearchID: "search-1", Query: "query-1", - Frequency: "DAILY", + Frequency: workertypes.FrequencyImmediate, Reasons: []workertypes.Reason{workertypes.ReasonDataUpdated}, Summary: []byte(`{"added": 1}`), StateID: "state-id-1", @@ -332,7 +332,7 @@ func TestPublisher_Publish(t *testing.T) { "diff_blob_path": "gs://bucket/diff-blob", "reasons": []interface{}{"DATA_UPDATED"}, "generated_at": now.Format(time.RFC3339), - "frequency": "DAILY", + "frequency": "IMMEDIATE", }, } diff --git a/lib/gcpspanner/saved_search_state.go b/lib/gcpspanner/saved_search_state.go index d2ac701bf..279134abc 100644 --- a/lib/gcpspanner/saved_search_state.go +++ b/lib/gcpspanner/saved_search_state.go @@ -37,6 +37,7 @@ const ( SavedSearchSnapshotTypeImmediate SavedSearchSnapshotType = "IMMEDIATE" SavedSearchSnapshotTypeWeekly SavedSearchSnapshotType = "WEEKLY" SavedSearchSnapshotTypeMonthly SavedSearchSnapshotType = "MONTHLY" + SavedSearchSnapshotTypeUnknown SavedSearchSnapshotType = "UNKNOWN" ) type SavedSearchState struct { diff --git a/lib/gcpspanner/saved_search_subscription.go b/lib/gcpspanner/saved_search_subscription.go index 718474ee6..13ab5c2b5 100644 --- a/lib/gcpspanner/saved_search_subscription.go +++ b/lib/gcpspanner/saved_search_subscription.go @@ -29,30 +29,41 @@ const savedSearchSubscriptionTable = "SavedSearchSubscriptions" // SavedSearchSubscription represents a row in the SavedSearchSubscription table. type SavedSearchSubscription struct { - ID string `spanner:"ID"` - ChannelID string `spanner:"ChannelID"` - SavedSearchID string `spanner:"SavedSearchID"` - Triggers []string `spanner:"Triggers"` - Frequency string `spanner:"Frequency"` - CreatedAt time.Time `spanner:"CreatedAt"` - UpdatedAt time.Time `spanner:"UpdatedAt"` + ID string `spanner:"ID"` + ChannelID string `spanner:"ChannelID"` + SavedSearchID string `spanner:"SavedSearchID"` + Triggers []SubscriptionTrigger `spanner:"Triggers"` + Frequency SavedSearchSnapshotType `spanner:"Frequency"` + CreatedAt time.Time `spanner:"CreatedAt"` + UpdatedAt time.Time `spanner:"UpdatedAt"` } +type SubscriptionTrigger string + +const ( + SubscriptionTriggerBrowserImplementationAnyComplete SubscriptionTrigger = "feature.browser_implementation." + + "any_complete" + SubscriptionTriggerFeatureBaselinePromoteToNewly SubscriptionTrigger = "feature.baseline.promote_to_newly" + SubscriptionTriggerFeatureBaselinePromoteToWidely SubscriptionTrigger = "feature.baseline.promote_to_widely" + SubscriptionTriggerFeatureBaselineRegressionToLimited SubscriptionTrigger = "feature.baseline.regression_to_limited" + SubscriptionTriggerUnknown SubscriptionTrigger = "unknown" +) + // CreateSavedSearchSubscriptionRequest is the request to create a subscription. type CreateSavedSearchSubscriptionRequest struct { UserID string ChannelID string SavedSearchID string - Triggers []string - Frequency string + Triggers []SubscriptionTrigger + Frequency SavedSearchSnapshotType } // UpdateSavedSearchSubscriptionRequest is a request to update a saved search subscription. type UpdateSavedSearchSubscriptionRequest struct { ID string UserID string - Triggers OptionallySet[[]string] - Frequency OptionallySet[string] + Triggers OptionallySet[[]SubscriptionTrigger] + Frequency OptionallySet[SavedSearchSnapshotType] } // ListSavedSearchSubscriptionsRequest is a request to list saved search subscriptions. @@ -318,7 +329,7 @@ type readAllActivePushSubscriptionsMapper struct { type activePushSubscriptionKey struct { SavedSearchID string - Frequency string + Frequency SavedSearchSnapshotType } func (m readAllActivePushSubscriptionsMapper) SelectAllByKeys(key activePushSubscriptionKey) spanner.Statement { @@ -354,7 +365,7 @@ func (m readAllActivePushSubscriptionsMapper) SelectAllByKeys(key activePushSubs func (c *Client) FindAllActivePushSubscriptions( ctx context.Context, savedSearchID string, - frequency string, + frequency SavedSearchSnapshotType, ) ([]SubscriberDestination, error) { return newAllByKeysEntityReader[ readAllActivePushSubscriptionsMapper, diff --git a/lib/gcpspanner/saved_search_subscription_test.go b/lib/gcpspanner/saved_search_subscription_test.go index 74570ecd9..6d687f7ee 100644 --- a/lib/gcpspanner/saved_search_subscription_test.go +++ b/lib/gcpspanner/saved_search_subscription_test.go @@ -58,8 +58,8 @@ func TestCreateAndGetSavedSearchSubscription(t *testing.T) { UserID: userID, ChannelID: channelID, SavedSearchID: savedSearchID, - Triggers: []string{"spec.links"}, - Frequency: "DAILY", + Triggers: []SubscriptionTrigger{SubscriptionTriggerFeatureBaselineRegressionToLimited}, + Frequency: SavedSearchSnapshotTypeImmediate, } subIDPtr, err := spannerClient.CreateSavedSearchSubscription(ctx, createReq) if err != nil { @@ -128,8 +128,8 @@ func TestGetSavedSearchSubscriptionFailsForWrongUser(t *testing.T) { UserID: userID, ChannelID: channelID, SavedSearchID: savedSearchID, - Triggers: []string{"baseline.status"}, - Frequency: "IMMEDIATE", + Triggers: []SubscriptionTrigger{SubscriptionTriggerFeatureBaselineRegressionToLimited}, + Frequency: SavedSearchSnapshotTypeImmediate, } subToUpdateIDPtr, err := spannerClient.CreateSavedSearchSubscription(ctx, baseCreateReq) @@ -178,8 +178,8 @@ func TestUpdateSavedSearchSubscription(t *testing.T) { UserID: userID, ChannelID: channelID, SavedSearchID: savedSearchID, - Triggers: []string{"baseline.status"}, - Frequency: "IMMEDIATE", + Triggers: []SubscriptionTrigger{SubscriptionTriggerFeatureBaselinePromoteToNewly}, + Frequency: SavedSearchSnapshotTypeImmediate, } subToUpdateIDPtr, err := spannerClient.CreateSavedSearchSubscription(ctx, baseCreateReq) @@ -189,10 +189,11 @@ func TestUpdateSavedSearchSubscription(t *testing.T) { subToUpdateID := *subToUpdateIDPtr updateReq := UpdateSavedSearchSubscriptionRequest{ - ID: subToUpdateID, - UserID: userID, - Triggers: OptionallySet[[]string]{Value: []string{"developer_signals.upvotes"}, IsSet: true}, - Frequency: OptionallySet[string]{Value: "WEEKLY_DIGEST", IsSet: true}, + ID: subToUpdateID, + UserID: userID, + Triggers: OptionallySet[[]SubscriptionTrigger]{Value: []SubscriptionTrigger{ + SubscriptionTriggerBrowserImplementationAnyComplete}, IsSet: true}, + Frequency: OptionallySet[SavedSearchSnapshotType]{Value: SavedSearchSnapshotTypeWeekly, IsSet: true}, } err = spannerClient.UpdateSavedSearchSubscription(ctx, updateReq) if err != nil { @@ -203,9 +204,13 @@ func TestUpdateSavedSearchSubscription(t *testing.T) { if err != nil { t.Fatalf("GetSavedSearchSubscription after update failed: %v", err) } - if retrieved.Frequency != "WEEKLY_DIGEST" { + if retrieved.Frequency != SavedSearchSnapshotTypeWeekly { t.Errorf("expected updated frequency, got %s", retrieved.Frequency) } + expectedTriggers := []SubscriptionTrigger{SubscriptionTriggerBrowserImplementationAnyComplete} + if diff := cmp.Diff(expectedTriggers, retrieved.Triggers); diff != "" { + t.Errorf("updated triggers mismatch (-want +got):\n%s", diff) + } } func TestDeleteSavedSearchSubscription(t *testing.T) { @@ -242,8 +247,8 @@ func TestDeleteSavedSearchSubscription(t *testing.T) { UserID: userID, ChannelID: channelID, SavedSearchID: savedSearchID, - Triggers: []string{"baseline.status"}, - Frequency: "IMMEDIATE", + Triggers: []SubscriptionTrigger{SubscriptionTriggerFeatureBaselinePromoteToNewly}, + Frequency: SavedSearchSnapshotTypeImmediate, } subToDeleteIDPtr, err := spannerClient.CreateSavedSearchSubscription(ctx, baseCreateReq) @@ -297,8 +302,8 @@ func TestListSavedSearchSubscriptions(t *testing.T) { UserID: userID, ChannelID: channelID, SavedSearchID: savedSearchID, - Triggers: []string{"baseline.status"}, - Frequency: "IMMEDIATE", + Triggers: []SubscriptionTrigger{SubscriptionTriggerFeatureBaselinePromoteToNewly}, + Frequency: SavedSearchSnapshotTypeImmediate, } // Create a few subscriptions to list @@ -410,7 +415,8 @@ func TestFindAllActivePushSubscriptions(t *testing.T) { // Subscription 1: Correct, on active EMAIL channel _, err = spannerClient.CreateSavedSearchSubscription(ctx, CreateSavedSearchSubscriptionRequest{ - UserID: userID, ChannelID: emailChannelID, SavedSearchID: savedSearchID, Frequency: "IMMEDIATE", Triggers: nil, + UserID: userID, ChannelID: emailChannelID, SavedSearchID: savedSearchID, + Frequency: SavedSearchSnapshotTypeImmediate, Triggers: nil, }) if err != nil { t.Fatalf("failed to create sub 1: %v", err) @@ -419,7 +425,8 @@ func TestFindAllActivePushSubscriptions(t *testing.T) { // Subscription 2: Correct, on active WEBHOOK channel // TODO: Enable webhook channel tests once webhooks are supported. // _, err = spannerClient.CreateSavedSearchSubscription(ctx, CreateSavedSearchSubscriptionRequest{ - // UserID: userID, ChannelID: webhookChannelID, SavedSearchID: savedSearchID, Frequency: "IMMEDIATE", + // UserID: userID, ChannelID: webhookChannelID, SavedSearchID: savedSearchID, + // Frequency: SavedSearchSnapshotTypeImmediate, // }) // if err != nil { // t.Fatalf("failed to create sub 2: %v", err) @@ -435,7 +442,8 @@ func TestFindAllActivePushSubscriptions(t *testing.T) { // Subscription 4: Non-push channel (RSS) _, err = spannerClient.CreateSavedSearchSubscription(ctx, CreateSavedSearchSubscriptionRequest{ - UserID: userID, ChannelID: rssChannelID, SavedSearchID: savedSearchID, Frequency: "IMMEDIATE", Triggers: nil, + UserID: userID, ChannelID: rssChannelID, SavedSearchID: savedSearchID, + Frequency: SavedSearchSnapshotTypeImmediate, Triggers: nil, }) if err != nil { t.Fatalf("failed to create sub 4: %v", err) @@ -443,15 +451,17 @@ func TestFindAllActivePushSubscriptions(t *testing.T) { // Subscription 5: Disabled channel _, err = spannerClient.CreateSavedSearchSubscription(ctx, CreateSavedSearchSubscriptionRequest{ - UserID: userID, ChannelID: disabledChannelID, SavedSearchID: savedSearchID, Frequency: "IMMEDIATE", - Triggers: nil, + UserID: userID, ChannelID: disabledChannelID, SavedSearchID: savedSearchID, + Frequency: SavedSearchSnapshotTypeImmediate, + Triggers: nil, }) if err != nil { t.Fatalf("failed to create sub 5: %v", err) } // Find subscribers - subscribers, err := spannerClient.FindAllActivePushSubscriptions(ctx, savedSearchID, "IMMEDIATE") + subscribers, err := spannerClient.FindAllActivePushSubscriptions(ctx, savedSearchID, + SavedSearchSnapshotTypeImmediate) if err != nil { t.Fatalf("FindAllActivePushSubscriptions failed: %v", err) } diff --git a/lib/gcpspanner/spanneradapters/backend.go b/lib/gcpspanner/spanneradapters/backend.go index e3c2841c3..8ca58c377 100644 --- a/lib/gcpspanner/spanneradapters/backend.go +++ b/lib/gcpspanner/spanneradapters/backend.go @@ -1332,29 +1332,49 @@ func (s *Backend) GetIDFromFeatureKey( return id, nil } -func backendTriggersToSpannerTriggers(backendTriggers []backend.SubscriptionTriggerWritable) []string { - triggers := make([]string, 0, len(backendTriggers)) +func backendTriggersToSpannerTriggers( + backendTriggers []backend.SubscriptionTriggerWritable) []gcpspanner.SubscriptionTrigger { + triggers := make([]gcpspanner.SubscriptionTrigger, 0, len(backendTriggers)) for _, trigger := range backendTriggers { - triggers = append(triggers, string(trigger)) + triggers = append(triggers, toSpannerSubscriptionTrigger(trigger)) } return triggers } -func spannerTriggersToBackendTriggers(spannerTriggers []string) []backend.SubscriptionTriggerResponseItem { +func spannerTriggersToBackendTriggers( + spannerTriggers []gcpspanner.SubscriptionTrigger) []backend.SubscriptionTriggerResponseItem { triggers := make([]backend.SubscriptionTriggerResponseItem, 0, len(spannerTriggers)) for _, trigger := range spannerTriggers { - input := backend.SubscriptionTriggerWritable(trigger) - switch input { - case backend.SubscriptionTriggerFeatureAnyBrowserImplementationComplete, - backend.SubscriptionTriggerFeatureBaselineLimitedToNewly, - backend.SubscriptionTriggerFeatureBaselineRegressionNewlyToLimited: + switch trigger { + case gcpspanner.SubscriptionTriggerBrowserImplementationAnyComplete: triggers = append(triggers, backend.SubscriptionTriggerResponseItem{ - Value: backendtypes.AttemptToStoreSubscriptionTrigger(input), + Value: backendtypes.AttemptToStoreSubscriptionTrigger( + backend.SubscriptionTriggerFeatureBrowserImplementationAnyComplete), RawValue: nil, }) + case gcpspanner.SubscriptionTriggerFeatureBaselinePromoteToNewly: + triggers = append(triggers, backend.SubscriptionTriggerResponseItem{ + Value: backendtypes.AttemptToStoreSubscriptionTrigger( + backend.SubscriptionTriggerFeatureBaselineToNewly), + RawValue: nil, + }) + case gcpspanner.SubscriptionTriggerFeatureBaselinePromoteToWidely: + triggers = append(triggers, backend.SubscriptionTriggerResponseItem{ + Value: backendtypes.AttemptToStoreSubscriptionTrigger( + backend.SubscriptionTriggerFeatureBaselineToWidely), + RawValue: nil, + }) + case gcpspanner.SubscriptionTriggerFeatureBaselineRegressionToLimited: + triggers = append(triggers, backend.SubscriptionTriggerResponseItem{ + Value: backendtypes.AttemptToStoreSubscriptionTrigger( + backend.SubscriptionTriggerFeatureBaselineRegressionToLimited), + RawValue: nil, + }) + case gcpspanner.SubscriptionTriggerUnknown: + fallthrough default: - value := trigger + value := string(trigger) triggers = append(triggers, backend.SubscriptionTriggerResponseItem{ Value: backendtypes.AttemptToStoreSubscriptionTriggerUnknown(), RawValue: &value, @@ -1365,14 +1385,63 @@ func spannerTriggersToBackendTriggers(spannerTriggers []string) []backend.Subscr return triggers } +func toBackendSubscriptionFrequency(freq gcpspanner.SavedSearchSnapshotType) backend.SubscriptionFrequency { + switch freq { + case gcpspanner.SavedSearchSnapshotTypeImmediate: + return backend.SubscriptionFrequencyImmediate + case gcpspanner.SavedSearchSnapshotTypeWeekly: + return backend.SubscriptionFrequencyWeekly + case gcpspanner.SavedSearchSnapshotTypeMonthly: + return backend.SubscriptionFrequencyMonthly + case gcpspanner.SavedSearchSnapshotTypeUnknown: + break + } + + slog.WarnContext(context.TODO(), "unknown subscription frequency from spanner", "frequency", freq) + + // Should not reach here normally. The database should not have unknown frequencies. + return backend.SubscriptionFrequencyImmediate +} + +func toSpannerSubscriptionFrequency(freq backend.SubscriptionFrequency) gcpspanner.SavedSearchSnapshotType { + switch freq { + case backend.SubscriptionFrequencyImmediate: + return gcpspanner.SavedSearchSnapshotTypeImmediate + case backend.SubscriptionFrequencyWeekly: + return gcpspanner.SavedSearchSnapshotTypeWeekly + case backend.SubscriptionFrequencyMonthly: + return gcpspanner.SavedSearchSnapshotTypeMonthly + } + + // Should not reach here normally. The http layer already checks for valid frequencies, default to unknown. + return gcpspanner.SavedSearchSnapshotTypeUnknown +} + +func toSpannerSubscriptionTrigger(trigger backend.SubscriptionTriggerWritable) gcpspanner.SubscriptionTrigger { + switch trigger { + case backend.SubscriptionTriggerFeatureBrowserImplementationAnyComplete: + return gcpspanner.SubscriptionTriggerBrowserImplementationAnyComplete + case backend.SubscriptionTriggerFeatureBaselineToNewly: + return gcpspanner.SubscriptionTriggerFeatureBaselinePromoteToNewly + case backend.SubscriptionTriggerFeatureBaselineToWidely: + return gcpspanner.SubscriptionTriggerFeatureBaselinePromoteToWidely + case backend.SubscriptionTriggerFeatureBaselineRegressionToLimited: + return gcpspanner.SubscriptionTriggerFeatureBaselineRegressionToLimited + } + + // Should not reach here normally. The http layer already checks for valid triggers, default to unknown. + return gcpspanner.SubscriptionTriggerUnknown +} + func (s *Backend) CreateSavedSearchSubscription(ctx context.Context, userID string, req backend.Subscription) (*backend.SubscriptionResponse, error) { + spannerFreq := toSpannerSubscriptionFrequency(req.Frequency) createReq := gcpspanner.CreateSavedSearchSubscriptionRequest{ UserID: userID, ChannelID: req.ChannelId, SavedSearchID: req.SavedSearchId, Triggers: backendTriggersToSpannerTriggers(req.Triggers), - Frequency: string(req.Frequency), + Frequency: spannerFreq, } id, err := s.client.CreateSavedSearchSubscription(ctx, createReq) @@ -1439,11 +1508,11 @@ func (s *Backend) UpdateSavedSearchSubscription(ctx context.Context, updateReq := gcpspanner.UpdateSavedSearchSubscriptionRequest{ ID: subscriptionID, UserID: userID, - Triggers: gcpspanner.OptionallySet[[]string]{ + Triggers: gcpspanner.OptionallySet[[]gcpspanner.SubscriptionTrigger]{ IsSet: false, Value: nil, }, - Frequency: gcpspanner.OptionallySet[string]{ + Frequency: gcpspanner.OptionallySet[gcpspanner.SavedSearchSnapshotType]{ IsSet: false, Value: "", }, @@ -1452,11 +1521,12 @@ func (s *Backend) UpdateSavedSearchSubscription(ctx context.Context, for _, field := range req.UpdateMask { switch field { case backend.UpdateSubscriptionRequestMaskTriggers: - updateReq.Triggers = gcpspanner.OptionallySet[[]string]{ + updateReq.Triggers = gcpspanner.OptionallySet[[]gcpspanner.SubscriptionTrigger]{ Value: backendTriggersToSpannerTriggers(*req.Triggers), IsSet: true} case backend.UpdateSubscriptionRequestMaskFrequency: - updateReq.Frequency = gcpspanner.OptionallySet[string]{Value: string(*req.Frequency), IsSet: true} + updateReq.Frequency = gcpspanner.OptionallySet[gcpspanner.SavedSearchSnapshotType]{ + Value: toSpannerSubscriptionFrequency(*req.Frequency), IsSet: true} } } @@ -1505,7 +1575,7 @@ func toBackendSubscription(sub *gcpspanner.SavedSearchSubscription) *backend.Sub ChannelId: sub.ChannelID, SavedSearchId: sub.SavedSearchID, Triggers: spannerTriggersToBackendTriggers(sub.Triggers), - Frequency: backend.SubscriptionFrequency(sub.Frequency), + Frequency: toBackendSubscriptionFrequency(sub.Frequency), CreatedAt: sub.CreatedAt, UpdatedAt: sub.UpdatedAt, } diff --git a/lib/gcpspanner/spanneradapters/backend_test.go b/lib/gcpspanner/spanneradapters/backend_test.go index dfbce5af4..c47e9f074 100644 --- a/lib/gcpspanner/spanneradapters/backend_test.go +++ b/lib/gcpspanner/spanneradapters/backend_test.go @@ -3653,16 +3653,17 @@ func TestCreateSavedSearchSubscription(t *testing.T) { ChannelId: channelID, SavedSearchId: savedSearchID, Triggers: []backend.SubscriptionTriggerWritable{ - backend.SubscriptionTriggerFeatureAnyBrowserImplementationComplete}, - Frequency: backend.SubscriptionFrequencyDaily, + backend.SubscriptionTriggerFeatureBrowserImplementationAnyComplete}, + Frequency: backend.SubscriptionFrequencyImmediate, }, createCfg: &mockCreateSavedSearchSubscriptionConfig{ expectedRequest: gcpspanner.CreateSavedSearchSubscriptionRequest{ UserID: userID, ChannelID: channelID, SavedSearchID: savedSearchID, - Triggers: []string{"feature_any_browser_implementation_complete"}, - Frequency: string(backend.SubscriptionFrequencyDaily), + Triggers: []gcpspanner.SubscriptionTrigger{ + gcpspanner.SubscriptionTriggerBrowserImplementationAnyComplete}, + Frequency: gcpspanner.SavedSearchSnapshotTypeImmediate, }, result: valuePtr(subID), returnedError: nil, @@ -3674,8 +3675,8 @@ func TestCreateSavedSearchSubscription(t *testing.T) { ID: subID, ChannelID: channelID, SavedSearchID: savedSearchID, - Triggers: []string{"feature_any_browser_implementation_complete"}, - Frequency: string(backend.SubscriptionFrequencyDaily), + Triggers: []gcpspanner.SubscriptionTrigger{gcpspanner.SubscriptionTriggerBrowserImplementationAnyComplete}, + Frequency: gcpspanner.SavedSearchSnapshotTypeImmediate, CreatedAt: now, UpdatedAt: now, }, @@ -3688,11 +3689,11 @@ func TestCreateSavedSearchSubscription(t *testing.T) { Triggers: []backend.SubscriptionTriggerResponseItem{ { Value: backendtypes.AttemptToStoreSubscriptionTrigger( - backend.SubscriptionTriggerFeatureAnyBrowserImplementationComplete), + backend.SubscriptionTriggerFeatureBrowserImplementationAnyComplete), RawValue: nil, }, }, - Frequency: backend.SubscriptionFrequencyDaily, + Frequency: backend.SubscriptionFrequencyImmediate, CreatedAt: now, UpdatedAt: now, }, @@ -3704,16 +3705,16 @@ func TestCreateSavedSearchSubscription(t *testing.T) { ChannelId: channelID, SavedSearchId: savedSearchID, Triggers: []backend.SubscriptionTriggerWritable{ - backend.SubscriptionTriggerFeatureAnyBrowserImplementationComplete}, - Frequency: backend.SubscriptionFrequencyDaily, + backend.SubscriptionTriggerFeatureBrowserImplementationAnyComplete}, + Frequency: backend.SubscriptionFrequencyImmediate, }, createCfg: &mockCreateSavedSearchSubscriptionConfig{ expectedRequest: gcpspanner.CreateSavedSearchSubscriptionRequest{ UserID: userID, ChannelID: channelID, SavedSearchID: savedSearchID, - Triggers: []string{"feature_any_browser_implementation_complete"}, - Frequency: string(backend.SubscriptionFrequencyDaily), + Triggers: []gcpspanner.SubscriptionTrigger{gcpspanner.SubscriptionTriggerBrowserImplementationAnyComplete}, + Frequency: gcpspanner.SavedSearchSnapshotTypeImmediate, }, result: nil, returnedError: gcpspanner.ErrMissingRequiredRole, @@ -3728,16 +3729,16 @@ func TestCreateSavedSearchSubscription(t *testing.T) { ChannelId: channelID, SavedSearchId: savedSearchID, Triggers: []backend.SubscriptionTriggerWritable{ - backend.SubscriptionTriggerFeatureAnyBrowserImplementationComplete}, - Frequency: backend.SubscriptionFrequencyDaily, + backend.SubscriptionTriggerFeatureBrowserImplementationAnyComplete}, + Frequency: backend.SubscriptionFrequencyImmediate, }, createCfg: &mockCreateSavedSearchSubscriptionConfig{ expectedRequest: gcpspanner.CreateSavedSearchSubscriptionRequest{ UserID: userID, ChannelID: channelID, SavedSearchID: savedSearchID, - Triggers: []string{"feature_any_browser_implementation_complete"}, - Frequency: string(backend.SubscriptionFrequencyDaily), + Triggers: []gcpspanner.SubscriptionTrigger{gcpspanner.SubscriptionTriggerBrowserImplementationAnyComplete}, + Frequency: gcpspanner.SavedSearchSnapshotTypeImmediate, }, result: nil, returnedError: errTest, @@ -3797,8 +3798,8 @@ func TestListSavedSearchSubscriptions(t *testing.T) { ID: "sub1", ChannelID: "chan1", SavedSearchID: "search1", - Triggers: []string{"feature_any_browser_implementation_complete"}, - Frequency: "daily", + Triggers: []gcpspanner.SubscriptionTrigger{gcpspanner.SubscriptionTriggerBrowserImplementationAnyComplete}, + Frequency: gcpspanner.SavedSearchSnapshotTypeImmediate, CreatedAt: now, UpdatedAt: now, }, @@ -3815,11 +3816,11 @@ func TestListSavedSearchSubscriptions(t *testing.T) { Triggers: []backend.SubscriptionTriggerResponseItem{ { Value: backendtypes.AttemptToStoreSubscriptionTrigger( - backend.SubscriptionTriggerFeatureAnyBrowserImplementationComplete), + backend.SubscriptionTriggerFeatureBrowserImplementationAnyComplete), RawValue: nil, }, }, - Frequency: "daily", + Frequency: backend.SubscriptionFrequencyImmediate, CreatedAt: now, UpdatedAt: now, }, @@ -3890,8 +3891,8 @@ func TestGetSavedSearchSubscription(t *testing.T) { ID: subID, ChannelID: "chan1", SavedSearchID: "search1", - Triggers: []string{"feature_any_browser_implementation_complete"}, - Frequency: "daily", + Triggers: []gcpspanner.SubscriptionTrigger{gcpspanner.SubscriptionTriggerBrowserImplementationAnyComplete}, + Frequency: gcpspanner.SavedSearchSnapshotTypeImmediate, CreatedAt: now, UpdatedAt: now, }, @@ -3904,11 +3905,11 @@ func TestGetSavedSearchSubscription(t *testing.T) { Triggers: []backend.SubscriptionTriggerResponseItem{ { Value: backendtypes.AttemptToStoreSubscriptionTrigger( - backend.SubscriptionTriggerFeatureAnyBrowserImplementationComplete), + backend.SubscriptionTriggerFeatureBrowserImplementationAnyComplete), RawValue: nil, }, }, - Frequency: "daily", + Frequency: backend.SubscriptionFrequencyImmediate, CreatedAt: now, UpdatedAt: now, }, @@ -3964,10 +3965,11 @@ func TestUpdateSavedSearchSubscription(t *testing.T) { ) now := time.Now() updatedTriggers := []backend.SubscriptionTriggerWritable{ - backend.SubscriptionTriggerFeatureBaselineLimitedToNewly, - backend.SubscriptionTriggerFeatureBaselineRegressionNewlyToLimited, + backend.SubscriptionTriggerFeatureBaselineToNewly, + backend.SubscriptionTriggerFeatureBaselineRegressionToLimited, } - updatedFrequency := backend.SubscriptionFrequencyDaily + updatedFrequency := backend.SubscriptionFrequencyImmediate + updatedSpannerFrequency := gcpspanner.SavedSearchSnapshotTypeImmediate testCases := []struct { name string @@ -3989,13 +3991,13 @@ func TestUpdateSavedSearchSubscription(t *testing.T) { expectedRequest: gcpspanner.UpdateSavedSearchSubscriptionRequest{ ID: subID, UserID: userID, - Triggers: gcpspanner.OptionallySet[[]string]{ - Value: []string{ - "feature_baseline_limited_to_newly", - "feature_baseline_regression_newly_to_limited", + Triggers: gcpspanner.OptionallySet[[]gcpspanner.SubscriptionTrigger]{ + Value: []gcpspanner.SubscriptionTrigger{ + gcpspanner.SubscriptionTriggerFeatureBaselinePromoteToNewly, + gcpspanner.SubscriptionTriggerFeatureBaselineRegressionToLimited, }, IsSet: true, }, - Frequency: gcpspanner.OptionallySet[string]{IsSet: false, Value: ""}, + Frequency: gcpspanner.OptionallySet[gcpspanner.SavedSearchSnapshotType]{IsSet: false, Value: ""}, }, returnedError: nil, }, @@ -4003,11 +4005,12 @@ func TestUpdateSavedSearchSubscription(t *testing.T) { expectedSubscriptionID: subID, expectedUserID: userID, result: &gcpspanner.SavedSearchSubscription{ - ID: subID, - Triggers: []string{"feature_baseline_limited_to_newly"}, + ID: subID, + Triggers: []gcpspanner.SubscriptionTrigger{ + gcpspanner.SubscriptionTriggerFeatureBaselinePromoteToNewly}, ChannelID: "channel", SavedSearchID: "savedsearch", - Frequency: "daily", + Frequency: gcpspanner.SavedSearchSnapshotTypeImmediate, CreatedAt: now, UpdatedAt: now, }, @@ -4018,13 +4021,13 @@ func TestUpdateSavedSearchSubscription(t *testing.T) { Triggers: []backend.SubscriptionTriggerResponseItem{ { Value: backendtypes.AttemptToStoreSubscriptionTrigger( - backend.SubscriptionTriggerFeatureBaselineLimitedToNewly), + backend.SubscriptionTriggerFeatureBaselineToNewly), RawValue: nil, }, }, ChannelId: "channel", SavedSearchId: "savedsearch", - Frequency: "daily", + Frequency: backend.SubscriptionFrequencyImmediate, CreatedAt: now, UpdatedAt: now, }, @@ -4042,9 +4045,9 @@ func TestUpdateSavedSearchSubscription(t *testing.T) { expectedRequest: gcpspanner.UpdateSavedSearchSubscriptionRequest{ ID: subID, UserID: userID, - Triggers: gcpspanner.OptionallySet[[]string]{IsSet: false, Value: nil}, - Frequency: gcpspanner.OptionallySet[string]{ - Value: "daily", IsSet: true, + Triggers: gcpspanner.OptionallySet[[]gcpspanner.SubscriptionTrigger]{IsSet: false, Value: nil}, + Frequency: gcpspanner.OptionallySet[gcpspanner.SavedSearchSnapshotType]{ + Value: gcpspanner.SavedSearchSnapshotTypeImmediate, IsSet: true, }, }, returnedError: nil, @@ -4056,10 +4059,11 @@ func TestUpdateSavedSearchSubscription(t *testing.T) { ID: subID, ChannelID: "channel", SavedSearchID: "savedsearchid", - Triggers: []string{"feature_any_browser_implementation_complete"}, - Frequency: string(updatedFrequency), - CreatedAt: now, - UpdatedAt: now, + Triggers: []gcpspanner.SubscriptionTrigger{ + gcpspanner.SubscriptionTriggerBrowserImplementationAnyComplete}, + Frequency: updatedSpannerFrequency, + CreatedAt: now, + UpdatedAt: now, }, returnedError: nil, }, @@ -4070,7 +4074,7 @@ func TestUpdateSavedSearchSubscription(t *testing.T) { Triggers: []backend.SubscriptionTriggerResponseItem{ { Value: backendtypes.AttemptToStoreSubscriptionTrigger( - backend.SubscriptionTriggerFeatureAnyBrowserImplementationComplete), + backend.SubscriptionTriggerFeatureBrowserImplementationAnyComplete), RawValue: nil, }, }, @@ -4092,13 +4096,13 @@ func TestUpdateSavedSearchSubscription(t *testing.T) { expectedRequest: gcpspanner.UpdateSavedSearchSubscriptionRequest{ ID: subID, UserID: userID, - Triggers: gcpspanner.OptionallySet[[]string]{ - Value: []string{ - "feature_baseline_limited_to_newly", - "feature_baseline_regression_newly_to_limited", + Triggers: gcpspanner.OptionallySet[[]gcpspanner.SubscriptionTrigger]{ + Value: []gcpspanner.SubscriptionTrigger{ + gcpspanner.SubscriptionTriggerFeatureBaselinePromoteToNewly, + gcpspanner.SubscriptionTriggerFeatureBaselineRegressionToLimited, }, IsSet: true, }, - Frequency: gcpspanner.OptionallySet[string]{ + Frequency: gcpspanner.OptionallySet[gcpspanner.SavedSearchSnapshotType]{ Value: "", IsSet: false, }, @@ -4223,7 +4227,7 @@ func assertUnknownTrigger(t *testing.T, itemIndex int, func TestSpannerTriggersToBackendTriggers(t *testing.T) { testCases := []struct { name string - inputTriggers []string + inputTriggers []gcpspanner.SubscriptionTrigger expectedItems []struct { IsUnknown bool Value string @@ -4232,9 +4236,9 @@ func TestSpannerTriggersToBackendTriggers(t *testing.T) { }{ { name: "All Valid Triggers", - inputTriggers: []string{ - string(backend.SubscriptionTriggerFeatureAnyBrowserImplementationComplete), - string(backend.SubscriptionTriggerFeatureBaselineLimitedToNewly), + inputTriggers: []gcpspanner.SubscriptionTrigger{ + gcpspanner.SubscriptionTriggerBrowserImplementationAnyComplete, + gcpspanner.SubscriptionTriggerFeatureBaselinePromoteToNewly, }, expectedItems: []struct { IsUnknown bool @@ -4242,19 +4246,19 @@ func TestSpannerTriggersToBackendTriggers(t *testing.T) { RawValue *string }{ {IsUnknown: false, - Value: string(backend.SubscriptionTriggerFeatureAnyBrowserImplementationComplete), + Value: string(backend.SubscriptionTriggerFeatureBrowserImplementationAnyComplete), RawValue: nil}, {IsUnknown: false, - Value: string(backend.SubscriptionTriggerFeatureBaselineLimitedToNewly), + Value: string(backend.SubscriptionTriggerFeatureBaselineToNewly), RawValue: nil}, }, }, { name: "Mixed Valid and Unknown Triggers", - inputTriggers: []string{ - string(backend.SubscriptionTriggerFeatureAnyBrowserImplementationComplete), + inputTriggers: []gcpspanner.SubscriptionTrigger{ + gcpspanner.SubscriptionTriggerBrowserImplementationAnyComplete, "deprecated_trigger", - string(backend.SubscriptionTriggerFeatureBaselineRegressionNewlyToLimited), + gcpspanner.SubscriptionTriggerFeatureBaselineRegressionToLimited, "another_unknown", }, expectedItems: []struct { @@ -4263,13 +4267,13 @@ func TestSpannerTriggersToBackendTriggers(t *testing.T) { RawValue *string }{ {IsUnknown: false, - Value: string(backend.SubscriptionTriggerFeatureAnyBrowserImplementationComplete), + Value: string(backend.SubscriptionTriggerFeatureBrowserImplementationAnyComplete), RawValue: nil}, {IsUnknown: true, Value: string(backend.EnumUnknownValue), RawValue: valuePtr("deprecated_trigger")}, {IsUnknown: false, - Value: string(backend.SubscriptionTriggerFeatureBaselineRegressionNewlyToLimited), + Value: string(backend.SubscriptionTriggerFeatureBaselineRegressionToLimited), RawValue: nil}, {IsUnknown: true, Value: string(backend.EnumUnknownValue), @@ -4278,7 +4282,7 @@ func TestSpannerTriggersToBackendTriggers(t *testing.T) { }, { name: "All Unknown Triggers", - inputTriggers: []string{"unknown1", "unknown2"}, + inputTriggers: []gcpspanner.SubscriptionTrigger{"unknown1", "unknown2"}, expectedItems: []struct { IsUnknown bool Value string @@ -4290,7 +4294,7 @@ func TestSpannerTriggersToBackendTriggers(t *testing.T) { }, { name: "Empty Triggers", - inputTriggers: []string{}, + inputTriggers: []gcpspanner.SubscriptionTrigger{}, expectedItems: []struct { IsUnknown bool Value string diff --git a/lib/gcpspanner/spanneradapters/event_producer.go b/lib/gcpspanner/spanneradapters/event_producer.go index 03cea2cb6..ff801c483 100644 --- a/lib/gcpspanner/spanneradapters/event_producer.go +++ b/lib/gcpspanner/spanneradapters/event_producer.go @@ -134,16 +134,17 @@ func NewEventProducer(client EventProducerSpannerClient) *EventProducer { func convertFrequencyToSnapshotType(freq workertypes.JobFrequency) gcpspanner.SavedSearchSnapshotType { switch freq { - // Eventually daily and unknown will be their own types. - case workertypes.FrequencyImmediate, workertypes.FrequencyDaily, workertypes.FrequencyUnknown: + case workertypes.FrequencyImmediate: return gcpspanner.SavedSearchSnapshotTypeImmediate case workertypes.FrequencyWeekly: return gcpspanner.SavedSearchSnapshotTypeWeekly case workertypes.FrequencyMonthly: return gcpspanner.SavedSearchSnapshotTypeMonthly + case workertypes.FrequencyUnknown: + return gcpspanner.SavedSearchSnapshotTypeUnknown } - return gcpspanner.SavedSearchSnapshotTypeImmediate + return gcpspanner.SavedSearchSnapshotTypeUnknown } func convertWorktypeReasonsToSpanner(reasons []workertypes.Reason) []string { diff --git a/lib/gcpspanner/spanneradapters/event_producer_test.go b/lib/gcpspanner/spanneradapters/event_producer_test.go index 3de8fdbfb..983a2b10b 100644 --- a/lib/gcpspanner/spanneradapters/event_producer_test.go +++ b/lib/gcpspanner/spanneradapters/event_producer_test.go @@ -200,8 +200,8 @@ func TestEventProducer_ReleaseLock(t *testing.T) { wantErr bool }{ { - name: "Daily maps to Immediate (as per implementation)", - freq: workertypes.FrequencyDaily, + name: "Regular release", + freq: workertypes.FrequencyImmediate, wantSnapshotType: gcpspanner.SavedSearchSnapshotTypeImmediate, mockErr: nil, wantErr: false, @@ -432,7 +432,7 @@ func TestEventProducer_GetLatestEvent(t *testing.T) { }, { name: "Spanner error", - freq: workertypes.FrequencyDaily, + freq: workertypes.FrequencyImmediate, wantSnapshotType: gcpspanner.SavedSearchSnapshotTypeImmediate, mockResp: nil, mockErr: errors.New("db error"), diff --git a/lib/workertypes/types.go b/lib/workertypes/types.go index 7bd6470de..e6342fb2e 100644 --- a/lib/workertypes/types.go +++ b/lib/workertypes/types.go @@ -572,7 +572,6 @@ type JobFrequency string const ( FrequencyUnknown JobFrequency = "UNKNOWN" FrequencyImmediate JobFrequency = "IMMEDIATE" - FrequencyDaily JobFrequency = "DAILY" FrequencyWeekly JobFrequency = "WEEKLY" FrequencyMonthly JobFrequency = "MONTHLY" ) diff --git a/openapi/backend/openapi.yaml b/openapi/backend/openapi.yaml index 2cf6b4f40..efacfe7ca 100644 --- a/openapi/backend/openapi.yaml +++ b/openapi/backend/openapi.yaml @@ -1813,22 +1813,28 @@ components: - EnumUnknownValue SubscriptionFrequency: type: string - description: The frequency for a subscription. Currently, only 'daily' is supported. + description: The frequency for a subscription. enum: - - daily + - immediate + - weekly + - monthly x-enumNames: - - SubscriptionFrequencyDaily + - SubscriptionFrequencyImmediate + - SubscriptionFrequencyWeekly + - SubscriptionFrequencyMonthly SubscriptionTriggerWritable: type: string description: The set of valid, user-selectable triggers for a subscription. enum: - - feature_baseline_limited_to_newly - - feature_any_browser_implementation_complete - - feature_baseline_regression_newly_to_limited + - feature_baseline_to_newly + - feature_baseline_to_widely + - feature_browser_implementation_any_complete + - feature_baseline_regression_to_limited x-enumNames: - - SubscriptionTriggerFeatureBaselineLimitedToNewly - - SubscriptionTriggerFeatureAnyBrowserImplementationComplete - - SubscriptionTriggerFeatureBaselineRegressionNewlyToLimited + - SubscriptionTriggerFeatureBaselineToNewly + - SubscriptionTriggerFeatureBaselineToWidely + - SubscriptionTriggerFeatureBrowserImplementationAnyComplete + - SubscriptionTriggerFeatureBaselineRegressionToLimited SubscriptionTriggerResponseValue: description: > Represents a subscription trigger value. Includes 'unknown' for handling deprecated triggers. diff --git a/workers/event_producer/pkg/producer/batch_handler_test.go b/workers/event_producer/pkg/producer/batch_handler_test.go index 7e89fe90c..d0955d9dc 100644 --- a/workers/event_producer/pkg/producer/batch_handler_test.go +++ b/workers/event_producer/pkg/producer/batch_handler_test.go @@ -100,7 +100,7 @@ func TestProcessBatchUpdate(t *testing.T) { pub := &mockCommandPublisher{err: tc.pubErr, commands: nil} handler := NewBatchUpdateHandler(lister, pub) - err := handler.ProcessBatchUpdate(context.Background(), "trigger-1", workertypes.FrequencyDaily) + err := handler.ProcessBatchUpdate(context.Background(), "trigger-1", workertypes.FrequencyImmediate) if (err != nil) != tc.wantErr { t.Errorf("ProcessBatchUpdate() error = %v, wantErr %v", err, tc.wantErr) @@ -121,7 +121,7 @@ func TestProcessBatchUpdate(t *testing.T) { if pub.commands[0].SearchID != "s1" { t.Errorf("Command data mismatch") } - if pub.commands[0].Frequency != workertypes.FrequencyDaily { + if pub.commands[0].Frequency != workertypes.FrequencyImmediate { t.Errorf("Frequency mismatch") } } From d9dcefb8369bcb219a0ac978438fd447a35a2dbb Mon Sep 17 00:00:00 2001 From: James Scott Date: Mon, 29 Dec 2025 00:18:07 +0000 Subject: [PATCH 10/27] feat(push_delivery): implement spanner adapter and strongly-typed subscribers Introduces the `PushDeliverySubscriberFinder` adapter in `spanneradapters`. This component is responsible for retrieving subscribers from Spanner and mapping them into the domain-layer `SubscriberSet`. Key changes: - **Spanner Adapter (`spanneradapters/push_delivery.go`)**: Implemented `FindSubscribers` which calls `FindAllActivePushSubscriptions` and maps the results. - **Spanner Client (`gcpspanner`)**: - Updated `SubscriberDestination` to hold a strongly-typed `EmailConfig` instead of raw `spanner.NullJSON`. - Moved JSON unmarshalling logic into the Spanner client layer (`toPublic` and `loadSubscriptionConfigs`), ensuring raw bytes don't leak into the adapter. - Added `Triggers` (typed as `[]SubscriptionTrigger`) to the destination struct. --- lib/gcpspanner/notification_channel.go | 37 ++- lib/gcpspanner/saved_search_subscription.go | 48 +++- .../saved_search_subscription_test.go | 36 ++- .../spanneradapters/push_delivery.go | 99 +++++++ .../spanneradapters/push_delivery_test.go | 242 ++++++++++++++++++ lib/workertypes/types.go | 11 +- .../pkg/dispatcher/dispatcher.go | 2 +- .../pkg/dispatcher/dispatcher_test.go | 12 +- 8 files changed, 461 insertions(+), 26 deletions(-) create mode 100644 lib/gcpspanner/spanneradapters/push_delivery.go create mode 100644 lib/gcpspanner/spanneradapters/push_delivery_test.go diff --git a/lib/gcpspanner/notification_channel.go b/lib/gcpspanner/notification_channel.go index 7f9c9d3eb..22f6425f7 100644 --- a/lib/gcpspanner/notification_channel.go +++ b/lib/gcpspanner/notification_channel.go @@ -144,17 +144,34 @@ func (c *NotificationChannel) toSpanner() *spannerNotificationChannel { // toPublic converts the internal spannerNotificationChannel to the public NotificationChannel for reading. func (sc *spannerNotificationChannel) toPublic() (*NotificationChannel, error) { ret := &sc.NotificationChannel - if sc.Config.Valid { - bytes, err := json.Marshal(sc.Config.Value) - if err != nil { - return nil, err - } - var emailConfig EmailConfig - if err := json.Unmarshal(bytes, &emailConfig); err != nil { - return nil, err - } - ret.EmailConfig = &emailConfig + subscriptionConfigs, err := loadSubscriptionConfigs(sc.Type, sc.Config) + if err != nil { + return nil, err + } + ret.EmailConfig = subscriptionConfigs.EmailConfig + + return ret, nil +} + +type subscriptionConfigs struct { + EmailConfig *EmailConfig +} + +func loadSubscriptionConfigs(_ string, config spanner.NullJSON) (subscriptionConfigs, error) { + var ret subscriptionConfigs + if !config.Valid { + return ret, nil + } + // For now, only email config is supported. + bytes, err := json.Marshal(config.Value) + if err != nil { + return ret, err + } + var emailConfig EmailConfig + if err := json.Unmarshal(bytes, &emailConfig); err != nil { + return ret, err } + ret.EmailConfig = &emailConfig return ret, nil } diff --git a/lib/gcpspanner/saved_search_subscription.go b/lib/gcpspanner/saved_search_subscription.go index 13ab5c2b5..4dcd93eff 100644 --- a/lib/gcpspanner/saved_search_subscription.go +++ b/lib/gcpspanner/saved_search_subscription.go @@ -315,12 +315,23 @@ func (c *Client) ListSavedSearchSubscriptions( return newEntityLister[savedSearchSubscriptionMapper](c).list(ctx, req) } +type spannerSubscriberDestination struct { + SubscriptionID string `spanner:"ID"` + UserID string `spanner:"UserID"` + ChannelID string `spanner:"ChannelID"` + Type string `spanner:"Type"` + Triggers []SubscriptionTrigger `spanner:"Triggers"` + Config spanner.NullJSON `spanner:"Config"` +} + type SubscriberDestination struct { - SubscriptionID string `spanner:"ID"` - UserID string `spanner:"UserID"` - ChannelID string `spanner:"ChannelID"` - Type string `spanner:"Type"` - Config spanner.NullJSON `spanner:"Config"` + SubscriptionID string + UserID string + ChannelID string + Type string + Triggers []SubscriptionTrigger + // If type is EMAIL, EmailConfig is set. + EmailConfig *EmailConfig } type readAllActivePushSubscriptionsMapper struct { @@ -341,6 +352,7 @@ func (m readAllActivePushSubscriptionsMapper) SelectAllByKeys(key activePushSubs sc.ID, nc.UserID, sc.ChannelID, + sc.Triggers, nc.Type, nc.Config FROM SavedSearchSubscriptions sc @@ -367,14 +379,36 @@ func (c *Client) FindAllActivePushSubscriptions( savedSearchID string, frequency SavedSearchSnapshotType, ) ([]SubscriberDestination, error) { - return newAllByKeysEntityReader[ + values, err := newAllByKeysEntityReader[ readAllActivePushSubscriptionsMapper, activePushSubscriptionKey, - SubscriberDestination](c).readAllByKeys( + spannerSubscriberDestination](c).readAllByKeys( ctx, activePushSubscriptionKey{ SavedSearchID: savedSearchID, Frequency: frequency, }, ) + if err != nil { + return nil, err + } + results := make([]SubscriberDestination, 0, len(values)) + for _, v := range values { + dest := SubscriberDestination{ + SubscriptionID: v.SubscriptionID, + UserID: v.UserID, + ChannelID: v.ChannelID, + Type: v.Type, + Triggers: v.Triggers, + EmailConfig: nil, + } + subscriptionConfigs, err := loadSubscriptionConfigs(v.Type, v.Config) + if err != nil { + return nil, err + } + dest.EmailConfig = subscriptionConfigs.EmailConfig + results = append(results, dest) + } + + return results, nil } diff --git a/lib/gcpspanner/saved_search_subscription_test.go b/lib/gcpspanner/saved_search_subscription_test.go index 6d687f7ee..9fcd8ca8b 100644 --- a/lib/gcpspanner/saved_search_subscription_test.go +++ b/lib/gcpspanner/saved_search_subscription_test.go @@ -16,6 +16,7 @@ package gcpspanner import ( "context" + "slices" "testing" "time" @@ -416,7 +417,12 @@ func TestFindAllActivePushSubscriptions(t *testing.T) { // Subscription 1: Correct, on active EMAIL channel _, err = spannerClient.CreateSavedSearchSubscription(ctx, CreateSavedSearchSubscriptionRequest{ UserID: userID, ChannelID: emailChannelID, SavedSearchID: savedSearchID, - Frequency: SavedSearchSnapshotTypeImmediate, Triggers: nil, + Frequency: SavedSearchSnapshotTypeImmediate, Triggers: []SubscriptionTrigger{ + SubscriptionTriggerFeatureBaselineRegressionToLimited, + SubscriptionTriggerBrowserImplementationAnyComplete, + SubscriptionTriggerFeatureBaselinePromoteToNewly, + SubscriptionTriggerFeatureBaselinePromoteToWidely, + }, }) if err != nil { t.Fatalf("failed to create sub 1: %v", err) @@ -477,6 +483,34 @@ func TestFindAllActivePushSubscriptions(t *testing.T) { // foundWebhook := false for _, sub := range subscribers { if sub.ChannelID == emailChannelID { + // Do the comparison for the email subscriber details + if sub.Type != "EMAIL" { + t.Errorf("expected EMAIL type for email channel, got %s", sub.Type) + + continue + } + if sub.EmailConfig == nil { + t.Error("expected EmailConfig to be set for email subscriber, got nil") + + continue + } + if sub.EmailConfig.Address != "active@example.com" { + t.Errorf("expected address to be active@example.com, got %s", sub.EmailConfig.Address) + + continue + } + expectedTriggers := []SubscriptionTrigger{ + SubscriptionTriggerFeatureBaselineRegressionToLimited, + SubscriptionTriggerBrowserImplementationAnyComplete, + SubscriptionTriggerFeatureBaselinePromoteToNewly, + SubscriptionTriggerFeatureBaselinePromoteToWidely, + } + if !slices.Equal(expectedTriggers, sub.Triggers) { + t.Errorf("expected triggers %v, got %v", expectedTriggers, sub.Triggers) + + continue + } + foundEmail = true } // if sub.ChannelID == webhookChannelID { diff --git a/lib/gcpspanner/spanneradapters/push_delivery.go b/lib/gcpspanner/spanneradapters/push_delivery.go new file mode 100644 index 000000000..b10b4c498 --- /dev/null +++ b/lib/gcpspanner/spanneradapters/push_delivery.go @@ -0,0 +1,99 @@ +// 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 spanneradapters + +import ( + "context" + "log/slog" + + "github.com/GoogleChrome/webstatus.dev/lib/gcpspanner" + "github.com/GoogleChrome/webstatus.dev/lib/workertypes" +) + +type PushDeliverySpannerClient interface { + FindAllActivePushSubscriptions( + ctx context.Context, + savedSearchID string, + frequency gcpspanner.SavedSearchSnapshotType, + ) ([]gcpspanner.SubscriberDestination, error) +} + +type PushDeliverySubscriberFinder struct { + client PushDeliverySpannerClient +} + +func NewPushDeliverySubscriberFinder(client PushDeliverySpannerClient) *PushDeliverySubscriberFinder { + return &PushDeliverySubscriberFinder{client: client} +} + +func (f *PushDeliverySubscriberFinder) FindSubscribers(ctx context.Context, searchID string, + frequency workertypes.JobFrequency) (*workertypes.SubscriberSet, error) { + spannerFrequency := convertFrequencyToSnapshotType(frequency) + + dests, err := f.client.FindAllActivePushSubscriptions(ctx, searchID, spannerFrequency) + if err != nil { + return nil, err + } + + set := &workertypes.SubscriberSet{ + Emails: make([]workertypes.EmailSubscriber, 0), + } + + for _, dest := range dests { + // If EmailConfig is set, it's an email subscriber. + if dest.EmailConfig != nil { + set.Emails = append(set.Emails, workertypes.EmailSubscriber{ + SubscriptionID: dest.SubscriptionID, + UserID: dest.UserID, + Triggers: convertSpannerTriggersToJobTriggers(dest.Triggers), + EmailAddress: dest.EmailConfig.Address, + }) + } + } + + return set, nil +} + +func convertSpannerTriggersToJobTriggers(triggers []gcpspanner.SubscriptionTrigger) []workertypes.JobTrigger { + if triggers == nil { + return nil + } + jobTriggers := make([]workertypes.JobTrigger, 0, len(triggers)) + for _, t := range triggers { + jobTriggers = append(jobTriggers, convertSpannerTriggerToJobTrigger(t)) + } + + return jobTriggers +} + +func convertSpannerTriggerToJobTrigger(trigger gcpspanner.SubscriptionTrigger) workertypes.JobTrigger { + switch trigger { + case gcpspanner.SubscriptionTriggerFeatureBaselinePromoteToNewly: + return workertypes.FeaturePromotedToNewly + case gcpspanner.SubscriptionTriggerFeatureBaselinePromoteToWidely: + return workertypes.FeaturePromotedToWidely + case gcpspanner.SubscriptionTriggerFeatureBaselineRegressionToLimited: + return workertypes.FeatureRegressedToLimited + case gcpspanner.SubscriptionTriggerBrowserImplementationAnyComplete: + return workertypes.BrowserImplementationAnyComplete + case gcpspanner.SubscriptionTriggerUnknown: + break + } + // Should not reach here. + slog.WarnContext(context.TODO(), "unknown subscription trigger encountered in push deliveryspanner adapter", + "trigger", trigger) + + return "" +} diff --git a/lib/gcpspanner/spanneradapters/push_delivery_test.go b/lib/gcpspanner/spanneradapters/push_delivery_test.go new file mode 100644 index 000000000..9b73e13c6 --- /dev/null +++ b/lib/gcpspanner/spanneradapters/push_delivery_test.go @@ -0,0 +1,242 @@ +// 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 spanneradapters + +import ( + "context" + "errors" + "testing" + + "github.com/GoogleChrome/webstatus.dev/lib/gcpspanner" + "github.com/GoogleChrome/webstatus.dev/lib/workertypes" + "github.com/google/go-cmp/cmp" +) + +type mockPushDeliverySpannerClient struct { + findAllCalledWith *findAllCalledWith + findAllReturns findAllReturns +} + +type findAllCalledWith struct { + SearchID string + Frequency gcpspanner.SavedSearchSnapshotType +} + +type findAllReturns struct { + dests []gcpspanner.SubscriberDestination + err error +} + +func (m *mockPushDeliverySpannerClient) FindAllActivePushSubscriptions( + _ context.Context, + savedSearchID string, + frequency gcpspanner.SavedSearchSnapshotType, +) ([]gcpspanner.SubscriberDestination, error) { + m.findAllCalledWith = &findAllCalledWith{ + SearchID: savedSearchID, + Frequency: frequency, + } + + return m.findAllReturns.dests, m.findAllReturns.err +} + +func TestFindSubscribers(t *testing.T) { + tests := []struct { + name string + dests []gcpspanner.SubscriberDestination + clientErr error + expectedCall *findAllCalledWith + expectedSet *workertypes.SubscriberSet + expectedError error + }{ + { + name: "Success with Email and Triggers", + expectedCall: &findAllCalledWith{ + SearchID: "search-1", + Frequency: gcpspanner.SavedSearchSnapshotTypeImmediate, + }, + dests: []gcpspanner.SubscriberDestination{ + { + SubscriptionID: "sub-1", + UserID: "user-1", + Type: "EMAIL", + ChannelID: "chan-1", + EmailConfig: &gcpspanner.EmailConfig{ + Address: "test@example.com", + IsVerified: true, + VerificationToken: nil, + }, + Triggers: []gcpspanner.SubscriptionTrigger{ + gcpspanner.SubscriptionTriggerFeatureBaselinePromoteToNewly, + gcpspanner.SubscriptionTriggerFeatureBaselinePromoteToWidely, + }, + }, + }, + expectedSet: &workertypes.SubscriberSet{ + Emails: []workertypes.EmailSubscriber{ + { + SubscriptionID: "sub-1", + UserID: "user-1", + EmailAddress: "test@example.com", + Triggers: []workertypes.JobTrigger{ + workertypes.FeaturePromotedToNewly, + workertypes.FeaturePromotedToWidely, + }, + }, + }, + }, + clientErr: nil, + expectedError: nil, + }, + { + name: "Mixed types (Webhook ignored)", + expectedCall: &findAllCalledWith{ + SearchID: "search-1", + Frequency: gcpspanner.SavedSearchSnapshotTypeImmediate, + }, + clientErr: nil, + dests: []gcpspanner.SubscriberDestination{ + { + UserID: "user-1", + SubscriptionID: "sub-1", + Type: "EMAIL", + ChannelID: "chan-1", + EmailConfig: &gcpspanner.EmailConfig{ + Address: "test@example.com", + IsVerified: true, + VerificationToken: nil, + }, + Triggers: []gcpspanner.SubscriptionTrigger{ + gcpspanner.SubscriptionTriggerBrowserImplementationAnyComplete, + }, + }, + { + SubscriptionID: "sub-2", + Type: "WEBHOOK", + ChannelID: "chan-2", + EmailConfig: nil, // Webhooks don't have EmailConfig + Triggers: nil, + UserID: "user-3", + }, + }, + expectedSet: &workertypes.SubscriberSet{ + Emails: []workertypes.EmailSubscriber{ + { + SubscriptionID: "sub-1", + UserID: "user-1", + EmailAddress: "test@example.com", + Triggers: []workertypes.JobTrigger{ + workertypes.BrowserImplementationAnyComplete, + }, + }, + }, + }, + expectedError: nil, + }, + { + name: "Client Error", + expectedCall: &findAllCalledWith{ + SearchID: "search-1", + Frequency: gcpspanner.SavedSearchSnapshotTypeImmediate, + }, + expectedSet: nil, + dests: nil, + clientErr: errTest, + expectedError: errTest, + }, + { + name: "Nil Email Config (Should Skip)", + expectedCall: &findAllCalledWith{ + SearchID: "search-1", + Frequency: gcpspanner.SavedSearchSnapshotTypeImmediate, + }, + dests: []gcpspanner.SubscriberDestination{ + { + UserID: "user-1", + SubscriptionID: "sub-1", + Type: "EMAIL", + ChannelID: "chan-1", + Triggers: nil, + EmailConfig: nil, // Missing config should be skipped + }, + }, + clientErr: nil, + expectedSet: &workertypes.SubscriberSet{ + Emails: []workertypes.EmailSubscriber{}, + }, + expectedError: nil, + }, + { + name: "Unknown Trigger (Should be logged/ignored/empty string)", + expectedCall: &findAllCalledWith{ + SearchID: "search-1", + Frequency: gcpspanner.SavedSearchSnapshotTypeImmediate, + }, + dests: []gcpspanner.SubscriberDestination{ + { + UserID: "user-1", + SubscriptionID: "sub-1", + Type: "EMAIL", + ChannelID: "chan-1", + EmailConfig: &gcpspanner.EmailConfig{ + Address: "test@example.com", + IsVerified: true, + VerificationToken: nil, + }, + Triggers: []gcpspanner.SubscriptionTrigger{ + "some_unknown_trigger", + }, + }, + }, + clientErr: nil, + expectedSet: &workertypes.SubscriberSet{ + Emails: []workertypes.EmailSubscriber{ + { + UserID: "user-1", + SubscriptionID: "sub-1", + EmailAddress: "test@example.com", + Triggers: []workertypes.JobTrigger{ + "", // Unknown triggers map to empty string/zero value in current implementation + }, + }, + }, + }, + expectedError: nil, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + mock := new(mockPushDeliverySpannerClient) + mock.findAllReturns.dests = tc.dests + mock.findAllReturns.err = tc.clientErr + + finder := NewPushDeliverySubscriberFinder(mock) + set, err := finder.FindSubscribers(context.Background(), "search-1", workertypes.FrequencyImmediate) + + if !errors.Is(err, tc.expectedError) { + t.Errorf("FindSubscribers error = %v, wantErr %v", err, tc.expectedError) + } + + if diff := cmp.Diff(tc.expectedSet, set); diff != "" { + t.Errorf("SubscriberSet mismatch (-want +got):\n%s", diff) + } + + if diff := cmp.Diff(tc.expectedCall, mock.findAllCalledWith); diff != "" { + t.Errorf("findAllCalledWith mismatch (-want +got):\n%s", diff) + } + }) + } +} diff --git a/lib/workertypes/types.go b/lib/workertypes/types.go index e6342fb2e..1f8c8e03e 100644 --- a/lib/workertypes/types.go +++ b/lib/workertypes/types.go @@ -588,11 +588,20 @@ type SearchJob struct { Query string } +type JobTrigger string + +const ( + FeaturePromotedToNewly JobTrigger = "FEATURE_PROMOTED_TO_NEWLY" + FeaturePromotedToWidely JobTrigger = "FEATURE_PROMOTED_TO_WIDELY" + FeatureRegressedToLimited JobTrigger = "FEATURE_REGRESSED_TO_LIMITED" + BrowserImplementationAnyComplete JobTrigger = "BROWSER_IMPLEMENTATION_ANY_COMPLETE" +) + // EmailSubscriber represents a subscriber using an Email channel. type EmailSubscriber struct { SubscriptionID string UserID string - Triggers []string + Triggers []JobTrigger EmailAddress string } diff --git a/workers/push_delivery/pkg/dispatcher/dispatcher.go b/workers/push_delivery/pkg/dispatcher/dispatcher.go index 6c81e1449..e3f43682b 100644 --- a/workers/push_delivery/pkg/dispatcher/dispatcher.go +++ b/workers/push_delivery/pkg/dispatcher/dispatcher.go @@ -170,7 +170,7 @@ func (g *deliveryJobGenerator) JobCount() int { } // shouldNotifyV1 determines if the V1 event summary matches any of the user's triggers. -func shouldNotifyV1(triggers []string, summary workertypes.EventSummary) bool { +func shouldNotifyV1(triggers []workertypes.JobTrigger, summary workertypes.EventSummary) bool { hasChanges := summary.Categories.Added > 0 || summary.Categories.Removed > 0 || summary.Categories.Updated > 0 || diff --git a/workers/push_delivery/pkg/dispatcher/dispatcher_test.go b/workers/push_delivery/pkg/dispatcher/dispatcher_test.go index 738530837..839f43e03 100644 --- a/workers/push_delivery/pkg/dispatcher/dispatcher_test.go +++ b/workers/push_delivery/pkg/dispatcher/dispatcher_test.go @@ -135,13 +135,13 @@ func TestProcessEvent_Success(t *testing.T) { { SubscriptionID: "sub-1", UserID: "user-1", - Triggers: []string{"any_change"}, // Matches logic in shouldNotifyV1 + Triggers: []workertypes.JobTrigger{"any_change"}, // Matches logic in shouldNotifyV1 EmailAddress: "user1@example.com", }, { SubscriptionID: "sub-2", UserID: "user-2", - Triggers: []string{}, // Empty triggers = no notify + Triggers: []workertypes.JobTrigger{}, // Empty triggers = no notify EmailAddress: "user2@example.com", }, }, @@ -220,7 +220,7 @@ func TestProcessEvent_NoChanges_FiltersAll(t *testing.T) { { SubscriptionID: "sub-1", UserID: "user-1", - Triggers: []string{"any_change"}, + Triggers: []workertypes.JobTrigger{"any_change"}, EmailAddress: "user1@example.com", }, }, @@ -303,8 +303,8 @@ func TestProcessEvent_PublisherPartialFailure(t *testing.T) { // Two subscribers subSet := &workertypes.SubscriberSet{ Emails: []workertypes.EmailSubscriber{ - {SubscriptionID: "sub-1", Triggers: []string{"change"}, UserID: "u1", EmailAddress: "e1"}, - {SubscriptionID: "sub-2", Triggers: []string{"change"}, UserID: "u2", EmailAddress: "e2"}, + {SubscriptionID: "sub-1", Triggers: []workertypes.JobTrigger{"change"}, UserID: "u1", EmailAddress: "e1"}, + {SubscriptionID: "sub-2", Triggers: []workertypes.JobTrigger{"change"}, UserID: "u2", EmailAddress: "e2"}, }, } @@ -355,7 +355,7 @@ func TestProcessEvent_JobCount(t *testing.T) { // Verify that if no jobs are generated (e.g. no matching triggers), ProcessEvent returns early/cleanly. subSet := &workertypes.SubscriberSet{ Emails: []workertypes.EmailSubscriber{ - {SubscriptionID: "sub-1", Triggers: []string{}, EmailAddress: "e1", UserID: "u1"}, // No match + {SubscriptionID: "sub-1", Triggers: []workertypes.JobTrigger{}, EmailAddress: "e1", UserID: "u1"}, // No match }, } finder := &mockSubscriptionFinder{ From 65c450596f9af35c2c27527a5c3c2b79f497ec40 Mon Sep 17 00:00:00 2001 From: James Scott Date: Mon, 29 Dec 2025 03:23:43 +0000 Subject: [PATCH 11/27] feat: Add Pub/Sub adapters for push delivery worker This commit introduces `PushDeliveryPublisher` and `PushDeliverySubscriberAdapter` within the `lib/gcppubsub/gcppubsubadapters` package. - `PushDeliveryPublisher` provides functionality to publish email delivery jobs to a Pub/Sub topic. - `PushDeliverySubscriberAdapter` is responsible for subscribing to a Pub/Sub topic, routing incoming messages, and processing `FeatureDiffEvent`s by dispatching them to a `PushDeliveryMessageHandler`. --- lib/event/emailjob/v1/types.go | 73 +++++ .../gcppubsubadapters/push_delivery.go | 115 ++++++++ .../gcppubsubadapters/push_delivery_test.go | 254 ++++++++++++++++++ 3 files changed, 442 insertions(+) create mode 100644 lib/event/emailjob/v1/types.go create mode 100644 lib/gcppubsub/gcppubsubadapters/push_delivery.go create mode 100644 lib/gcppubsub/gcppubsubadapters/push_delivery_test.go diff --git a/lib/event/emailjob/v1/types.go b/lib/event/emailjob/v1/types.go new file mode 100644 index 000000000..3fd1dd2b8 --- /dev/null +++ b/lib/event/emailjob/v1/types.go @@ -0,0 +1,73 @@ +// 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 v1 + +import ( + "time" + + "github.com/GoogleChrome/webstatus.dev/lib/workertypes" +) + +// EmailJobEvent represents an email job event. +type EmailJobEvent struct { + // SubscriptionID is the ID of the subscription that triggered this job. + SubscriptionID string `json:"subscription_id"` + // RecipientEmail is the email address of the recipient. + RecipientEmail string `json:"recipient_email"` + // SummaryRaw is the raw JSON bytes of the event summary. + SummaryRaw []byte `json:"summary_raw"` + // Metadata contains additional metadata about the event. + Metadata EmailJobEventMetadata `json:"metadata"` +} + +type EmailJobEventMetadata struct { + // EventID is the ID of the original event that triggered this job. + EventID string `json:"event_id"` + // SearchID is the ID of the search that generated the event. + SearchID string `json:"search_id"` + // Query is the query string used for the search. + Query string `json:"query"` + // Frequency is the frequency of the job (e.g., "daily", "weekly"). + Frequency JobFrequency `json:"frequency"` + // GeneratedAt is the timestamp when the original event was generated. + GeneratedAt time.Time `json:"generated_at"` +} + +func (EmailJobEvent) Kind() string { return "EmailJobEvent" } +func (EmailJobEvent) APIVersion() string { return "v1" } + +type JobFrequency string + +const ( + FrequencyUnknown JobFrequency = "UNKNOWN" + FrequencyImmediate JobFrequency = "IMMEDIATE" + FrequencyWeekly JobFrequency = "WEEKLY" + FrequencyMonthly JobFrequency = "MONTHLY" +) + +func ToJobFrequency(freq workertypes.JobFrequency) JobFrequency { + switch freq { + case workertypes.FrequencyImmediate: + return FrequencyImmediate + case workertypes.FrequencyWeekly: + return FrequencyWeekly + case workertypes.FrequencyMonthly: + return FrequencyMonthly + case workertypes.FrequencyUnknown: + return FrequencyUnknown + } + + return FrequencyUnknown +} diff --git a/lib/gcppubsub/gcppubsubadapters/push_delivery.go b/lib/gcppubsub/gcppubsubadapters/push_delivery.go new file mode 100644 index 000000000..d919a694f --- /dev/null +++ b/lib/gcppubsub/gcppubsubadapters/push_delivery.go @@ -0,0 +1,115 @@ +// 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" + "fmt" + "log/slog" + + "github.com/GoogleChrome/webstatus.dev/lib/event" + v1 "github.com/GoogleChrome/webstatus.dev/lib/event/emailjob/v1" + featurediffv1 "github.com/GoogleChrome/webstatus.dev/lib/event/featurediff/v1" + "github.com/GoogleChrome/webstatus.dev/lib/workertypes" +) + +type PushDeliveryPublisher struct { + client EventPublisher + emailTopic string +} + +func NewPushDeliveryPublisher(client EventPublisher, emailTopic string) *PushDeliveryPublisher { + return &PushDeliveryPublisher{ + client: client, + emailTopic: emailTopic, + } +} + +func (p *PushDeliveryPublisher) PublishEmailJob(ctx context.Context, job workertypes.EmailDeliveryJob) error { + b, err := event.New(v1.EmailJobEvent{ + SubscriptionID: job.SubscriptionID, + RecipientEmail: job.RecipientEmail, + SummaryRaw: job.SummaryRaw, + Metadata: v1.EmailJobEventMetadata{ + EventID: job.Metadata.EventID, + SearchID: job.Metadata.SearchID, + Query: job.Metadata.Query, + Frequency: v1.ToJobFrequency(job.Metadata.Frequency), + GeneratedAt: job.Metadata.GeneratedAt, + }, + }) + if err != nil { + return err + } + + if _, err := p.client.Publish(ctx, p.emailTopic, b); err != nil { + return fmt.Errorf("failed to publish email job: %w", err) + } + + return nil +} + +// PushDeliveryMessageHandler defines the interface for the Dispatcher logic. +type PushDeliveryMessageHandler interface { + ProcessEvent(ctx context.Context, metadata workertypes.DispatchEventMetadata, summary []byte) error +} + +type PushDeliverySubscriberAdapter struct { + dispatcher PushDeliveryMessageHandler + eventSubscriber EventSubscriber + subscriptionID string + router *event.Router +} + +func NewPushDeliverySubscriberAdapter( + dispatcher PushDeliveryMessageHandler, + eventSubscriber EventSubscriber, + subscriptionID string, +) *PushDeliverySubscriberAdapter { + router := event.NewRouter() + + ret := &PushDeliverySubscriberAdapter{ + dispatcher: dispatcher, + eventSubscriber: eventSubscriber, + subscriptionID: subscriptionID, + router: router, + } + + event.Register(router, ret.processFeatureDiffEvent) + + return ret +} + +func (a *PushDeliverySubscriberAdapter) Subscribe(ctx context.Context) error { + return a.eventSubscriber.Subscribe(ctx, a.subscriptionID, func(ctx context.Context, + msgID string, data []byte) error { + return a.router.HandleMessage(ctx, msgID, data) + }) +} + +func (a *PushDeliverySubscriberAdapter) processFeatureDiffEvent(ctx context.Context, + eventID string, event featurediffv1.FeatureDiffEvent) error { + slog.InfoContext(ctx, "received feature diff event", "eventID", eventID) + + metadata := workertypes.DispatchEventMetadata{ + EventID: event.EventID, + SearchID: event.SearchID, + Query: event.Query, + Frequency: event.Frequency.ToWorkertypes(), + GeneratedAt: event.GeneratedAt, + } + + return a.dispatcher.ProcessEvent(ctx, metadata, event.Summary) +} diff --git a/lib/gcppubsub/gcppubsubadapters/push_delivery_test.go b/lib/gcppubsub/gcppubsubadapters/push_delivery_test.go new file mode 100644 index 000000000..49b825596 --- /dev/null +++ b/lib/gcppubsub/gcppubsubadapters/push_delivery_test.go @@ -0,0 +1,254 @@ +// 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/base64" + "encoding/json" + "sync" + "testing" + "time" + + featurediffv1 "github.com/GoogleChrome/webstatus.dev/lib/event/featurediff/v1" + "github.com/GoogleChrome/webstatus.dev/lib/workertypes" + "github.com/google/go-cmp/cmp" +) + +// --- Mocks --- + +type mockPushDeliveryPublisher struct { + publishedData []byte + publishedTopic string + err error + mu sync.Mutex // Added mutex for concurrent access +} + +func (m *mockPushDeliveryPublisher) Publish(_ context.Context, topicID string, data []byte) (string, error) { + m.mu.Lock() + defer m.mu.Unlock() + m.publishedData = data + m.publishedTopic = topicID + + return "msg-id", m.err +} + +type mockDispatcher struct { + calls []processEventCall + mu sync.Mutex + err error +} + +type processEventCall struct { + Metadata workertypes.DispatchEventMetadata + Summary []byte +} + +func (m *mockDispatcher) ProcessEvent(_ context.Context, + metadata workertypes.DispatchEventMetadata, summary []byte) error { + m.mu.Lock() + defer m.mu.Unlock() + m.calls = append(m.calls, processEventCall{Metadata: metadata, Summary: summary}) + + return m.err +} + +type mockPushDeliverySubscriber struct { + handlers map[string]func(context.Context, string, []byte) error + mu sync.Mutex + // block allows us to simulate a long-running Subscribe call so RunGroup doesn't exit immediately + block chan struct{} +} + +func (m *mockPushDeliverySubscriber) Subscribe(ctx context.Context, subID string, + handler func(context.Context, string, []byte) error) error { + m.mu.Lock() + if m.handlers == nil { + m.handlers = make(map[string]func(context.Context, string, []byte) error) + } + m.handlers[subID] = handler + m.mu.Unlock() + + // Simulate blocking behavior of a real subscriber logic + if m.block != nil { + select { + case <-m.block: + return nil + case <-ctx.Done(): + return ctx.Err() + } + } + + return nil +} + +// --- Tests --- + +func TestPushDeliveryPublisher_PublishEmailJob(t *testing.T) { + mockPub := new(mockPushDeliveryPublisher) + publisher := NewPushDeliveryPublisher(mockPub, "email-topic") + + job := workertypes.EmailDeliveryJob{ + SubscriptionID: "sub-1", + RecipientEmail: "test@example.com", + SummaryRaw: []byte(`{"text": "Test Body"}`), + Metadata: workertypes.DeliveryMetadata{ + EventID: "event-1", + SearchID: "search-1", + Query: "query-string", + Frequency: workertypes.FrequencyMonthly, + GeneratedAt: time.Date(2025, 1, 1, 12, 0, 0, 0, time.UTC), + }, + } + + err := publisher.PublishEmailJob(context.Background(), job) + if err != nil { + t.Fatalf("PublishEmailJob failed: %v", err) + } + + if mockPub.publishedTopic != "email-topic" { + t.Errorf("Topic mismatch: got %s, want email-topic", mockPub.publishedTopic) + } + + var actualEnvelope map[string]interface{} + if err := json.Unmarshal(mockPub.publishedData, &actualEnvelope); err != nil { + t.Fatalf("Failed to unmarshal published data: %v", err) + } + + expectedEnvelope := map[string]interface{}{ + "apiVersion": "v1", + "kind": "EmailJobEvent", + "data": map[string]interface{}{ + "subscription_id": "sub-1", + "recipient_email": "test@example.com", + "summary_raw": base64.StdEncoding.EncodeToString([]byte(`{"text": "Test Body"}`)), + "metadata": map[string]interface{}{ + "event_id": "event-1", + "search_id": "search-1", + "query": "query-string", + "frequency": "MONTHLY", + "generated_at": "2025-01-01T12:00:00Z", + }, + }, + } + + if diff := cmp.Diff(expectedEnvelope, actualEnvelope); diff != "" { + t.Errorf("Email job mismatch (-want +got):\n%s", diff) + } +} + +type pushDeliveryTestEnv struct { + dispatcher *mockDispatcher + subscriber *mockPushDeliverySubscriber + adapter *PushDeliverySubscriberAdapter + featureDiffFn func(context.Context, string, []byte) error + stop func() +} + +func setupPushDeliveryTestAdapter(t *testing.T) *pushDeliveryTestEnv { + t.Helper() + dispatcher := new(mockDispatcher) + subscriber := &mockPushDeliverySubscriber{block: make(chan struct{}), mu: sync.Mutex{}, handlers: nil} + subscriptionID := "feature-diff-sub" + + adapter := NewPushDeliverySubscriberAdapter(dispatcher, subscriber, subscriptionID) + + ctx, cancel := context.WithCancel(context.Background()) + + errChan := make(chan error, 1) // Buffered channel to prevent goroutine leak on t.Fatal + go func() { + errChan <- adapter.Subscribe(ctx) + }() + + // Wait briefly for Subscribe to start and handler to be registered + time.Sleep(50 * time.Millisecond) + + subscriber.mu.Lock() + featureDiffFn := subscriber.handlers[subscriptionID] + subscriber.mu.Unlock() + + if featureDiffFn == nil { + cancel() + close(subscriber.block) + <-errChan + t.Fatal("Subscribe did not register handler for subscription") + } + + return &pushDeliveryTestEnv{ + dispatcher: dispatcher, + subscriber: subscriber, + adapter: adapter, + featureDiffFn: featureDiffFn, + stop: func() { + close(subscriber.block) // Unblock the subscriber + cancel() // Cancel the context + <-errChan // Wait for adapter.Subscribe to return + }, + } +} + +func TestPushDeliverySubscriber_RoutesFeatureDiffEvent(t *testing.T) { + env := setupPushDeliveryTestAdapter(t) + defer env.stop() + + now := time.Date(2025, 1, 1, 12, 0, 0, 0, time.UTC) + featureDiffEvent := featurediffv1.FeatureDiffEvent{ + EventID: "evt-1", + SearchID: "s1", + Query: "q1", + Summary: []byte(`{"added": 1}`), + StateID: "state-id-1", + StateBlobPath: "gs://bucket/state-blob", + DiffID: "diff-id-1", + DiffBlobPath: "gs://bucket/diff-blob", + GeneratedAt: now, + Frequency: featurediffv1.FrequencyMonthly, + Reasons: []featurediffv1.Reason{featurediffv1.ReasonDataUpdated}, + } + ceWrapper := map[string]interface{}{ + "apiVersion": "v1", + "kind": "FeatureDiffEvent", + "data": featureDiffEvent, + } + ceBytes, _ := json.Marshal(ceWrapper) + + if err := env.featureDiffFn(context.Background(), "msg-1", ceBytes); err != nil { + t.Errorf("featureDiffFn failed: %v", err) + } + + if len(env.dispatcher.calls) != 1 { + t.Fatalf("Expected 1 dispatcher call, got %d", len(env.dispatcher.calls)) + } + + expectedMetadata := workertypes.DispatchEventMetadata{ + EventID: "evt-1", + SearchID: "s1", + Query: "q1", + Frequency: workertypes.FrequencyMonthly, + GeneratedAt: now, + } + + // Compare summary as string since cmp.Diff might struggle with []byte directly within interface{} + actualSummaryStr := string(env.dispatcher.calls[0].Summary) + expectedSummaryStr := string(featureDiffEvent.Summary) + + if diff := cmp.Diff(expectedMetadata, env.dispatcher.calls[0].Metadata); diff != "" { + t.Errorf("Dispatcher metadata mismatch (-want +got):\n%s", diff) + } + + if diff := cmp.Diff(expectedSummaryStr, actualSummaryStr); diff != "" { + t.Errorf("Dispatcher summary mismatch (-want +got):\n%s", diff) + } +} From 87667758945c8511be073850847f9569a7125093 Mon Sep 17 00:00:00 2001 From: James Scott Date: Mon, 29 Dec 2025 04:12:36 +0000 Subject: [PATCH 12/27] feat(push_delivery): complete event processing, filtering, and subscription This commit unifies the `push_delivery` worker's event processing pipeline by connecting all necessary components, implementing event filtering, and enforcing typed triggers for subscriptions. **Core Features & Changes:** - **Full Event Pipeline Integration**: Uses the `PushDeliverySubscriberAdapter` to consume `FeatureDiffEvent` notifications from Pub/Sub, parsing incoming messages, and feeding them to the `Dispatcher`. - **Intelligent Event Filtering**: Implements the core event filtering logic within the Push Delivery Dispatcher using `shouldNotifyV1` and `matchesTrigger`. This logic inspects `EventSummary` highlights, ensuring that subscribers only receive notifications for events that precisely match their defined criteria (e.g., "Baseline Status Changed to Newly"). - **Enhanced Worker Types**: Adds `DispatchEventMetadata` to `lib/workertypes` to carry essential context for notification rendering. - **Main Entry Point Setup**: Modifies `cmd/job/main.go` to correctly initialize the Spanner finder, Pub/Sub publisher, and the new `PushDeliverySubscriberAdapter`, thereby enabling the full worker loop. - **Infrastructure Alignment**: Updates `manifests/pod.yaml` with the correct environment variables and topic IDs for seamless deployment and operation. --- lib/workertypes/types.go | 14 +- workers/email/manifests/pod.yaml | 2 +- workers/push_delivery/cmd/job/main.go | 31 +-- workers/push_delivery/go.mod | 18 +- workers/push_delivery/go.sum | 22 +- workers/push_delivery/manifests/pod.yaml | 6 +- .../pkg/dispatcher/dispatcher.go | 48 +++- .../pkg/dispatcher/dispatcher_test.go | 223 +++++++++++++++++- workers/push_delivery/skaffold.yaml | 1 - 9 files changed, 307 insertions(+), 58 deletions(-) diff --git a/lib/workertypes/types.go b/lib/workertypes/types.go index 1f8c8e03e..05d3804f7 100644 --- a/lib/workertypes/types.go +++ b/lib/workertypes/types.go @@ -174,11 +174,11 @@ type SummaryHighlight struct { DocLinks []DocLink `json:"doc_links,omitempty"` // Strongly typed change fields to support i18n and avoid interface{} - NameChange *Change[string] `json:"name_change,omitempty"` - BaselineChange *Change[BaselineValue] `json:"baseline_change,omitempty"` - BrowserChanges map[BrowserName]Change[BrowserValue] `json:"browser_changes,omitempty"` - Moved *Change[FeatureRef] `json:"moved,omitempty"` - Split *SplitChange `json:"split,omitempty"` + NameChange *Change[string] `json:"name_change,omitempty"` + BaselineChange *Change[BaselineValue] `json:"baseline_change,omitempty"` + BrowserChanges map[BrowserName]*Change[BrowserValue] `json:"browser_changes,omitempty"` + Moved *Change[FeatureRef] `json:"moved,omitempty"` + Split *SplitChange `json:"split,omitempty"` } // SummaryVisitor defines the contract for consuming immutable Event Summaries. @@ -342,7 +342,7 @@ func (g FeatureDiffV1SummaryGenerator) processModified(highlights []SummaryHighl } if len(m.BrowserChanges) > 0 { - h.BrowserChanges = make(map[BrowserName]Change[BrowserValue]) + h.BrowserChanges = make(map[BrowserName]*Change[BrowserValue]) for b, c := range m.BrowserChanges { if c == nil { continue @@ -366,7 +366,7 @@ func (g FeatureDiffV1SummaryGenerator) processModified(highlights []SummaryHighl default: continue } - h.BrowserChanges[key] = Change[BrowserValue]{ + h.BrowserChanges[key] = &Change[BrowserValue]{ From: toBrowserValue(c.From), To: toBrowserValue(c.To), } diff --git a/workers/email/manifests/pod.yaml b/workers/email/manifests/pod.yaml index 0e8b1744e..b3ab7b3d5 100644 --- a/workers/email/manifests/pod.yaml +++ b/workers/email/manifests/pod.yaml @@ -33,7 +33,7 @@ spec: - name: SPANNER_EMULATOR_HOST value: 'spanner:9010' - name: PUBSUB_EMULATOR_HOST - value: 'http://pubsub:8086' + value: 'pubsub:8060' - name: EMAIL_SUBSCRIPTION_ID value: 'chime-delivery-sub-id' resources: diff --git a/workers/push_delivery/cmd/job/main.go b/workers/push_delivery/cmd/job/main.go index e92d2aad3..764fbfef3 100644 --- a/workers/push_delivery/cmd/job/main.go +++ b/workers/push_delivery/cmd/job/main.go @@ -19,9 +19,11 @@ import ( "log/slog" "os" - "github.com/GoogleChrome/webstatus.dev/lib/gcpgcs" "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/workers/push_delivery/pkg/dispatcher" ) func main() { @@ -64,29 +66,22 @@ func main() { os.Exit(1) } - stateBlobBucket := os.Getenv("STATE_BLOB_BUCKET") - if stateBlobBucket == "" { - slog.ErrorContext(ctx, "STATE_BLOB_BUCKET is not set. exiting...") - os.Exit(1) - } - queueClient, err := gcppubsub.NewClient(ctx, projectID) if err != nil { slog.ErrorContext(ctx, "unable to create pub sub client", "error", err) os.Exit(1) } - _, err = gcpgcs.NewClient(ctx, stateBlobBucket) - if err != nil { - slog.ErrorContext(ctx, "unable to create gcs client", "error", err) - os.Exit(1) - } - - // TODO: https://github.com/GoogleChrome/webstatus.dev/issues/1851 - // Nil handler for now. Will fix later - err = queueClient.Subscribe(ctx, notificationSubID, nil) - if err != nil { - slog.ErrorContext(ctx, "unable to connect to subscription", "error", err) + listener := gcppubsubadapters.NewPushDeliverySubscriberAdapter( + dispatcher.NewDispatcher( + spanneradapters.NewPushDeliverySubscriberFinder(spannerClient), + gcppubsubadapters.NewPushDeliveryPublisher(queueClient, emailTopicID), + ), + queueClient, + notificationSubID, + ) + if err := listener.Subscribe(ctx); err != nil { + slog.ErrorContext(ctx, "Push delivery subscriber failed", "error", err) os.Exit(1) } } diff --git a/workers/push_delivery/go.mod b/workers/push_delivery/go.mod index a9e832968..2caa01c2c 100644 --- a/workers/push_delivery/go.mod +++ b/workers/push_delivery/go.mod @@ -16,21 +16,24 @@ require ( cloud.google.com/go v0.123.0 // indirect cloud.google.com/go/auth v0.17.0 // indirect cloud.google.com/go/auth/oauth2adapt v0.2.8 // indirect + cloud.google.com/go/cloudtasks v1.13.7 // indirect cloud.google.com/go/compute/metadata v0.9.0 // indirect + cloud.google.com/go/datastore v1.21.0 // indirect cloud.google.com/go/iam v1.5.3 // indirect + 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 github.com/GoogleChrome/webstatus.dev/lib/gen v0.0.0-20251209015135-23da3f0e7b76 // indirect github.com/GoogleCloudPlatform/grpc-gcp-go/grpcgcp v1.5.3 // indirect github.com/GoogleCloudPlatform/opentelemetry-operations-go/detectors/gcp v1.30.0 // indirect - github.com/GoogleCloudPlatform/opentelemetry-operations-go/exporter/metric v0.54.0 // indirect - github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/resourcemapping v0.54.0 // indirect github.com/antlr4-go/antlr/v4 v4.13.1 // indirect github.com/apapsch/go-jsonmerge/v2 v2.0.0 // indirect github.com/cespare/xxhash/v2 v2.3.0 // indirect github.com/cncf/xds/go v0.0.0-20251110193048-8bfbf64dc13e // indirect + github.com/deckarep/golang-set v1.8.0 // indirect github.com/envoyproxy/go-control-plane/envoy v1.36.0 // indirect github.com/envoyproxy/protoc-gen-validate v1.2.1 // indirect github.com/felixge/httpsnoop v1.0.4 // indirect @@ -41,10 +44,16 @@ require ( github.com/go-openapi/jsonpointer v0.22.3 // indirect github.com/go-openapi/swag/jsonname v0.25.3 // indirect github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 // indirect + github.com/gomodule/redigo v1.9.3 // indirect + github.com/google/go-github/v77 v77.0.0 // indirect + github.com/google/go-querystring v1.1.0 // indirect github.com/google/s2a-go v0.1.9 // indirect github.com/google/uuid v1.6.0 // indirect github.com/googleapis/enterprise-certificate-proxy v0.3.7 // indirect github.com/googleapis/gax-go/v2 v2.15.0 // indirect + github.com/gorilla/handlers v1.5.2 // indirect + github.com/gorilla/mux v1.8.1 // indirect + github.com/gorilla/securecookie v1.1.2 // indirect github.com/josharian/intern v1.0.0 // indirect github.com/mailru/easyjson v0.9.1 // indirect github.com/mohae/deepcopy v0.0.0-20170929034955-c48cc78d4826 // indirect @@ -53,7 +62,9 @@ require ( github.com/oasdiff/yaml3 v0.0.0-20250309153720-d2182401db90 // indirect github.com/perimeterx/marshmallow v1.1.5 // indirect github.com/planetscale/vtprotobuf v0.6.1-0.20240319094008-0393e58bdf10 // indirect + github.com/sirupsen/logrus v1.9.3 // indirect github.com/spiffe/go-spiffe/v2 v2.6.0 // indirect + github.com/web-platform-tests/wpt.fyi v0.0.0-20251118162843-54f805c8a632 // indirect github.com/woodsbury/decimal128 v1.4.0 // indirect go.opencensus.io v0.24.0 // indirect go.opentelemetry.io/auto/sdk v1.2.1 // indirect @@ -79,4 +90,5 @@ require ( google.golang.org/genproto/googleapis/rpc v0.0.0-20251111163417-95abcf5c77ba // indirect google.golang.org/grpc v1.77.0 // indirect google.golang.org/protobuf v1.36.10 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect ) diff --git a/workers/push_delivery/go.sum b/workers/push_delivery/go.sum index dfb79e895..672b53cf0 100644 --- a/workers/push_delivery/go.sum +++ b/workers/push_delivery/go.sum @@ -555,8 +555,6 @@ cloud.google.com/go/storage v1.23.0/go.mod h1:vOEEDNFnciUMhBeT6hsJIn3ieU5cFRmzeL cloud.google.com/go/storage v1.27.0/go.mod h1:x9DOL8TK/ygDUMieqwfhdpQryTeEkhGKMi80i/iqR2s= cloud.google.com/go/storage v1.28.1/go.mod h1:Qnisd4CqDdo6BGs2AD5LLnEsmSQ80wQ5ogcBBKhU86Y= cloud.google.com/go/storage v1.29.0/go.mod h1:4puEjyTKnku6gfKoTfNOU/W+a9JyuVNxjpS5GBrB8h4= -cloud.google.com/go/storage v1.57.2 h1:sVlym3cHGYhrp6XZKkKb+92I1V42ks2qKKpB0CF5Mb4= -cloud.google.com/go/storage v1.57.2/go.mod h1:n5ijg4yiRXXpCu0sJTD6k+eMf7GRrJmPyr9YxLXGHOk= cloud.google.com/go/storagetransfer v1.5.0/go.mod h1:dxNzUopWy7RQevYFHewchb29POFv3/AaBgnhqzqiK0w= cloud.google.com/go/storagetransfer v1.6.0/go.mod h1:y77xm4CQV/ZhFZH75PLEXY0ROiS7Gh6pSKrM8dJyg6I= cloud.google.com/go/storagetransfer v1.7.0/go.mod h1:8Giuj1QNb1kfLAiWM1bN6dHzfdlDAVC9rv9abHot2W4= @@ -576,8 +574,6 @@ cloud.google.com/go/trace v1.3.0/go.mod h1:FFUE83d9Ca57C+K8rDl/Ih8LwOzWIV1krKgxg cloud.google.com/go/trace v1.4.0/go.mod h1:UG0v8UBqzusp+z63o7FK74SdFE+AXpCLdFb1rshXG+Y= cloud.google.com/go/trace v1.8.0/go.mod h1:zH7vcsbAhklH8hWFig58HvxcxyQbaIqMarMg9hn5ECA= cloud.google.com/go/trace v1.9.0/go.mod h1:lOQqpE5IaWY0Ixg7/r2SjixMuc6lfTFeO4QGM4dQWOk= -cloud.google.com/go/trace v1.11.7 h1:kDNDX8JkaAG3R2nq1lIdkb7FCSi1rCmsEtKVsty7p+U= -cloud.google.com/go/trace v1.11.7/go.mod h1:TNn9d5V3fQVf6s4SCveVMIBS2LJUqo73GACmq/Tky0s= cloud.google.com/go/translate v1.3.0/go.mod h1:gzMUwRjvOqj5i69y/LYLd8RrNQk+hOmIXTi9+nb3Djs= cloud.google.com/go/translate v1.4.0/go.mod h1:06Dn/ppvLD6WvA5Rhdp029IX2Mi3Mn7fpMRLPvXT5Wg= cloud.google.com/go/translate v1.5.0/go.mod h1:29YDSYveqqpA1CQFD7NQuP49xymq17RXNaUDdc0mNu0= @@ -637,12 +633,6 @@ github.com/GoogleCloudPlatform/grpc-gcp-go/grpcgcp v1.5.3 h1:2afWGsMzkIcN8Qm4mgP github.com/GoogleCloudPlatform/grpc-gcp-go/grpcgcp v1.5.3/go.mod h1:dppbR7CwXD4pgtV9t3wD1812RaLDcBjtblcDF5f1vI0= github.com/GoogleCloudPlatform/opentelemetry-operations-go/detectors/gcp v1.30.0 h1:sBEjpZlNHzK1voKq9695PJSX2o5NEXl7/OL3coiIY0c= github.com/GoogleCloudPlatform/opentelemetry-operations-go/detectors/gcp v1.30.0/go.mod h1:P4WPRUkOhJC13W//jWpyfJNDAIpvRbAUIYLX/4jtlE0= -github.com/GoogleCloudPlatform/opentelemetry-operations-go/exporter/metric v0.54.0 h1:lhhYARPUu3LmHysQ/igznQphfzynnqI3D75oUyw1HXk= -github.com/GoogleCloudPlatform/opentelemetry-operations-go/exporter/metric v0.54.0/go.mod h1:l9rva3ApbBpEJxSNYnwT9N4CDLrWgtq3u8736C5hyJw= -github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/cloudmock v0.54.0 h1:xfK3bbi6F2RDtaZFtUdKO3osOBIhNb+xTs8lFW6yx9o= -github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/cloudmock v0.54.0/go.mod h1:vB2GH9GAYYJTO3mEn8oYwzEdhlayZIdQz6zdzgUIRvA= -github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/resourcemapping v0.54.0 h1:s0WlVbf9qpvkh1c/uDAPElam0WrL7fHRIidgZJ7UqZI= -github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/resourcemapping v0.54.0/go.mod h1:Mf6O40IAyB9zR/1J8nGDDPirZQQPbYJni8Yisy7NTMc= github.com/JohnCGriffin/overflow v0.0.0-20211019200055-46fa312c352c/go.mod h1:X0CRv0ky0k6m906ixxpzmDRLvX58TFUKS2eePweuyxk= github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY= github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU= @@ -852,14 +842,13 @@ github.com/google/go-github/v77 v77.0.0 h1:9DsKKbZqil5y/4Z9mNpZDQnpli6PJbqipSuuN github.com/google/go-github/v77 v77.0.0/go.mod h1:c8VmGXRUmaZUqbctUcGEDWYnMrtzZfJhDSylEf1wfmA= github.com/google/go-querystring v1.1.0 h1:AnCroh3fv4ZBgVIf1Iwtovgjaw/GiKJo8M8yD/fhyJ8= github.com/google/go-querystring v1.1.0/go.mod h1:Kcdr2DB4koayq7X8pmAG4sNG59So17icRSOU623lUBU= -github.com/google/martian v2.1.0+incompatible h1:/CP5g8u/VJHijgedC/Legn3BAbAaWPgecwXBIDzw5no= +github.com/google/gofuzz v1.2.0 h1:xRy4A+RhZaiKjJ1bPfwQ8sedCA+YS2YcCHW6ec7JMi0= +github.com/google/gofuzz v1.2.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= github.com/google/martian v2.1.0+incompatible/go.mod h1:9I4somxYTbIHy5NJKHRl3wXiIaQGbYVAs8BPL6v8lEs= github.com/google/martian/v3 v3.0.0/go.mod h1:y5Zk1BBys9G+gd6Jrk0W3cC1+ELVxBWuIGO+w/tUAp0= github.com/google/martian/v3 v3.1.0/go.mod h1:y5Zk1BBys9G+gd6Jrk0W3cC1+ELVxBWuIGO+w/tUAp0= github.com/google/martian/v3 v3.2.1/go.mod h1:oBOf6HBosgwRXnUGWUB05QECsc6uvmMiJ3+6W4l/CUk= github.com/google/martian/v3 v3.3.2/go.mod h1:oBOf6HBosgwRXnUGWUB05QECsc6uvmMiJ3+6W4l/CUk= -github.com/google/martian/v3 v3.3.3 h1:DIhPTQrbPkgs2yJYdXU/eNACCG5DVQjySNRNlflZ9Fc= -github.com/google/martian/v3 v3.3.3/go.mod h1:iEPrYcgCF7jA9OtScMFQyAlZZ4YXTKEtJ1E6RWzmBA0= github.com/google/pprof v0.0.0-20181206194817-3ea8567a2e57/go.mod h1:zfwlbNMJ+OItoe0UupaVj+oy1omPYYDuagoSzA8v9mc= github.com/google/pprof v0.0.0-20190515194954-54271f7e092f/go.mod h1:zfwlbNMJ+OItoe0UupaVj+oy1omPYYDuagoSzA8v9mc= github.com/google/pprof v0.0.0-20191218002539-d4f498aebedc/go.mod h1:ZgVRPoUq/hfqzAqh7sHMqb3I9Rq5C59dIz2SbBwJ4eM= @@ -986,6 +975,8 @@ github.com/opencontainers/image-spec v1.1.1 h1:y0fUlFfIZhPF1W537XOLg0/fcx6zcHCJw github.com/opencontainers/image-spec v1.1.1/go.mod h1:qpqAh3Dmcf36wStyyWU+kCeDgrGnAve2nCC8+7h8Q0M= github.com/perimeterx/marshmallow v1.1.5 h1:a2LALqQ1BlHM8PZblsDdidgv1mWi1DgC2UmX50IvK2s= github.com/perimeterx/marshmallow v1.1.5/go.mod h1:dsXbUu8CRzfYP5a87xpp0xq9S3u0Vchtcl8we9tYaXw= +github.com/phayes/freeport v0.0.0-20220201140144-74d24b5ae9f5 h1:Ii+DKncOVM8Cu1Hc+ETb5K+23HdAMvESYE3ZJ5b5cMI= +github.com/phayes/freeport v0.0.0-20220201140144-74d24b5ae9f5/go.mod h1:iIss55rKnNBTvrwdmkUpLnDpZoAHvWaiq5+iMmen4AE= github.com/phpdave11/gofpdf v1.4.2/go.mod h1:zpO6xFn9yxo3YLyMvW8HcKWVdbNqgIfOOp2dXMnm1mY= github.com/phpdave11/gofpdi v1.0.12/go.mod h1:vBmVV0Do6hSBHC8uKUQ71JGW+ZGQq74llk/7bXwjDoI= github.com/phpdave11/gofpdi v1.0.13/go.mod h1:vBmVV0Do6hSBHC8uKUQ71JGW+ZGQq74llk/7bXwjDoI= @@ -1085,8 +1076,6 @@ go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.63.0 h1:RbKq8BG go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.63.0/go.mod h1:h06DGIukJOevXaj/xrNjhi/2098RZzcLTbc0jDAUbsg= go.opentelemetry.io/otel v1.38.0 h1:RkfdswUDRimDg0m2Az18RKOsnI8UDzppJAtj01/Ymk8= go.opentelemetry.io/otel v1.38.0/go.mod h1:zcmtmQ1+YmQM9wrNsTGV/q/uyusom3P8RxwExxkZhjM= -go.opentelemetry.io/otel/exporters/stdout/stdoutmetric v1.38.0 h1:wm/Q0GAAykXv83wzcKzGGqAnnfLFyFe7RslekZuv+VI= -go.opentelemetry.io/otel/exporters/stdout/stdoutmetric v1.38.0/go.mod h1:ra3Pa40+oKjvYh+ZD3EdxFZZB0xdMfuileHAm4nNN7w= go.opentelemetry.io/otel/metric v1.38.0 h1:Kl6lzIYGAh5M159u9NgiRkmoMKjvbsKtYRwgfrA6WpA= go.opentelemetry.io/otel/metric v1.38.0/go.mod h1:kB5n/QoRM8YwmUahxvI3bO34eVtQf2i4utNVLr9gEmI= go.opentelemetry.io/otel/sdk v1.38.0 h1:l48sr5YbNf2hpCUj/FoGhW9yDkl+Ma+LrVl8qaM5b+E= @@ -1098,6 +1087,8 @@ go.opentelemetry.io/otel/trace v1.38.0/go.mod h1:j1P9ivuFsTceSWe1oY+EeW3sc+Pp42s go.opentelemetry.io/proto/otlp v0.7.0/go.mod h1:PqfVotwruBrMGOCsRd/89rSnXhoiJIqeYNgFYFoEGnI= go.opentelemetry.io/proto/otlp v0.15.0/go.mod h1:H7XAot3MsfNsj7EXtrA2q5xSNQ10UqI405h3+duxN4U= go.opentelemetry.io/proto/otlp v0.19.0/go.mod h1:H7XAot3MsfNsj7EXtrA2q5xSNQ10UqI405h3+duxN4U= +go.uber.org/mock v0.6.0 h1:hyF9dfmbgIX5EfOdasqLsWD6xqpNZlXblLB/Dbnwv3Y= +go.uber.org/mock v0.6.0/go.mod h1:KiVJ4BqZJaMj4svdfmHM0AUx4NJYO8ZNpPnZn1Z+BBU= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= golang.org/x/crypto v0.0.0-20190510104115-cbcb75029529/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= golang.org/x/crypto v0.0.0-20190605123033-f99c8df09eb5/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= @@ -1343,6 +1334,7 @@ golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBc golang.org/x/sys v0.0.0-20220610221304-9f5ed59c137d/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220615213510-4f61da869c0c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220624220833-87e55d714810/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220728004956-3c1f35247d10/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= diff --git a/workers/push_delivery/manifests/pod.yaml b/workers/push_delivery/manifests/pod.yaml index ff2504835..0b80a3f3c 100644 --- a/workers/push_delivery/manifests/pod.yaml +++ b/workers/push_delivery/manifests/pod.yaml @@ -33,15 +33,11 @@ spec: - name: SPANNER_EMULATOR_HOST value: 'spanner:9010' - name: PUBSUB_EMULATOR_HOST - value: 'http://pubsub:8086' + value: 'pubsub:8060' - name: NOTIFICATION_SUBSCRIPTION_ID value: 'notification-events-sub-id' - name: EMAIL_TOPIC_ID value: 'chime-delivery-topic-id' - - name: STATE_BLOB_BUCKET - value: 'state-bucket' - - name: STORAGE_EMULATOR_HOST - value: 'http://gcs:4443' resources: limits: cpu: 250m diff --git a/workers/push_delivery/pkg/dispatcher/dispatcher.go b/workers/push_delivery/pkg/dispatcher/dispatcher.go index e3f43682b..d245cc5fb 100644 --- a/workers/push_delivery/pkg/dispatcher/dispatcher.go +++ b/workers/push_delivery/pkg/dispatcher/dispatcher.go @@ -171,6 +171,7 @@ func (g *deliveryJobGenerator) JobCount() int { // shouldNotifyV1 determines if the V1 event summary matches any of the user's triggers. func shouldNotifyV1(triggers []workertypes.JobTrigger, summary workertypes.EventSummary) bool { + // 1. Determine if summary has changes. hasChanges := summary.Categories.Added > 0 || summary.Categories.Removed > 0 || summary.Categories.Updated > 0 || @@ -182,6 +183,49 @@ func shouldNotifyV1(triggers []workertypes.JobTrigger, summary workertypes.Event return false } - // For V1, if the user has ANY triggers, and there ARE changes, we notify. - return len(triggers) > 0 + // 2. If user has no triggers. + // Assuming empty triggers = no notifications for safety. + if len(triggers) == 0 { + return false + } + + // 3. Iterate triggers and check highlights + for _, t := range triggers { + if matchesTrigger(t, summary) { + return true + } + } + + return false +} + +func matchesTrigger(t workertypes.JobTrigger, summary workertypes.EventSummary) bool { + for _, h := range summary.Highlights { + switch t { + case workertypes.FeaturePromotedToNewly: + if h.BaselineChange != nil && h.BaselineChange.To.Status == workertypes.BaselineStatusNewly { + return true + } + case workertypes.FeaturePromotedToWidely: + if h.BaselineChange != nil && h.BaselineChange.To.Status == workertypes.BaselineStatusWidely { + return true + } + case workertypes.FeatureRegressedToLimited: + if h.BaselineChange != nil && h.BaselineChange.To.Status == workertypes.BaselineStatusLimited { + return true + } + case workertypes.BrowserImplementationAnyComplete: + // BrowserChanges is a map, so we iterate values + for _, change := range h.BrowserChanges { + if change == nil { + continue + } + if change.To.Status == workertypes.BrowserStatusAvailable { + return true + } + } + } + } + + return false } diff --git a/workers/push_delivery/pkg/dispatcher/dispatcher_test.go b/workers/push_delivery/pkg/dispatcher/dispatcher_test.go index 839f43e03..5f847f052 100644 --- a/workers/push_delivery/pkg/dispatcher/dispatcher_test.go +++ b/workers/push_delivery/pkg/dispatcher/dispatcher_test.go @@ -135,14 +135,15 @@ func TestProcessEvent_Success(t *testing.T) { { SubscriptionID: "sub-1", UserID: "user-1", - Triggers: []workertypes.JobTrigger{"any_change"}, // Matches logic in shouldNotifyV1 + Triggers: []workertypes.JobTrigger{workertypes.FeaturePromotedToNewly}, // Matches EmailAddress: "user1@example.com", }, { SubscriptionID: "sub-2", UserID: "user-2", - Triggers: []workertypes.JobTrigger{}, // Empty triggers = no notify - EmailAddress: "user2@example.com", + // Does not match (summary is Newly) + Triggers: []workertypes.JobTrigger{workertypes.FeaturePromotedToWidely}, + EmailAddress: "user2@example.com", }, }, } @@ -159,6 +160,24 @@ func TestProcessEvent_Success(t *testing.T) { // Create a summary that HAS changes so notification logic proceeds. summary := createTestSummary(true) + summary.Categories.UpdatedBaseline = 1 + summary.Categories.Updated = 1 + summary.Highlights = []workertypes.SummaryHighlight{ + { + Type: workertypes.SummaryHighlightTypeChanged, + FeatureID: "test-feature-id", + FeatureName: "Test Feature", + DocLinks: nil, + NameChange: nil, + BaselineChange: &workertypes.Change[workertypes.BaselineValue]{ + From: newBaselineValue(workertypes.BaselineStatusLimited), + To: newBaselineValue(workertypes.BaselineStatusNewly), + }, + BrowserChanges: nil, + Moved: nil, + Split: nil, + }, + } parser := mockParserFactory(summary, nil) d := NewDispatcher(finder, publisher) @@ -303,8 +322,10 @@ func TestProcessEvent_PublisherPartialFailure(t *testing.T) { // Two subscribers subSet := &workertypes.SubscriberSet{ Emails: []workertypes.EmailSubscriber{ - {SubscriptionID: "sub-1", Triggers: []workertypes.JobTrigger{"change"}, UserID: "u1", EmailAddress: "e1"}, - {SubscriptionID: "sub-2", Triggers: []workertypes.JobTrigger{"change"}, UserID: "u2", EmailAddress: "e2"}, + {SubscriptionID: "sub-1", Triggers: []workertypes.JobTrigger{workertypes.FeaturePromotedToNewly}, + UserID: "u1", EmailAddress: "e1"}, + {SubscriptionID: "sub-2", Triggers: []workertypes.JobTrigger{workertypes.FeaturePromotedToNewly}, + UserID: "u2", EmailAddress: "e2"}, }, } @@ -326,8 +347,10 @@ func TestProcessEvent_PublisherPartialFailure(t *testing.T) { }, } + summaryWithNewly := withBaselineHighlight(createTestSummary(false), + workertypes.BaselineStatusLimited, workertypes.BaselineStatusNewly) d := NewDispatcher(finder, publisher) - d.parser = mockParserFactory(createTestSummary(true), nil) + d.parser = mockParserFactory(summaryWithNewly, nil) metadata := workertypes.DispatchEventMetadata{ EventID: "", @@ -383,3 +406,191 @@ func TestProcessEvent_JobCount(t *testing.T) { } assertFindSubscribersCalledWith(t, finder, generic.ValuePtr(emptyFinderReq())) } + +// --- shouldNotifyV1 Test Helpers --- + +func newBaselineValue(status workertypes.BaselineStatus) workertypes.BaselineValue { + t := time.Date(2025, 1, 1, 0, 0, 0, 0, time.UTC) + + return workertypes.BaselineValue{ + Status: status, + LowDate: &t, + HighDate: nil, + } +} + +func newBrowserValue(status workertypes.BrowserStatus) workertypes.BrowserValue { + version := "100" + + return workertypes.BrowserValue{ + Status: status, + Version: &version, + } +} + +func withBaselineHighlight( + s workertypes.EventSummary, from, to workertypes.BaselineStatus) workertypes.EventSummary { + s.Highlights = append(s.Highlights, workertypes.SummaryHighlight{ + Type: workertypes.SummaryHighlightTypeChanged, + FeatureID: "test-feature-id", + FeatureName: "Test Feature", + DocLinks: nil, + BaselineChange: &workertypes.Change[workertypes.BaselineValue]{ + From: newBaselineValue(from), + To: newBaselineValue(to), + }, + BrowserChanges: nil, + NameChange: nil, + Moved: nil, + Split: nil, + }) + s.Categories.Updated = 1 + s.Categories.UpdatedBaseline = 1 + + return s +} + +func withBrowserChangeHighlight( + s workertypes.EventSummary, from, to workertypes.BrowserStatus) workertypes.EventSummary { + s.Highlights = append(s.Highlights, workertypes.SummaryHighlight{ + Type: workertypes.SummaryHighlightTypeChanged, + FeatureID: "test-feature-id", + FeatureName: "Test Feature", + DocLinks: nil, + BaselineChange: nil, + BrowserChanges: map[workertypes.BrowserName]*workertypes.Change[workertypes.BrowserValue]{ + workertypes.BrowserChrome: { + From: newBrowserValue(from), + To: newBrowserValue(to), + }, + workertypes.BrowserChromeAndroid: nil, + workertypes.BrowserFirefox: nil, + workertypes.BrowserFirefoxAndroid: nil, + workertypes.BrowserEdge: nil, + workertypes.BrowserSafari: nil, + workertypes.BrowserSafariIos: nil, + }, + NameChange: nil, + Moved: nil, + Split: nil, + }) + s.Categories.Updated = 1 + s.Categories.UpdatedImpl = 1 + + return s +} + +func TestShouldNotifyV1(t *testing.T) { + summaryWithNewly := withBaselineHighlight(createTestSummary(false), + workertypes.BaselineStatusLimited, workertypes.BaselineStatusNewly) + summaryWithWidely := withBaselineHighlight(createTestSummary(false), + workertypes.BaselineStatusNewly, workertypes.BaselineStatusWidely) + summaryWithLimited := withBaselineHighlight(createTestSummary(false), + workertypes.BaselineStatusWidely, workertypes.BaselineStatusLimited) + summaryWithBrowserAvailable := withBrowserChangeHighlight(createTestSummary(false), + workertypes.BrowserStatusUnknown, workertypes.BrowserStatusAvailable) + summaryWithBrowserInDev := withBrowserChangeHighlight(createTestSummary(false), + workertypes.BrowserStatusUnknown, workertypes.BrowserStatusUnknown) + summaryQueryChanged := createTestSummary(false) + summaryQueryChanged.Categories.QueryChanged = 1 + + testCases := []struct { + name string + triggers []workertypes.JobTrigger + summary workertypes.EventSummary + want bool + }{ + { + name: "no changes should return false", + triggers: []workertypes.JobTrigger{workertypes.FeaturePromotedToNewly}, + summary: createTestSummary(false), + want: false, + }, + { + name: "changes but no triggers should return false", + triggers: []workertypes.JobTrigger{}, + summary: createTestSummary(true), + want: false, + }, + { + name: "changes and triggers but no highlights should return false", + triggers: []workertypes.JobTrigger{workertypes.FeaturePromotedToNewly}, + summary: createTestSummary(true), + want: false, + }, + { + name: "changes, triggers, highlights, but no match should return false", + triggers: []workertypes.JobTrigger{workertypes.FeaturePromotedToWidely}, + summary: summaryWithNewly, + want: false, + }, + { + name: "match on FeaturePromotedToNewly should return true", + triggers: []workertypes.JobTrigger{workertypes.FeaturePromotedToNewly}, + summary: summaryWithNewly, + want: true, + }, + { + name: "match on FeaturePromotedToWidely should return true", + triggers: []workertypes.JobTrigger{workertypes.FeaturePromotedToWidely}, + summary: summaryWithWidely, + want: true, + }, + { + name: "match on FeatureRegressedToLimited should return true", + triggers: []workertypes.JobTrigger{workertypes.FeatureRegressedToLimited}, + summary: summaryWithLimited, + want: true, + }, + { + name: "match on BrowserImplementationAnyComplete should return true", + triggers: []workertypes.JobTrigger{workertypes.BrowserImplementationAnyComplete}, + summary: summaryWithBrowserAvailable, + want: true, + }, + { + name: "no match on BrowserImplementation when status is not Available", + triggers: []workertypes.JobTrigger{workertypes.BrowserImplementationAnyComplete}, + summary: summaryWithBrowserInDev, + want: false, + }, + { + name: "multiple triggers with one match should return true", + triggers: []workertypes.JobTrigger{ + workertypes.FeaturePromotedToWidely, workertypes.FeaturePromotedToNewly}, + summary: summaryWithNewly, + want: true, + }, + { + name: "multiple highlights with one match should return true", + triggers: []workertypes.JobTrigger{workertypes.FeaturePromotedToWidely}, + summary: withBaselineHighlight(summaryWithNewly, + workertypes.BaselineStatusNewly, workertypes.BaselineStatusWidely), + want: true, + }, + { + name: "QueryChanged is considered a change and matches with highlight", + triggers: []workertypes.JobTrigger{ + workertypes.FeaturePromotedToNewly, + }, + summary: withBaselineHighlight(summaryQueryChanged, + workertypes.BaselineStatusLimited, workertypes.BaselineStatusNewly), + want: true, + }, + { + name: "no match when baseline highlight has wrong status", + triggers: []workertypes.JobTrigger{workertypes.FeaturePromotedToNewly}, + summary: summaryWithWidely, + want: false, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + got := shouldNotifyV1(tc.triggers, tc.summary) + if got != tc.want { + t.Errorf("shouldNotifyV1() = %v, want %v", got, tc.want) + } + }) + } +} diff --git a/workers/push_delivery/skaffold.yaml b/workers/push_delivery/skaffold.yaml index ce757f5fe..42cd614d2 100644 --- a/workers/push_delivery/skaffold.yaml +++ b/workers/push_delivery/skaffold.yaml @@ -19,7 +19,6 @@ metadata: requires: - path: ../../.dev/pubsub - path: ../../.dev/spanner - - path: ../../.dev/gcs profiles: - name: local build: From d4f7e597e65ea88ad53ea0e0f09fe496f6ef9913 Mon Sep 17 00:00:00 2001 From: James Scott Date: Mon, 29 Dec 2025 16:11:37 +0000 Subject: [PATCH 13/27] propagate ChannelID for status reporting & initial email worker setup Updates the `Subscriber` and `DeliveryJob` types (and their corresponding Spanner adapters and tests) to include `ChannelID`. This identifier is needed for the Email Worker to report delivery status (success/failure) back to the `NotificationChannelState` table. Changes: - **Worker Types**: Added `ChannelID` to `EmailSubscriber` and `EmailDeliveryJob`. - **Event Types**: Added `ChannelID` to `EmailJobEvent`. - **Spanner Adapter**: Updated `FindSubscribers` to populate `ChannelID` from the query results. - **Pub/Sub Adapter**: Updated `PublishEmailJob` to pass the ID through. - **Email Worker**: Initial scaffolding for `sender` package (interfaces/mocks) that will utilize this ID. --- lib/event/emailjob/v1/types.go | 2 + .../gcppubsubadapters/push_delivery.go | 1 + .../gcppubsubadapters/push_delivery_test.go | 2 + .../spanneradapters/push_delivery.go | 1 + .../spanneradapters/push_delivery_test.go | 3 + lib/workertypes/types.go | 2 + workers/email/go.mod | 17 +- workers/email/go.sum | 41 ++++ workers/email/pkg/sender/sender.go | 87 ++++++++ workers/email/pkg/sender/sender_test.go | 210 ++++++++++++++++++ .../pkg/dispatcher/dispatcher.go | 1 + .../pkg/dispatcher/dispatcher_test.go | 14 +- 12 files changed, 377 insertions(+), 4 deletions(-) create mode 100644 workers/email/pkg/sender/sender.go create mode 100644 workers/email/pkg/sender/sender_test.go diff --git a/lib/event/emailjob/v1/types.go b/lib/event/emailjob/v1/types.go index 3fd1dd2b8..831f6620d 100644 --- a/lib/event/emailjob/v1/types.go +++ b/lib/event/emailjob/v1/types.go @@ -30,6 +30,8 @@ type EmailJobEvent struct { SummaryRaw []byte `json:"summary_raw"` // Metadata contains additional metadata about the event. Metadata EmailJobEventMetadata `json:"metadata"` + // ChannelID is the ID of the channel associated with this job. + ChannelID string `json:"channel_id"` } type EmailJobEventMetadata struct { diff --git a/lib/gcppubsub/gcppubsubadapters/push_delivery.go b/lib/gcppubsub/gcppubsubadapters/push_delivery.go index d919a694f..0a42bd39c 100644 --- a/lib/gcppubsub/gcppubsubadapters/push_delivery.go +++ b/lib/gcppubsub/gcppubsubadapters/push_delivery.go @@ -49,6 +49,7 @@ func (p *PushDeliveryPublisher) PublishEmailJob(ctx context.Context, job workert Frequency: v1.ToJobFrequency(job.Metadata.Frequency), GeneratedAt: job.Metadata.GeneratedAt, }, + ChannelID: job.ChannelID, }) if err != nil { return err diff --git a/lib/gcppubsub/gcppubsubadapters/push_delivery_test.go b/lib/gcppubsub/gcppubsubadapters/push_delivery_test.go index 49b825596..e61598341 100644 --- a/lib/gcppubsub/gcppubsubadapters/push_delivery_test.go +++ b/lib/gcppubsub/gcppubsubadapters/push_delivery_test.go @@ -111,6 +111,7 @@ func TestPushDeliveryPublisher_PublishEmailJob(t *testing.T) { Frequency: workertypes.FrequencyMonthly, GeneratedAt: time.Date(2025, 1, 1, 12, 0, 0, 0, time.UTC), }, + ChannelID: "chan-1", } err := publisher.PublishEmailJob(context.Background(), job) @@ -141,6 +142,7 @@ func TestPushDeliveryPublisher_PublishEmailJob(t *testing.T) { "frequency": "MONTHLY", "generated_at": "2025-01-01T12:00:00Z", }, + "channel_id": "chan-1", }, } diff --git a/lib/gcpspanner/spanneradapters/push_delivery.go b/lib/gcpspanner/spanneradapters/push_delivery.go index b10b4c498..54bb8545b 100644 --- a/lib/gcpspanner/spanneradapters/push_delivery.go +++ b/lib/gcpspanner/spanneradapters/push_delivery.go @@ -59,6 +59,7 @@ func (f *PushDeliverySubscriberFinder) FindSubscribers(ctx context.Context, sear UserID: dest.UserID, Triggers: convertSpannerTriggersToJobTriggers(dest.Triggers), EmailAddress: dest.EmailConfig.Address, + ChannelID: dest.ChannelID, }) } } diff --git a/lib/gcpspanner/spanneradapters/push_delivery_test.go b/lib/gcpspanner/spanneradapters/push_delivery_test.go index 9b73e13c6..8e4cf46ad 100644 --- a/lib/gcpspanner/spanneradapters/push_delivery_test.go +++ b/lib/gcpspanner/spanneradapters/push_delivery_test.go @@ -94,6 +94,7 @@ func TestFindSubscribers(t *testing.T) { workertypes.FeaturePromotedToNewly, workertypes.FeaturePromotedToWidely, }, + ChannelID: "chan-1", }, }, }, @@ -140,6 +141,7 @@ func TestFindSubscribers(t *testing.T) { Triggers: []workertypes.JobTrigger{ workertypes.BrowserImplementationAnyComplete, }, + ChannelID: "chan-1", }, }, }, @@ -210,6 +212,7 @@ func TestFindSubscribers(t *testing.T) { Triggers: []workertypes.JobTrigger{ "", // Unknown triggers map to empty string/zero value in current implementation }, + ChannelID: "chan-1", }, }, }, diff --git a/lib/workertypes/types.go b/lib/workertypes/types.go index 05d3804f7..fef3c93db 100644 --- a/lib/workertypes/types.go +++ b/lib/workertypes/types.go @@ -603,6 +603,7 @@ type EmailSubscriber struct { UserID string Triggers []JobTrigger EmailAddress string + ChannelID string } // SubscriberSet groups subscribers by channel type to avoid runtime type assertions. @@ -633,6 +634,7 @@ type DispatchEventMetadata struct { type EmailDeliveryJob struct { SubscriptionID string RecipientEmail string + ChannelID string // SummaryRaw is the opaque JSON payload describing the event. SummaryRaw []byte // Metadata contains context for links and tracking. diff --git a/workers/email/go.mod b/workers/email/go.mod index 7c9352e96..050017a66 100644 --- a/workers/email/go.mod +++ b/workers/email/go.mod @@ -6,7 +6,10 @@ replace github.com/GoogleChrome/webstatus.dev/lib => ../../lib replace github.com/GoogleChrome/webstatus.dev/lib/gen => ../../lib/gen -require github.com/GoogleChrome/webstatus.dev/lib v0.0.0-00010101000000-000000000000 +require ( + github.com/GoogleChrome/webstatus.dev/lib v0.0.0-00010101000000-000000000000 + github.com/google/go-cmp v0.7.0 +) require ( cel.dev/expr v0.25.1 // indirect @@ -22,21 +25,33 @@ require ( github.com/GoogleCloudPlatform/grpc-gcp-go/grpcgcp v1.5.3 // indirect github.com/GoogleCloudPlatform/opentelemetry-operations-go/detectors/gcp v1.30.0 // indirect github.com/antlr4-go/antlr/v4 v4.13.1 // indirect + github.com/apapsch/go-jsonmerge/v2 v2.0.0 // indirect github.com/cespare/xxhash/v2 v2.3.0 // indirect github.com/cncf/xds/go v0.0.0-20251110193048-8bfbf64dc13e // indirect github.com/envoyproxy/go-control-plane/envoy v1.36.0 // indirect github.com/envoyproxy/protoc-gen-validate v1.2.1 // indirect github.com/felixge/httpsnoop v1.0.4 // indirect + github.com/getkin/kin-openapi v0.133.0 // indirect github.com/go-jose/go-jose/v4 v4.1.3 // indirect github.com/go-logr/logr v1.4.3 // indirect github.com/go-logr/stdr v1.2.2 // indirect + github.com/go-openapi/jsonpointer v0.22.3 // indirect + github.com/go-openapi/swag/jsonname v0.25.3 // indirect github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 // indirect github.com/google/s2a-go v0.1.9 // indirect github.com/google/uuid v1.6.0 // indirect github.com/googleapis/enterprise-certificate-proxy v0.3.7 // indirect github.com/googleapis/gax-go/v2 v2.15.0 // indirect + github.com/josharian/intern v1.0.0 // indirect + github.com/mailru/easyjson v0.9.1 // indirect + github.com/mohae/deepcopy v0.0.0-20170929034955-c48cc78d4826 // indirect + github.com/oapi-codegen/runtime v1.1.2 // indirect + github.com/oasdiff/yaml v0.0.0-20250309154309-f31be36b4037 // indirect + github.com/oasdiff/yaml3 v0.0.0-20250309153720-d2182401db90 // indirect + github.com/perimeterx/marshmallow v1.1.5 // indirect github.com/planetscale/vtprotobuf v0.6.1-0.20240319094008-0393e58bdf10 // indirect github.com/spiffe/go-spiffe/v2 v2.6.0 // indirect + github.com/woodsbury/decimal128 v1.4.0 // indirect go.opencensus.io v0.24.0 // indirect go.opentelemetry.io/auto/sdk v1.2.1 // indirect go.opentelemetry.io/contrib/detectors/gcp v1.38.0 // indirect diff --git a/workers/email/go.sum b/workers/email/go.sum index af0a25e77..7f043218c 100644 --- a/workers/email/go.sum +++ b/workers/email/go.sum @@ -637,6 +637,7 @@ github.com/JohnCGriffin/overflow v0.0.0-20211019200055-46fa312c352c/go.mod h1:X0 github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY= github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU= github.com/OneOfOne/xxhash v1.2.2/go.mod h1:HSdplMjZKSmBqAxg5vPj2TmRDmfkzw+cTzAElWljhcU= +github.com/RaveNoX/go-jsoncommentstrip v1.0.0/go.mod h1:78ihd09MekBnJnxpICcwzCMzGrKSKYe4AqU6PDYYpjk= github.com/ajstarks/deck v0.0.0-20200831202436-30c9fc6549a9/go.mod h1:JynElWSGnm/4RlzPXRlREEwqTHAN3T56Bv2ITsFT3gY= github.com/ajstarks/deck/generate v0.0.0-20210309230005-c3f852c02e19/go.mod h1:T13YZdzov6OU0A1+RfKZiZN9ca6VeKdBdyDV+BY97Tk= github.com/ajstarks/svgo v0.0.0-20180226025133-644b8db467af/go.mod h1:K08gAheRH3/J6wwsYMMT4xOr94bZjxIelGM0+d/wbFw= @@ -648,6 +649,9 @@ github.com/antlr4-go/antlr/v4 v4.13.1/go.mod h1:GKmUxMtwp6ZgGwZSva4eWPC5mS6vUAmO github.com/apache/arrow/go/v10 v10.0.1/go.mod h1:YvhnlEePVnBS4+0z3fhPfUy7W1Ikj0Ih0vcRo/gZ1M0= github.com/apache/arrow/go/v11 v11.0.0/go.mod h1:Eg5OsL5H+e299f7u5ssuXsuHQVEGC4xei5aX110hRiI= github.com/apache/thrift v0.16.0/go.mod h1:PHK3hniurgQaNMZYaCLEqXKsYK8upmhPbmdP2FXSqgU= +github.com/apapsch/go-jsonmerge/v2 v2.0.0 h1:axGnT1gRIfimI7gJifB699GoE/oq+F2MU7Dml6nw9rQ= +github.com/apapsch/go-jsonmerge/v2 v2.0.0/go.mod h1:lvDnEdqiQrp0O42VQGgmlKpxL1AP2+08jFMw88y4klk= +github.com/bmatcuk/doublestar v1.1.1/go.mod h1:UD6OnuiIn0yFxxA2le/rnRU1G4RaI4UvFv1sNto9p6w= github.com/boombuler/barcode v1.0.0/go.mod h1:paBWMcWSl3LHKBqUq+rly7CNSldXjb2rDl3JlRe0mD8= github.com/boombuler/barcode v1.0.1/go.mod h1:paBWMcWSl3LHKBqUq+rly7CNSldXjb2rDl3JlRe0mD8= github.com/cenkalti/backoff/v4 v4.3.0 h1:MyRJ/UdXutAwSAT+s3wNd7MfTIcy71VQueUuFK343L8= @@ -735,6 +739,8 @@ github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2 github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U= github.com/fogleman/gg v1.2.1-0.20190220221249-0403632d5b90/go.mod h1:R/bRT+9gY/C5z7JzPU0zXsXHKM4/ayA+zqcVNZzPa1k= github.com/fogleman/gg v1.3.0/go.mod h1:R/bRT+9gY/C5z7JzPU0zXsXHKM4/ayA+zqcVNZzPa1k= +github.com/getkin/kin-openapi v0.133.0 h1:pJdmNohVIJ97r4AUFtEXRXwESr8b0bD721u/Tz6k8PQ= +github.com/getkin/kin-openapi v0.133.0/go.mod h1:boAciF6cXk5FhPqe/NQeBTeenbjqU4LhWBf09ILVvWE= github.com/ghodss/yaml v1.0.0/go.mod h1:4dBDuWmgqj2HViK6kFavaiC9ZROes6MMH2rRYeMEF04= github.com/go-fonts/dejavu v0.1.0/go.mod h1:4Wt4I4OU2Nq9asgDCteaAaWZOV24E+0/Pwo0gppep4g= github.com/go-fonts/latin-modern v0.2.0/go.mod h1:rQVLdDMK+mK1xscDwsqM5J8U2jrRa3T0ecnM9pNujks= @@ -755,8 +761,16 @@ github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= github.com/go-ole/go-ole v1.2.6 h1:/Fpf6oFPoeFik9ty7siob0G6Ke8QvQEuVcuChpwXzpY= github.com/go-ole/go-ole v1.2.6/go.mod h1:pprOEPIfldk/42T2oK7lQ4v4JSDwmV0As9GaiUsvbm0= +github.com/go-openapi/jsonpointer v0.22.3 h1:dKMwfV4fmt6Ah90zloTbUKWMD+0he+12XYAsPotrkn8= +github.com/go-openapi/jsonpointer v0.22.3/go.mod h1:0lBbqeRsQ5lIanv3LHZBrmRGHLHcQoOXQnf88fHlGWo= +github.com/go-openapi/swag/jsonname v0.25.3 h1:U20VKDS74HiPaLV7UZkztpyVOw3JNVsit+w+gTXRj0A= +github.com/go-openapi/swag/jsonname v0.25.3/go.mod h1:GPVEk9CWVhNvWhZgrnvRA6utbAltopbKwDu8mXNUMag= +github.com/go-openapi/testify/v2 v2.0.2 h1:X999g3jeLcoY8qctY/c/Z8iBHTbwLz7R2WXd6Ub6wls= +github.com/go-openapi/testify/v2 v2.0.2/go.mod h1:HCPmvFFnheKK2BuwSA0TbbdxJ3I16pjwMkYkP4Ywn54= github.com/go-pdf/fpdf v0.5.0/go.mod h1:HzcnA+A23uwogo0tp9yU+l3V+KXhiESpt1PMayhOh5M= github.com/go-pdf/fpdf v0.6.0/go.mod h1:HzcnA+A23uwogo0tp9yU+l3V+KXhiESpt1PMayhOh5M= +github.com/go-test/deep v1.0.8 h1:TDsG77qcSprGbC6vTN8OuXp5g+J+b5Pcguhf7Zt61VM= +github.com/go-test/deep v1.0.8/go.mod h1:5C2ZWiW0ErCdrYzpqxLbTX7MG14M9iiw8DgHncVwcsE= github.com/goccy/go-json v0.9.11/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I= github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q= github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q= @@ -891,8 +905,11 @@ github.com/hashicorp/golang-lru v0.5.1/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ github.com/iancoleman/strcase v0.2.0/go.mod h1:iwCmte+B7n89clKwxIoIXy/HfoL7AsD47ZCWhYzw7ho= github.com/ianlancetaylor/demangle v0.0.0-20181102032728-5e5cf60278f6/go.mod h1:aSSvb/t6k1mPoxDqO4vJh6VOCGPwU4O0C2/Eqndh1Sc= github.com/ianlancetaylor/demangle v0.0.0-20200824232613-28f6c0f3b639/go.mod h1:aSSvb/t6k1mPoxDqO4vJh6VOCGPwU4O0C2/Eqndh1Sc= +github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8HmY= +github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y= github.com/jstemmer/go-junit-report v0.0.0-20190106144839-af01ea7f8024/go.mod h1:6v2b51hI/fHJwM22ozAgKL4VKDeJcHhJFhtBdhmNjmU= github.com/jstemmer/go-junit-report v0.9.1/go.mod h1:Brl9GWCQeLvo8nXZwPNNblvFj/XSXhF0NWZEnDohbsk= +github.com/juju/gnuflag v0.0.0-20171113085948-2ce1bb71843d/go.mod h1:2PavIy+JPciBPrBUjwbNvtwB6RQlve+hkpll6QSNmOE= github.com/jung-kurt/gofpdf v1.0.0/go.mod h1:7Id9E/uU8ce6rXgefFLlgrJj/GYY22cpxn+r32jIOes= github.com/jung-kurt/gofpdf v1.0.3-0.20190309125859-24315acbbda5/go.mod h1:7Id9E/uU8ce6rXgefFLlgrJj/GYY22cpxn+r32jIOes= github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51/go.mod h1:CzGEWj7cYgsdH8dAjBGEr58BoE7ScuLd+fwFZ44+/x8= @@ -906,8 +923,11 @@ github.com/kr/fs v0.1.0/go.mod h1:FFnZGqtBN9Gxj7eW1uZ42v5BccTP0vu6NEaFoC2HwRg= github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI= github.com/kr/pretty v0.3.0/go.mod h1:640gp4NfQd8pI5XOwp5fnNeVWj67G7CFk/SaSQn7NBk= +github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= +github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= +github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0 h1:6E+4a0GO5zZEnZ81pIr0yLvtUWk2if982qA3F3QD6H4= github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0/go.mod h1:zJYVVT2jmtg6P3p1VtQj7WsuWi/y4VnjVBn7F8KPB3I= @@ -916,6 +936,8 @@ github.com/lyft/protoc-gen-star v0.6.1/go.mod h1:TGAoBVkt8w7MPG72TrKIu85MIdXwDuz github.com/lyft/protoc-gen-star/v2 v2.0.1/go.mod h1:RcCdONR2ScXaYnQC5tUzxzlpA3WVYF7/opLeUgcQs/o= github.com/magiconair/properties v1.8.10 h1:s31yESBquKXCV9a/ScB3ESkOjUYYv+X0rg8SYxI99mE= github.com/magiconair/properties v1.8.10/go.mod h1:Dhd985XPs7jluiymwWYZ0G4Z61jb3vdS329zhj2hYo0= +github.com/mailru/easyjson v0.9.1 h1:LbtsOm5WAswyWbvTEOqhypdPeZzHavpZx96/n553mR8= +github.com/mailru/easyjson v0.9.1/go.mod h1:1+xMtQp2MRNVL/V1bOzuP3aP8VNwRW55fQUto+XFtTU= github.com/mattn/go-isatty v0.0.12/go.mod h1:cbi8OIDigv2wuxKPP5vlRcQ1OAZbq2CE4Kysco4FUpU= github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= github.com/mattn/go-sqlite3 v1.14.14/go.mod h1:NyWgC/yNuGj7Q9rpYnZvas74GogHl5/Z4A/KQRfk6bU= @@ -935,12 +957,22 @@ github.com/moby/sys/userns v0.1.0 h1:tVLXkFOxVu9A64/yh59slHVv9ahO9UIev4JZusOLG/g github.com/moby/sys/userns v0.1.0/go.mod h1:IHUYgu/kao6N8YZlp9Cf444ySSvCmDlmzUcYfDHOl28= github.com/moby/term v0.5.0 h1:xt8Q1nalod/v7BqbG21f8mQPqH+xAaC9C3N3wfWbVP0= github.com/moby/term v0.5.0/go.mod h1:8FzsFHVUBGZdbDsJw/ot+X+d5HLUbvklYLJ9uGfcI3Y= +github.com/mohae/deepcopy v0.0.0-20170929034955-c48cc78d4826 h1:RWengNIwukTxcDr9M+97sNutRR1RKhG96O6jWumTTnw= +github.com/mohae/deepcopy v0.0.0-20170929034955-c48cc78d4826/go.mod h1:TaXosZuwdSHYgviHp1DAtfrULt5eUgsSMsZf+YrPgl8= github.com/morikuni/aec v1.0.0 h1:nP9CBfwrvYnBRgY6qfDQkygYDmYwOilePFkwzv4dU8A= github.com/morikuni/aec v1.0.0/go.mod h1:BbKIizmSmc5MMPqRYbxO4ZU0S0+P200+tUnFx7PXmsc= +github.com/oapi-codegen/runtime v1.1.2 h1:P2+CubHq8fO4Q6fV1tqDBZHCwpVpvPg7oKiYzQgXIyI= +github.com/oapi-codegen/runtime v1.1.2/go.mod h1:SK9X900oXmPWilYR5/WKPzt3Kqxn/uS/+lbpREv+eCg= +github.com/oasdiff/yaml v0.0.0-20250309154309-f31be36b4037 h1:G7ERwszslrBzRxj//JalHPu/3yz+De2J+4aLtSRlHiY= +github.com/oasdiff/yaml v0.0.0-20250309154309-f31be36b4037/go.mod h1:2bpvgLBZEtENV5scfDFEtB/5+1M4hkQhDQrccEJ/qGw= +github.com/oasdiff/yaml3 v0.0.0-20250309153720-d2182401db90 h1:bQx3WeLcUWy+RletIKwUIt4x3t8n2SxavmoclizMb8c= +github.com/oasdiff/yaml3 v0.0.0-20250309153720-d2182401db90/go.mod h1:y5+oSEHCPT/DGrS++Wc/479ERge0zTFxaF8PbGKcg2o= github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U= github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM= github.com/opencontainers/image-spec v1.1.1 h1:y0fUlFfIZhPF1W537XOLg0/fcx6zcHCJwooC2xJA040= github.com/opencontainers/image-spec v1.1.1/go.mod h1:qpqAh3Dmcf36wStyyWU+kCeDgrGnAve2nCC8+7h8Q0M= +github.com/perimeterx/marshmallow v1.1.5 h1:a2LALqQ1BlHM8PZblsDdidgv1mWi1DgC2UmX50IvK2s= +github.com/perimeterx/marshmallow v1.1.5/go.mod h1:dsXbUu8CRzfYP5a87xpp0xq9S3u0Vchtcl8we9tYaXw= github.com/phpdave11/gofpdf v1.4.2/go.mod h1:zpO6xFn9yxo3YLyMvW8HcKWVdbNqgIfOOp2dXMnm1mY= github.com/phpdave11/gofpdi v1.0.12/go.mod h1:vBmVV0Do6hSBHC8uKUQ71JGW+ZGQq74llk/7bXwjDoI= github.com/phpdave11/gofpdi v1.0.13/go.mod h1:vBmVV0Do6hSBHC8uKUQ71JGW+ZGQq74llk/7bXwjDoI= @@ -966,6 +998,8 @@ github.com/rogpeppe/fastuuid v1.2.0/go.mod h1:jVj6XXZzXRy/MSR5jhDC/2q6DgLz+nrA6L github.com/rogpeppe/go-internal v1.3.0/go.mod h1:M8bDsm7K2OlrFYOpmOWEs/qY81heoFRclV5y23lUDJ4= github.com/rogpeppe/go-internal v1.6.1/go.mod h1:xXDCJY+GAPziupqXw64V24skbSoqbTEfhy4qGm1nDQc= github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs= +github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= +github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= github.com/ruudk/golang-pdf417 v0.0.0-20181029194003-1af4ab5afa58/go.mod h1:6lfFZQK844Gfx8o5WFuvpxWRwnSoipWe/p622j1v06w= github.com/ruudk/golang-pdf417 v0.0.0-20201230142125-a7e3863a1245/go.mod h1:pQAZKsJ8yyVxGRWYNEm9oFB8ieLgKFnamEyDmSA0BRk= github.com/shirou/gopsutil/v4 v4.25.6 h1:kLysI2JsKorfaFPcYmcJqbzROzsBWEOAtw6A7dIfqXs= @@ -978,10 +1012,12 @@ github.com/spf13/afero v1.6.0/go.mod h1:Ai8FlHk4v/PARR026UzYexafAt9roJ7LcLMAmO6Z github.com/spf13/afero v1.9.2/go.mod h1:iUV7ddyEEZPO5gA3zD4fJt6iStLlL+Lg4m2cihcDf8Y= github.com/spiffe/go-spiffe/v2 v2.6.0 h1:l+DolpxNWYgruGQVV0xsfeya3CsC7m8iBzDnMpsbLuo= github.com/spiffe/go-spiffe/v2 v2.6.0/go.mod h1:gm2SeUoMZEtpnzPNs2Csc0D/gX33k1xIx7lEzqblHEs= +github.com/spkg/bom v0.0.0-20160624110644-59b7046e48ad/go.mod h1:qLr4V1qq6nMqFKkMo8ZTx3f+BZEkzsRUY10Xsm2mwU0= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= +github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= github.com/stretchr/testify v1.5.1/go.mod h1:5W2xD1RspED5o8YsWQXVCued0rvSQ+mT+I5cxcmMvtA= github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= @@ -998,8 +1034,12 @@ github.com/tklauser/go-sysconf v0.3.12 h1:0QaGUFOdQaIVdPgfITYzaTegZvdCjmYO52cSFA github.com/tklauser/go-sysconf v0.3.12/go.mod h1:Ho14jnntGE1fpdOqQEEaiKRpvIavV0hSfmBq8nJbHYI= github.com/tklauser/numcpus v0.6.1 h1:ng9scYS7az0Bk4OZLvrNXNSAO2Pxr1XXRAPyjhIx+Fk= github.com/tklauser/numcpus v0.6.1/go.mod h1:1XfjsgE2zo8GVw7POkMbHENHzVg3GzmoZ9fESEdAacY= +github.com/ugorji/go/codec v1.2.11 h1:BMaWp1Bb6fHwEtbplGBGJ498wD+LKlNSl25MjdZY4dU= +github.com/ugorji/go/codec v1.2.11/go.mod h1:UNopzCgEMSXjBc6AOMqYvWC1ktqTAfzJZUZgYf6w6lg= github.com/web-platform-tests/wpt.fyi v0.0.0-20251118162843-54f805c8a632 h1:9t4b2caqsFRUWK4k9+lM/PkxLTCvo46ZQPVaoHPaCxY= github.com/web-platform-tests/wpt.fyi v0.0.0-20251118162843-54f805c8a632/go.mod h1:YpCvJq5JKA+aa4+jwK1S2uQ7r0horYVl6DHcl/G9S3s= +github.com/woodsbury/decimal128 v1.4.0 h1:xJATj7lLu4f2oObouMt2tgGiElE5gO6mSWUjQsBgUlc= +github.com/woodsbury/decimal128 v1.4.0/go.mod h1:BP46FUrVjVhdTbKT+XuQh2xfQaGki9LMIRJSFuh6THU= github.com/yuin/goldmark v1.1.25/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= github.com/yuin/goldmark v1.1.32/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= @@ -1682,6 +1722,7 @@ google.golang.org/protobuf v1.36.10 h1:AYd7cD/uASjIL6Q9LiTjz8JLcrh/88q5UObnmY3aO google.golang.org/protobuf v1.36.10/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= gopkg.in/errgo.v2 v2.1.0/go.mod h1:hNsd1EY+bozCKY1Ytp96fpM3vjJbqLJn88ws8XvfDNI= gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= diff --git a/workers/email/pkg/sender/sender.go b/workers/email/pkg/sender/sender.go new file mode 100644 index 000000000..940e653ae --- /dev/null +++ b/workers/email/pkg/sender/sender.go @@ -0,0 +1,87 @@ +// 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 sender + +import ( + "context" + "log/slog" + + "github.com/GoogleChrome/webstatus.dev/lib/workertypes" +) + +type EmailSender interface { + Send(ctx context.Context, to string, subject string, htmlBody string) error +} + +type ChannelStateManager interface { + RecordSuccess(ctx context.Context, channelID string) error + RecordFailure(ctx context.Context, channelID string, err error) error +} + +type TemplateRenderer interface { + RenderDigest(job workertypes.EmailDeliveryJob) (string, string, error) +} + +type Sender struct { + sender EmailSender + stateManager ChannelStateManager + renderer TemplateRenderer +} + +func NewSender( + sender EmailSender, + stateManager ChannelStateManager, + renderer TemplateRenderer, +) *Sender { + return &Sender{ + sender: sender, + stateManager: stateManager, + renderer: renderer, + } +} + +func (s *Sender) ProcessMessage(ctx context.Context, job workertypes.EmailDeliveryJob) error { + // 1. Render (Parsing happens inside RenderDigest implementation) + subject, body, err := s.renderer.RenderDigest(job) + if err != nil { + slog.ErrorContext(ctx, "failed to render email", "subscription_id", job.SubscriptionID, "error", err) + if err := s.stateManager.RecordFailure(ctx, job.ChannelID, err); err != nil { + slog.ErrorContext(ctx, "failed to record channel failure", "channel_id", job.ChannelID, "error", err) + } + // Rendering errors might be transient or permanent. Assuming permanent for template bugs. + return nil + } + + // 2. Send + if err := s.sender.Send(ctx, job.RecipientEmail, subject, body); err != nil { + slog.ErrorContext(ctx, "failed to send email", "recipient", job.RecipientEmail, "error", err) + // Record failure in DB + if dbErr := s.stateManager.RecordFailure(ctx, job.ChannelID, err); dbErr != nil { + slog.ErrorContext(ctx, "failed to record channel failure", "channel_id", job.ChannelID, "error", dbErr) + } + + // Return error to NACK the message and retry sending? + // Sending failures (network, rate limit) are often transient. + return err + } + + // 3. Success + if err := s.stateManager.RecordSuccess(ctx, job.ChannelID); err != nil { + // Non-critical error, but good to log + slog.WarnContext(ctx, "failed to record channel success", "channel_id", job.ChannelID, "error", err) + } + + return nil +} diff --git a/workers/email/pkg/sender/sender_test.go b/workers/email/pkg/sender/sender_test.go new file mode 100644 index 000000000..10218f298 --- /dev/null +++ b/workers/email/pkg/sender/sender_test.go @@ -0,0 +1,210 @@ +// 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 sender + +import ( + "context" + "errors" + "testing" + "time" + + "github.com/GoogleChrome/webstatus.dev/lib/workertypes" + "github.com/google/go-cmp/cmp" +) + +// --- Mocks --- + +type mockEmailSender struct { + sentCalls []sentCall + sendErr error +} + +type sentCall struct { + to string + subject string + body string +} + +func (m *mockEmailSender) Send(_ context.Context, to, subject, body string) error { + m.sentCalls = append(m.sentCalls, sentCall{to, subject, body}) + + return m.sendErr +} + +type mockChannelStateManager struct { + successCalls []string // channelIDs + failureCalls []failureCall + recordErr error +} + +type failureCall struct { + channelID string + err error +} + +func (m *mockChannelStateManager) RecordSuccess(_ context.Context, channelID string) error { + m.successCalls = append(m.successCalls, channelID) + + return m.recordErr +} + +func (m *mockChannelStateManager) RecordFailure(_ context.Context, channelID string, err error) error { + m.failureCalls = append(m.failureCalls, failureCall{channelID, err}) + + return m.recordErr +} + +type mockTemplateRenderer struct { + renderSubject string + renderBody string + renderErr error + renderInput workertypes.EmailDeliveryJob +} + +func (m *mockTemplateRenderer) RenderDigest(job workertypes.EmailDeliveryJob) (string, string, error) { + m.renderInput = job + + return m.renderSubject, m.renderBody, m.renderErr +} + +func testGeneratedAt() time.Time { + return time.Date(2025, 1, 1, 12, 0, 0, 0, time.UTC) +} + +const testChannelID = "chan-1" + +func testMetadata() workertypes.DeliveryMetadata { + return workertypes.DeliveryMetadata{ + EventID: "event-1", + SearchID: "search-1", + Query: "query-string", + Frequency: workertypes.FrequencyMonthly, + GeneratedAt: testGeneratedAt(), + } +} + +// --- Tests --- + +func TestProcessMessage_Success(t *testing.T) { + ctx := context.Background() + job := workertypes.EmailDeliveryJob{ + SubscriptionID: "sub-1", + Metadata: testMetadata(), + RecipientEmail: "user@example.com", + SummaryRaw: []byte("{}"), + ChannelID: "chan-1", + } + + sender := new(mockEmailSender) + stateManager := new(mockChannelStateManager) + renderer := new(mockTemplateRenderer) + renderer.renderSubject = "Subject" + renderer.renderBody = "Body" + + h := NewSender(sender, stateManager, renderer) + + err := h.ProcessMessage(ctx, job) + if err != nil { + t.Fatalf("ProcessMessage failed: %v", err) + } + + // Verify Renderer Input + if diff := cmp.Diff(job, renderer.renderInput); diff != "" { + t.Errorf("Renderer input mismatch (-want +got):\n%s", diff) + } + + // Verify Send + if len(sender.sentCalls) != 1 { + t.Fatalf("Expected 1 email sent, got %d", len(sender.sentCalls)) + } + if sender.sentCalls[0].to != "user@example.com" { + t.Errorf("Recipient mismatch: %s", sender.sentCalls[0].to) + } + + // Verify State + if len(stateManager.successCalls) != 1 { + t.Errorf("Expected 1 success record, got %d", len(stateManager.successCalls)) + } + if stateManager.successCalls[0] != testChannelID { + t.Errorf("Success recorded for wrong channel: %s", stateManager.successCalls[0]) + } +} + +func TestProcessMessage_RenderError(t *testing.T) { + ctx := context.Background() + job := workertypes.EmailDeliveryJob{ + SubscriptionID: "sub-1", + Metadata: testMetadata(), + RecipientEmail: "user@example.com", + SummaryRaw: []byte("{}"), + ChannelID: "chan-1", + } + + sender := new(mockEmailSender) + stateManager := new(mockChannelStateManager) + renderer := new(mockTemplateRenderer) + renderer.renderErr = errors.New("template error") + + h := NewSender(sender, stateManager, renderer) + + // Should return nil (ACK) for rendering error (assuming permanent for now) + if err := h.ProcessMessage(ctx, job); err != nil { + t.Errorf("Expected nil error for render failure, got %v", err) + } + + // Should record failure + if len(stateManager.failureCalls) != 1 { + t.Fatal("Expected failure recording") + } + + // Should NOT send + if len(sender.sentCalls) > 0 { + t.Error("Should not send email on render error") + } +} + +func TestProcessMessage_SendError(t *testing.T) { + ctx := context.Background() + job := workertypes.EmailDeliveryJob{ + SubscriptionID: "sub-1", + Metadata: testMetadata(), + RecipientEmail: "user@example.com", + SummaryRaw: []byte("{}"), + ChannelID: "chan-1", + } + + sendErr := errors.New("smtp timeout") + sender := &mockEmailSender{sendErr: sendErr, sentCalls: nil} + stateManager := new(mockChannelStateManager) + renderer := new(mockTemplateRenderer) + renderer.renderSubject = "S" + renderer.renderBody = "B" + + h := NewSender(sender, stateManager, renderer) + + // Should return error (NACK) for send failure to allow retry + err := h.ProcessMessage(ctx, job) + if !errors.Is(err, sendErr) { + t.Errorf("Expected send error to propagate, got %v", err) + } + + // Should record failure in DB as well + if len(stateManager.failureCalls) != 1 { + t.Fatal("Expected failure recording") + } + if stateManager.failureCalls[0].channelID != testChannelID { + t.Errorf("Recorded failure for wrong channel") + } +} diff --git a/workers/push_delivery/pkg/dispatcher/dispatcher.go b/workers/push_delivery/pkg/dispatcher/dispatcher.go index d245cc5fb..aeb00a9ff 100644 --- a/workers/push_delivery/pkg/dispatcher/dispatcher.go +++ b/workers/push_delivery/pkg/dispatcher/dispatcher.go @@ -153,6 +153,7 @@ func (g *deliveryJobGenerator) VisitV1(s workertypes.EventSummary) error { RecipientEmail: sub.EmailAddress, SummaryRaw: g.rawSummary, Metadata: deliveryMetadata, + ChannelID: sub.ChannelID, }) } diff --git a/workers/push_delivery/pkg/dispatcher/dispatcher_test.go b/workers/push_delivery/pkg/dispatcher/dispatcher_test.go index 5f847f052..7d1fe8853 100644 --- a/workers/push_delivery/pkg/dispatcher/dispatcher_test.go +++ b/workers/push_delivery/pkg/dispatcher/dispatcher_test.go @@ -137,6 +137,7 @@ func TestProcessEvent_Success(t *testing.T) { UserID: "user-1", Triggers: []workertypes.JobTrigger{workertypes.FeaturePromotedToNewly}, // Matches EmailAddress: "user1@example.com", + ChannelID: "chan-1", }, { SubscriptionID: "sub-2", @@ -144,6 +145,7 @@ func TestProcessEvent_Success(t *testing.T) { // Does not match (summary is Newly) Triggers: []workertypes.JobTrigger{workertypes.FeaturePromotedToWidely}, EmailAddress: "user2@example.com", + ChannelID: "chan-2", }, }, } @@ -210,6 +212,7 @@ func TestProcessEvent_Success(t *testing.T) { Frequency: frequency, GeneratedAt: generatedAt, }, + ChannelID: "chan-1", } if diff := cmp.Diff(expectedJob, job); diff != "" { @@ -241,6 +244,7 @@ func TestProcessEvent_NoChanges_FiltersAll(t *testing.T) { UserID: "user-1", Triggers: []workertypes.JobTrigger{"any_change"}, EmailAddress: "user1@example.com", + ChannelID: "chan-1", }, }, } @@ -323,9 +327,9 @@ func TestProcessEvent_PublisherPartialFailure(t *testing.T) { subSet := &workertypes.SubscriberSet{ Emails: []workertypes.EmailSubscriber{ {SubscriptionID: "sub-1", Triggers: []workertypes.JobTrigger{workertypes.FeaturePromotedToNewly}, - UserID: "u1", EmailAddress: "e1"}, + UserID: "u1", EmailAddress: "e1", ChannelID: "chan-1"}, {SubscriptionID: "sub-2", Triggers: []workertypes.JobTrigger{workertypes.FeaturePromotedToNewly}, - UserID: "u2", EmailAddress: "e2"}, + UserID: "u2", EmailAddress: "e2", ChannelID: "chan-2"}, }, } @@ -371,6 +375,9 @@ func TestProcessEvent_PublisherPartialFailure(t *testing.T) { if publisher.emailJobs[0].SubscriptionID != "sub-2" { t.Errorf("Expected sub-2 to succeed, got %s", publisher.emailJobs[0].SubscriptionID) } + if publisher.emailJobs[0].ChannelID != "chan-2" { + t.Errorf("Expected chan-2 to succeed, got %s", publisher.emailJobs[0].ChannelID) + } assertFindSubscribersCalledWith(t, finder, generic.ValuePtr(emptyFinderReq())) } @@ -378,7 +385,8 @@ func TestProcessEvent_JobCount(t *testing.T) { // Verify that if no jobs are generated (e.g. no matching triggers), ProcessEvent returns early/cleanly. subSet := &workertypes.SubscriberSet{ Emails: []workertypes.EmailSubscriber{ - {SubscriptionID: "sub-1", Triggers: []workertypes.JobTrigger{}, EmailAddress: "e1", UserID: "u1"}, // No match + {SubscriptionID: "sub-1", Triggers: []workertypes.JobTrigger{}, EmailAddress: "e1", UserID: "u1", + ChannelID: "chan-1"}, // No match }, } finder := &mockSubscriptionFinder{ From 404e2d0790419848ddc2b10e01a235a2df0ea61c Mon Sep 17 00:00:00 2001 From: James Scott Date: Mon, 29 Dec 2025 19:32:14 +0000 Subject: [PATCH 14/27] feat(spanner): implement channel state and delivery attempt logging Implements the data layer for tracking notification channel health and logging delivery attempts, which is critical for the Email Worker's reliability logic. Changes: - **State Management**: Added `RecordNotificationChannelSuccess` and `RecordNotificationChannelFailure` methods. - `Success`: Resets failure count and logs a success attempt. - `Failure`: Increments failure count (if permanent), checks disable threshold (5 failures), and logs a failure attempt. Supports transient errors (no penalty). - **Transactions**: State updates and attempt logging are performed atomically within a single read-write transaction. Other changes: Added some more tests for the list functionality. --- lib/gcpspanner/client.go | 11 ++ .../notification_channel_delivery_attempt.go | 170 ++++++++++++------ ...ification_channel_delivery_attempt_test.go | 139 +++++++++++++- lib/gcpspanner/notification_channel_state.go | 103 +++++++++++ .../notification_channel_state_test.go | 148 +++++++++++++++ 5 files changed, 516 insertions(+), 55 deletions(-) diff --git a/lib/gcpspanner/client.go b/lib/gcpspanner/client.go index f6cdd12e5..045d372e4 100644 --- a/lib/gcpspanner/client.go +++ b/lib/gcpspanner/client.go @@ -81,6 +81,7 @@ type Client struct { featureSearchQuery FeatureSearchBaseQuery missingOneImplQuery MissingOneImplementationQuery searchCfg searchConfig + notificationCfg notificationConfig batchWriter batchSize int batchWriters int @@ -140,10 +141,17 @@ type searchConfig struct { maxBookmarksPerUser uint32 } +// notificationConfig holds the application configuation for notifications. +type notificationConfig struct { + // Max number of consecutive failures per channel + maxConsecutiveFailuresPerChannel uint32 +} + const defaultMaxOwnedSearchesPerUser = 25 const defaultMaxBookmarksPerUser = 25 const defaultBatchSize = 5000 const defaultBatchWriters = 8 +const defaultMaxConsecutiveFailuresPerChannel = 5 func combineAndDeduplicate(excluded []string, discouraged []string) []string { if excluded == nil && discouraged == nil { @@ -219,6 +227,9 @@ func NewSpannerClient(projectID string, instanceID string, name string) (*Client maxOwnedSearchesPerUser: defaultMaxOwnedSearchesPerUser, maxBookmarksPerUser: defaultMaxBookmarksPerUser, }, + notificationConfig{ + maxConsecutiveFailuresPerChannel: defaultMaxConsecutiveFailuresPerChannel, + }, bw, defaultBatchSize, defaultBatchWriters, diff --git a/lib/gcpspanner/notification_channel_delivery_attempt.go b/lib/gcpspanner/notification_channel_delivery_attempt.go index 4432a0e40..79484e49a 100644 --- a/lib/gcpspanner/notification_channel_delivery_attempt.go +++ b/lib/gcpspanner/notification_channel_delivery_attempt.go @@ -16,6 +16,7 @@ package gcpspanner import ( "context" + "encoding/json" "fmt" "time" @@ -25,13 +26,45 @@ import ( const notificationChannelDeliveryAttemptTable = "NotificationChannelDeliveryAttempts" const maxDeliveryAttemptsToKeep = 10 -// NotificationChannelDeliveryAttempt represents a row in the NotificationChannelDeliveryAttempt table. -type NotificationChannelDeliveryAttempt struct { +// spannerNotificationChannelDeliveryAttempt represents a row in the spannerNotificationChannelDeliveryAttempt table. +type spannerNotificationChannelDeliveryAttempt struct { ID string `spanner:"ID"` ChannelID string `spanner:"ChannelID"` AttemptTimestamp time.Time `spanner:"AttemptTimestamp"` Status NotificationChannelDeliveryAttemptStatus `spanner:"Status"` Details spanner.NullJSON `spanner:"Details"` + AttemptDetails *AttemptDetails `spanner:"-"` +} + +func (s spannerNotificationChannelDeliveryAttempt) toPublic() (*NotificationChannelDeliveryAttempt, error) { + var attemptDetails *AttemptDetails + if s.Details.Valid { + attemptDetails = new(AttemptDetails) + b, err := json.Marshal(s.Details.Value) + if err != nil { + return nil, err + } + err = json.Unmarshal(b, &attemptDetails) + if err != nil { + return nil, err + } + } + + return &NotificationChannelDeliveryAttempt{ + ID: s.ID, + ChannelID: s.ChannelID, + AttemptTimestamp: s.AttemptTimestamp, + Status: s.Status, + AttemptDetails: attemptDetails, + }, nil +} + +type NotificationChannelDeliveryAttempt struct { + ID string `spanner:"ID"` + ChannelID string `spanner:"ChannelID"` + AttemptTimestamp time.Time `spanner:"AttemptTimestamp"` + Status NotificationChannelDeliveryAttemptStatus `spanner:"Status"` + AttemptDetails *AttemptDetails `spanner:"AttemptDetails"` } type NotificationChannelDeliveryAttemptStatus string @@ -71,13 +104,14 @@ func (m notificationChannelDeliveryAttemptMapper) Table() string { func (m notificationChannelDeliveryAttemptMapper) NewEntity( id string, - req CreateNotificationChannelDeliveryAttemptRequest) (NotificationChannelDeliveryAttempt, error) { - return NotificationChannelDeliveryAttempt{ + req CreateNotificationChannelDeliveryAttemptRequest) (spannerNotificationChannelDeliveryAttempt, error) { + return spannerNotificationChannelDeliveryAttempt{ ID: id, ChannelID: req.ChannelID, AttemptTimestamp: req.AttemptTimestamp, Status: req.Status, Details: req.Details, + AttemptDetails: nil, }, nil } @@ -132,7 +166,8 @@ type notificationChannelDeliveryAttemptCursor struct { } // EncodePageToken returns the ID of the delivery attempt as a page token. -func (m notificationChannelDeliveryAttemptMapper) EncodePageToken(item NotificationChannelDeliveryAttempt) string { +func (m notificationChannelDeliveryAttemptMapper) EncodePageToken( + item spannerNotificationChannelDeliveryAttempt) string { return encodeCursor(notificationChannelDeliveryAttemptCursor{ LastID: item.ID, LastAttemptTimestamp: item.AttemptTimestamp, @@ -144,61 +179,65 @@ func (c *Client) CreateNotificationChannelDeliveryAttempt( ctx context.Context, req CreateNotificationChannelDeliveryAttemptRequest) (*string, error) { var newID *string _, err := c.ReadWriteTransaction(ctx, func(ctx context.Context, txn *spanner.ReadWriteTransaction) error { - // 1. Create the new attempt - id, err := newEntityCreator[notificationChannelDeliveryAttemptMapper](c).createWithTransaction(ctx, txn, req) - if err != nil { - return err - } - newID = id + var err error + newID, err = c.createNotificationChannelDeliveryAttemptWithTransaction(ctx, txn, req) + + return err + }) + + return newID, err +} +func (c *Client) createNotificationChannelDeliveryAttemptWithTransaction( + ctx context.Context, txn *spanner.ReadWriteTransaction, + req CreateNotificationChannelDeliveryAttemptRequest) (*string, error) { + var newID *string + // 1. Create the new attempt + id, err := newEntityCreator[notificationChannelDeliveryAttemptMapper](c).createWithTransaction(ctx, txn, req) + if err != nil { + return nil, err + } + newID = id - // 2. Count existing attempts for the channel. Note: This count does not include the new attempt just buffered. - countStmt := spanner.NewStatement(` + // 2. Count existing attempts for the channel. Note: This count does not include the new attempt just buffered. + countStmt := spanner.NewStatement(` SELECT COUNT(*) FROM NotificationChannelDeliveryAttempts WHERE ChannelID = @channelID`) - countStmt.Params["channelID"] = req.ChannelID - var count int64 - err = txn.Query(ctx, countStmt).Do(func(r *spanner.Row) error { - return r.Column(0, &count) - }) - if err != nil { - return err - } + countStmt.Params["channelID"] = req.ChannelID + var count int64 + err = txn.Query(ctx, countStmt).Do(func(r *spanner.Row) error { + return r.Column(0, &count) + }) + if err != nil { + return nil, err + } - // 3. If the pre-insert count is at the limit, fetch the oldest attempts to delete. - if count >= maxDeliveryAttemptsToKeep { - // We need to delete enough to make room for the one we are adding. - deleteCount := count - maxDeliveryAttemptsToKeep + 1 - deleteStmt := spanner.NewStatement(` + // 3. If the pre-insert count is at the limit, fetch the oldest attempts to delete. + // We need to delete enough to make room for the one we are adding. + + if count < maxDeliveryAttemptsToKeep { + return newID, nil + } + + deleteCount := count - maxDeliveryAttemptsToKeep + 1 + deleteStmt := spanner.NewStatement(` SELECT ID FROM NotificationChannelDeliveryAttempts WHERE ChannelID = @channelID ORDER BY AttemptTimestamp ASC LIMIT @deleteCount`) - deleteStmt.Params["channelID"] = req.ChannelID - deleteStmt.Params["deleteCount"] = deleteCount - - var mutations []*spanner.Mutation - err := txn.Query(ctx, deleteStmt).Do(func(r *spanner.Row) error { - var attemptID string - if err := r.Column(0, &attemptID); err != nil { - return err - } - mutations = append(mutations, - spanner.Delete(notificationChannelDeliveryAttemptTable, - spanner.Key{attemptID, req.ChannelID})) - - return nil - }) - if err != nil { - return err - } - - // 4. Buffer delete mutations - if len(mutations) > 0 { - return txn.BufferWrite(mutations) - } + deleteStmt.Params["channelID"] = req.ChannelID + deleteStmt.Params["deleteCount"] = deleteCount + + var mutations []*spanner.Mutation + err = txn.Query(ctx, deleteStmt).Do(func(r *spanner.Row) error { + var attemptID string + if err := r.Column(0, &attemptID); err != nil { + return err } + mutations = append(mutations, + spanner.Delete(notificationChannelDeliveryAttemptTable, + spanner.Key{attemptID, req.ChannelID})) return nil }) @@ -206,6 +245,14 @@ func (c *Client) CreateNotificationChannelDeliveryAttempt( return nil, err } + // 4. Buffer delete mutations + if len(mutations) > 0 { + err := txn.BufferWrite(mutations) + if err != nil { + return nil, err + } + } + return newID, nil } @@ -214,8 +261,13 @@ func (c *Client) GetNotificationChannelDeliveryAttempt( ctx context.Context, attemptID string, channelID string) (*NotificationChannelDeliveryAttempt, error) { key := deliveryAttemptKey{ID: attemptID, ChannelID: channelID} - return newEntityReader[notificationChannelDeliveryAttemptMapper, - NotificationChannelDeliveryAttempt, deliveryAttemptKey](c).readRowByKey(ctx, key) + attempt, err := newEntityReader[notificationChannelDeliveryAttemptMapper, + spannerNotificationChannelDeliveryAttempt, deliveryAttemptKey](c).readRowByKey(ctx, key) + if err != nil { + return nil, err + } + + return attempt.toPublic() } // ListNotificationChannelDeliveryAttempts lists all delivery attempts for a channel. @@ -223,5 +275,19 @@ func (c *Client) ListNotificationChannelDeliveryAttempts( ctx context.Context, req ListNotificationChannelDeliveryAttemptsRequest, ) ([]NotificationChannelDeliveryAttempt, *string, error) { - return newEntityLister[notificationChannelDeliveryAttemptMapper](c).list(ctx, req) + attempts, nextPageToken, err := newEntityLister[notificationChannelDeliveryAttemptMapper](c).list(ctx, req) + if err != nil { + return nil, nil, err + } + + publicAttempts := make([]NotificationChannelDeliveryAttempt, 0, len(attempts)) + for _, attempt := range attempts { + publicAttempt, err := attempt.toPublic() + if err != nil { + return nil, nil, err + } + publicAttempts = append(publicAttempts, *publicAttempt) + } + + return publicAttempts, nextPageToken, nil } diff --git a/lib/gcpspanner/notification_channel_delivery_attempt_test.go b/lib/gcpspanner/notification_channel_delivery_attempt_test.go index a536391a8..e34520e6a 100644 --- a/lib/gcpspanner/notification_channel_delivery_attempt_test.go +++ b/lib/gcpspanner/notification_channel_delivery_attempt_test.go @@ -43,9 +43,10 @@ func TestCreateNotificationChannelDeliveryAttempt(t *testing.T) { channelID := *channelIDPtr req := CreateNotificationChannelDeliveryAttemptRequest{ - ChannelID: channelID, - Status: "SUCCESS", - Details: spanner.NullJSON{Value: map[string]interface{}{"info": "delivered"}, Valid: true}, + ChannelID: channelID, + Status: "SUCCESS", + Details: spanner.NullJSON{Value: map[string]interface{}{ + "event_id": "evt-123", "message": "delivered"}, Valid: true}, AttemptTimestamp: time.Now(), } @@ -71,6 +72,15 @@ func TestCreateNotificationChannelDeliveryAttempt(t *testing.T) { if retrieved.AttemptTimestamp.IsZero() { t.Error("expected a non-zero commit timestamp") } + if retrieved.AttemptDetails == nil { + t.Fatal("expected details to be non-nil") + } + if retrieved.AttemptDetails.Message != "delivered" { + t.Errorf("expected details info to be 'delivered', got %s", retrieved.AttemptDetails.Message) + } + if retrieved.AttemptDetails.EventID != "evt-123" { + t.Errorf("expected details eventID to be 'evt-123', got %s", retrieved.AttemptDetails.EventID) + } } func TestCreateNotificationChannelDeliveryAttemptPruning(t *testing.T) { @@ -202,3 +212,126 @@ func TestCreateNotificationChannelDeliveryAttemptConcurrency(t *testing.T) { t.Errorf("expected %d attempts, got %d", maxDeliveryAttemptsToKeep, len(attempts)) } } + +func TestListNotificationChannelDeliveryAttemptsPagination(t *testing.T) { + ctx := context.Background() + restartDatabaseContainer(t) + // We need a channel to associate the attempt with. + userID := uuid.NewString() + createReq := CreateNotificationChannelRequest{ + UserID: userID, + Name: "Test Channel", + Type: "EMAIL", + EmailConfig: &EmailConfig{Address: "test@example.com", IsVerified: true, VerificationToken: nil}, + } + channelIDPtr, err := spannerClient.CreateNotificationChannel(ctx, createReq) + if err != nil { + t.Fatalf("failed to create notification channel: %v", err) + } + channelID := *channelIDPtr + + // Create more attempts than the page size to test pagination. + totalAttempts := 5 + for i := 0; i < totalAttempts; i++ { + // The sleep is a simple way to ensure distinct AttemptTimestamps for ordering. + time.Sleep(1 * time.Millisecond) + req := CreateNotificationChannelDeliveryAttemptRequest{ + ChannelID: channelID, + Status: "SUCCESS", + Details: spanner.NullJSON{Value: nil, Valid: false}, + AttemptTimestamp: time.Now(), + } + _, err := spannerClient.CreateNotificationChannelDeliveryAttempt(ctx, req) + if err != nil { + t.Fatalf("CreateNotificationChannelDeliveryAttempt (pagination test) failed: %v", err) + } + } + + // 1. First Page + pageSize := 2 + listReq1 := ListNotificationChannelDeliveryAttemptsRequest{ + ChannelID: channelID, + PageSize: pageSize, + PageToken: nil, + } + attempts1, nextToken1, err := spannerClient.ListNotificationChannelDeliveryAttempts(ctx, listReq1) + if err != nil { + t.Fatalf("ListNotificationChannelDeliveryAttempts (page 1) failed: %v", err) + } + if len(attempts1) != pageSize { + t.Errorf("expected %d attempts on page 1, got %d", pageSize, len(attempts1)) + } + if nextToken1 == nil { + t.Fatal("expected a next page token, but got nil") + } + + // 2. Second Page + listReq2 := ListNotificationChannelDeliveryAttemptsRequest{ + ChannelID: channelID, + PageSize: pageSize, + PageToken: nextToken1, + } + attempts2, nextToken2, err := spannerClient.ListNotificationChannelDeliveryAttempts(ctx, listReq2) + if err != nil { + t.Fatalf("ListNotificationChannelDeliveryAttempts (page 2) failed: %v", err) + } + if len(attempts2) != pageSize { + t.Errorf("expected %d attempts on page 2, got %d", pageSize, len(attempts2)) + } + if nextToken2 == nil { + t.Fatal("expected a next page token, but got nil") + } + + // 3. Third and Final Page + listReq3 := ListNotificationChannelDeliveryAttemptsRequest{ + ChannelID: channelID, + PageSize: pageSize, + PageToken: nextToken2, + } + attempts3, nextToken3, err := spannerClient.ListNotificationChannelDeliveryAttempts(ctx, listReq3) + if err != nil { + t.Fatalf("ListNotificationChannelDeliveryAttempts (page 3) failed: %v", err) + } + if len(attempts3) != 1 { + t.Errorf("expected 1 attempt on page 3, got %d", len(attempts3)) + } + if nextToken3 != nil { + t.Errorf("expected no next page token, but got one: %s", *nextToken3) + } +} + +func TestToPublic(t *testing.T) { + attempt := spannerNotificationChannelDeliveryAttempt{ + ID: "test-id", + ChannelID: "test-channel-id", + AttemptTimestamp: time.Now(), + Status: DeliveryAttemptStatusSuccess, + Details: spanner.NullJSON{Value: map[string]interface{}{"message": "test-info", + "event_id": "test-event-id"}, Valid: true}, + AttemptDetails: nil, + } + + publicAttempt, err := attempt.toPublic() + if err != nil { + t.Fatalf("toPublic() failed: %v", err) + } + + if publicAttempt.ID != attempt.ID { + t.Errorf("expected ID %s, got %s", attempt.ID, publicAttempt.ID) + } + if publicAttempt.ChannelID != attempt.ChannelID { + t.Errorf("expected ChannelID %s, got %s", attempt.ChannelID, publicAttempt.ChannelID) + } + if publicAttempt.Status != attempt.Status { + t.Errorf("expected Status %s, got %s", attempt.Status, publicAttempt.Status) + } + if publicAttempt.AttemptDetails == nil { + t.Fatal("expected AttemptDetails to be non-nil") + } + if publicAttempt.AttemptDetails.Message != "test-info" { + t.Errorf("expected AttemptDetails.Message %s, got %s", "test-info", publicAttempt.AttemptDetails.Message) + } + if publicAttempt.AttemptDetails.EventID != "test-event-id" { + t.Errorf("expected AttemptDetails.EventID %s, got %s", "test-event-id", publicAttempt.AttemptDetails.EventID) + } +} diff --git a/lib/gcpspanner/notification_channel_state.go b/lib/gcpspanner/notification_channel_state.go index 087af22b8..fe7e2351b 100644 --- a/lib/gcpspanner/notification_channel_state.go +++ b/lib/gcpspanner/notification_channel_state.go @@ -16,6 +16,7 @@ package gcpspanner import ( "context" + "errors" "fmt" "time" @@ -78,3 +79,105 @@ func (c *Client) GetNotificationChannelState( return newEntityReader[notificationChannelStateMapper, NotificationChannelState, string](c).readRowByKey(ctx, channelID) } + +// RecordNotificationChannelSuccess resets the consecutive failures count in the NotificationChannelStates table +// and logs a successful delivery attempt in the NotificationChannelDeliveryAttempts table. +func (c *Client) RecordNotificationChannelSuccess( + ctx context.Context, channelID string, timestamp time.Time, eventID string) error { + _, err := c.ReadWriteTransaction(ctx, func(ctx context.Context, txn *spanner.ReadWriteTransaction) error { + // Update NotificationChannelStates + err := newEntityWriter[notificationChannelStateMapper](c).upsertWithTransaction(ctx, txn, + NotificationChannelState{ + ChannelID: channelID, + IsDisabledBySystem: false, + ConsecutiveFailures: 0, + CreatedAt: timestamp, + UpdatedAt: timestamp, + }) + if err != nil { + return err + } + + _, err = c.createNotificationChannelDeliveryAttemptWithTransaction(ctx, txn, + CreateNotificationChannelDeliveryAttemptRequest{ + ChannelID: channelID, + AttemptTimestamp: timestamp, + Status: DeliveryAttemptStatusSuccess, + Details: spanner.NullJSON{Value: AttemptDetails{ + EventID: eventID, + Message: "delivered"}, Valid: true}, + }) + + return err + }) + + return err + +} + +// RecordNotificationChannelFailure increments the consecutive failures count in the NotificationChannelStates table +// and logs a failure delivery attempt in the NotificationChannelDeliveryAttempts table. +// If isPermanent is true, it increments the failure count and potentially disables the channel. +// If isPermanent is false (transient), it logs the error but does not penalize the channel health. +func (c *Client) RecordNotificationChannelFailure( + ctx context.Context, channelID string, errorMsg string, timestamp time.Time, + isPermanent bool, eventID string) error { + _, err := c.ReadWriteTransaction(ctx, func(ctx context.Context, txn *spanner.ReadWriteTransaction) error { + // Read current state + state, err := newEntityReader[notificationChannelStateMapper, NotificationChannelState, string](c). + readRowByKeyWithTransaction(ctx, channelID, txn) + if err != nil && !errors.Is(err, ErrQueryReturnedNoResults) { + return err + } else if errors.Is(err, ErrQueryReturnedNoResults) { + state = &NotificationChannelState{ + ChannelID: channelID, + CreatedAt: timestamp, + UpdatedAt: timestamp, + IsDisabledBySystem: false, + ConsecutiveFailures: 0, + } + } + + // Calculate new state + if isPermanent { + state.ConsecutiveFailures++ + } + state.UpdatedAt = timestamp + state.IsDisabledBySystem = state.ConsecutiveFailures >= int64( + c.notificationCfg.maxConsecutiveFailuresPerChannel) + + // Update NotificationChannelStates + err = newEntityWriter[notificationChannelStateMapper](c).upsertWithTransaction(ctx, + txn, NotificationChannelState{ + ChannelID: channelID, + IsDisabledBySystem: state.IsDisabledBySystem, + ConsecutiveFailures: state.ConsecutiveFailures, + CreatedAt: state.CreatedAt, + UpdatedAt: state.UpdatedAt, + }) + if err != nil { + return err + } + + // Log attempt + _, err = c.createNotificationChannelDeliveryAttemptWithTransaction(ctx, txn, + CreateNotificationChannelDeliveryAttemptRequest{ + ChannelID: channelID, + AttemptTimestamp: timestamp, + Status: DeliveryAttemptStatusFailure, + Details: spanner.NullJSON{Value: AttemptDetails{ + EventID: eventID, + Message: errorMsg}, Valid: true}, + }) + + return err + + }) + + return err +} + +type AttemptDetails struct { + Message string `json:"message"` + EventID string `json:"event_id"` +} diff --git a/lib/gcpspanner/notification_channel_state_test.go b/lib/gcpspanner/notification_channel_state_test.go index d9178d421..a9815a579 100644 --- a/lib/gcpspanner/notification_channel_state_test.go +++ b/lib/gcpspanner/notification_channel_state_test.go @@ -17,6 +17,7 @@ package gcpspanner import ( "context" "testing" + "time" "cloud.google.com/go/spanner" "github.com/google/go-cmp/cmp" @@ -115,4 +116,151 @@ func TestNotificationChannelStateOperations(t *testing.T) { t.Errorf("GetNotificationChannelState after update mismatch (-want +got):\n%s", diff) } }) + + t.Run("RecordNotificationChannelSuccess", func(t *testing.T) { + testRecordNotificationChannelSuccess(t, channelID) + }) + + t.Run("RecordNotificationChannelFailure", func(t *testing.T) { + testRecordNotificationChannelFailure(t, channelID) + }) +} + +func testRecordNotificationChannelSuccess(t *testing.T, channelID string) { + ctx := t.Context() + // First, set up a channel state with some failures. + initialState := &NotificationChannelState{ + ChannelID: channelID, + IsDisabledBySystem: true, + ConsecutiveFailures: 3, + CreatedAt: spanner.CommitTimestamp, + UpdatedAt: spanner.CommitTimestamp, + } + err := spannerClient.UpsertNotificationChannelState(ctx, *initialState) + if err != nil { + t.Fatalf("pre-test UpsertNotificationChannelState failed: %v", err) + } + + testTime := time.Now() + eventID := "evt-1" + err = spannerClient.RecordNotificationChannelSuccess(ctx, channelID, testTime, eventID) + if err != nil { + t.Fatalf("RecordNotificationChannelSuccess failed: %v", err) + } + + // Verify state update. + retrievedState, err := spannerClient.GetNotificationChannelState(ctx, channelID) + if err != nil { + t.Fatalf("GetNotificationChannelState after success failed: %v", err) + } + if retrievedState.IsDisabledBySystem != false { + t.Errorf("expected IsDisabledBySystem to be false, got %t", retrievedState.IsDisabledBySystem) + } + if retrievedState.ConsecutiveFailures != 0 { + t.Errorf("expected ConsecutiveFailures to be 0, got %d", retrievedState.ConsecutiveFailures) + } + + // Verify delivery attempt log. + listAttemptsReq := ListNotificationChannelDeliveryAttemptsRequest{ + ChannelID: channelID, + PageSize: 1, + PageToken: nil, + } + attempts, _, err := spannerClient.ListNotificationChannelDeliveryAttempts(ctx, listAttemptsReq) + if err != nil { + t.Fatalf("ListNotificationChannelDeliveryAttempts after success failed: %v", err) + } + if len(attempts) != 1 { + t.Fatalf("expected 1 delivery attempt, got %d", len(attempts)) + } + if attempts[0].Status != DeliveryAttemptStatusSuccess { + t.Errorf("expected status SUCCESS, got %s", attempts[0].Status) + } + if attempts[0].AttemptDetails == nil || attempts[0].AttemptDetails.Message != "delivered" || + attempts[0].AttemptDetails.EventID != "evt-1" { + t.Errorf("expected details message 'delivered', got %v", attempts[0].AttemptDetails) + } +} + +func testRecordNotificationChannelFailure(t *testing.T, channelID string) { + ctx := t.Context() + // Reset state for new test + initialState := &NotificationChannelState{ + ChannelID: channelID, + IsDisabledBySystem: false, + ConsecutiveFailures: 0, + CreatedAt: spanner.CommitTimestamp, + UpdatedAt: spanner.CommitTimestamp, + } + err := spannerClient.UpsertNotificationChannelState(ctx, *initialState) + if err != nil { + t.Fatalf("pre-test UpsertNotificationChannelState failed: %v", err) + } + + t.Run("Permanent Failure", func(t *testing.T) { + _ = spannerClient.UpsertNotificationChannelState(ctx, *initialState) // Ensure clean state + testTime := time.Now() + errorMsg := "permanent error" + eventID := "evt-124" + err = spannerClient.RecordNotificationChannelFailure(ctx, channelID, errorMsg, testTime, true, eventID) + if err != nil { + t.Fatalf("RecordNotificationChannelFailure (permanent) failed: %v", err) + } + + verifyFailureAttemptAndState(t, channelID, 1, false, errorMsg, eventID) + }) + + t.Run("Transient Failure", func(t *testing.T) { + _ = spannerClient.UpsertNotificationChannelState(ctx, *initialState) // Ensure clean state + testTime := time.Now() + errorMsg := "transient error" + eventID := "evt-125" + err = spannerClient.RecordNotificationChannelFailure(ctx, channelID, errorMsg, testTime, false, eventID) + if err != nil { + t.Fatalf("RecordNotificationChannelFailure (transient) failed: %v", err) + } + + verifyFailureAttemptAndState(t, channelID, 0, false, errorMsg, eventID) + }) +} + +// verifyFailureAttemptAndState is a helper function to verify the state and delivery attempt after a failure. +func verifyFailureAttemptAndState(t *testing.T, channelID string, + expectedFailures int64, expectedIsDisabled bool, expectedAttemptMessage string, expectedEventID string) { + t.Helper() + ctx := t.Context() + + // Verify state update. + retrievedState, err := spannerClient.GetNotificationChannelState(ctx, channelID) + if err != nil { + t.Fatalf("GetNotificationChannelState after failure failed: %v", err) + } + if retrievedState.ConsecutiveFailures != expectedFailures { + t.Errorf("expected ConsecutiveFailures to be %d, got %d", expectedFailures, retrievedState.ConsecutiveFailures) + } + if retrievedState.IsDisabledBySystem != expectedIsDisabled { + t.Errorf("expected IsDisabledBySystem to be %t, got %t", expectedIsDisabled, retrievedState.IsDisabledBySystem) + } + + // Verify delivery attempt log. + listAttemptsReq := ListNotificationChannelDeliveryAttemptsRequest{ + ChannelID: channelID, + PageSize: 1, + PageToken: nil, + } + attempts, _, err := spannerClient.ListNotificationChannelDeliveryAttempts(ctx, listAttemptsReq) + if err != nil { + t.Fatalf("ListNotificationChannelDeliveryAttempts after failure failed: %v", err) + } + if len(attempts) != 1 { + t.Fatalf("expected 1 delivery attempt, got %d", len(attempts)) + } + if attempts[0].Status != DeliveryAttemptStatusFailure { + t.Errorf("expected status FAILURE, got %s", attempts[0].Status) + } + if attempts[0].AttemptDetails == nil || attempts[0].AttemptDetails.Message != expectedAttemptMessage || + attempts[0].AttemptDetails.EventID != expectedEventID { + t.Errorf("expected details message '%s' eventID '%s', got %v", expectedAttemptMessage, expectedEventID, + attempts[0].AttemptDetails) + } } From f9040dfdd371dc03cf09fd39ed095c8b0feedb61 Mon Sep 17 00:00:00 2001 From: James Scott Date: Mon, 29 Dec 2025 20:57:43 +0000 Subject: [PATCH 15/27] feat(email_worker): refine sender logic and event types Updates the email worker's sender logic to handle delivery robustly and extends event types to propagate necessary context (ChannelID and Event IDs). Changes: - **Event Types**: Added `ChannelID` to `EmailJobEvent` to ensure the consumer can identify the target channel. - **Worker Types**: Introduced `IncomingEmailDeliveryJob` to capture the `EmailEventID` (Pub/Sub message ID) for audit logging. Added sentinel errors (`ErrUnrecoverableUserFailureEmailSending`, `ErrUnrecoverableSystemFailureEmailSending`) to distinguish failure modes. - **Sender Logic**: Refactored `ProcessMessage` to: - Use `IncomingEmailDeliveryJob` for better context. - Handle distinct error types: Permanent user errors are recorded and ACKed; transient errors trigger NACKs for retry. - Pass `EmailEventID` and error classification to the `ChannelStateManager` for precise attempt tracking. - **Pub/Sub Adapter**: Added logging to `PublishEmailJob` to trace published IDs. --- .../gcppubsubadapters/push_delivery.go | 4 +- lib/workertypes/types.go | 18 ++ workers/email/pkg/sender/sender.go | 38 ++-- workers/email/pkg/sender/sender_test.go | 185 +++++++++++++----- 4 files changed, 180 insertions(+), 65 deletions(-) diff --git a/lib/gcppubsub/gcppubsubadapters/push_delivery.go b/lib/gcppubsub/gcppubsubadapters/push_delivery.go index 0a42bd39c..c3a6703ea 100644 --- a/lib/gcppubsub/gcppubsubadapters/push_delivery.go +++ b/lib/gcppubsub/gcppubsubadapters/push_delivery.go @@ -55,9 +55,11 @@ func (p *PushDeliveryPublisher) PublishEmailJob(ctx context.Context, job workert return err } - if _, err := p.client.Publish(ctx, p.emailTopic, b); err != nil { + id, err := p.client.Publish(ctx, p.emailTopic, b) + if err != nil { return fmt.Errorf("failed to publish email job: %w", err) } + slog.InfoContext(ctx, "published email job", "id", id, "eventID", job.Metadata.EventID) return nil } diff --git a/lib/workertypes/types.go b/lib/workertypes/types.go index fef3c93db..d4d27f097 100644 --- a/lib/workertypes/types.go +++ b/lib/workertypes/types.go @@ -640,3 +640,21 @@ type EmailDeliveryJob struct { // Metadata contains context for links and tracking. Metadata DeliveryMetadata } + +type IncomingEmailDeliveryJob struct { + EmailDeliveryJob + // The ID from the queued event for this specific email job. + // This will be generated by the queuing service. + // This is different from the EventID in the Metadata which is for the original event that triggered + // the event producer in the very beginning. + EmailEventID string +} + +var ( + // ErrUnrecoverableSystemFailureEmailSending indicates that there's a system failure that should not be retried. + // Examples: System auth issue. + ErrUnrecoverableSystemFailureEmailSending = errors.New("unrecoverable user failure trying to send email") + // ErrUnrecoverableUserFailureEmailSending indicates that there's a user failure that should not be retried. + // Examples: Bad email address. + ErrUnrecoverableUserFailureEmailSending = errors.New("unrecoverable user failure trying to send email") +) diff --git a/workers/email/pkg/sender/sender.go b/workers/email/pkg/sender/sender.go index 940e653ae..139e2a4d9 100644 --- a/workers/email/pkg/sender/sender.go +++ b/workers/email/pkg/sender/sender.go @@ -16,8 +16,11 @@ package sender import ( "context" + "errors" "log/slog" + "time" + "github.com/GoogleChrome/webstatus.dev/lib/event" "github.com/GoogleChrome/webstatus.dev/lib/workertypes" ) @@ -26,18 +29,20 @@ type EmailSender interface { } type ChannelStateManager interface { - RecordSuccess(ctx context.Context, channelID string) error - RecordFailure(ctx context.Context, channelID string, err error) error + RecordSuccess(ctx context.Context, channelID string, timestamp time.Time, eventID string) error + RecordFailure(ctx context.Context, channelID string, err error, + timestamp time.Time, permanentUserFailure bool, emailEventID string) error } type TemplateRenderer interface { - RenderDigest(job workertypes.EmailDeliveryJob) (string, string, error) + RenderDigest(job workertypes.IncomingEmailDeliveryJob) (string, string, error) } type Sender struct { sender EmailSender stateManager ChannelStateManager renderer TemplateRenderer + now func() time.Time } func NewSender( @@ -49,36 +54,43 @@ func NewSender( sender: sender, stateManager: stateManager, renderer: renderer, + now: time.Now, } } -func (s *Sender) ProcessMessage(ctx context.Context, job workertypes.EmailDeliveryJob) error { +func (s *Sender) ProcessMessage(ctx context.Context, job workertypes.IncomingEmailDeliveryJob) error { // 1. Render (Parsing happens inside RenderDigest implementation) subject, body, err := s.renderer.RenderDigest(job) if err != nil { slog.ErrorContext(ctx, "failed to render email", "subscription_id", job.SubscriptionID, "error", err) - if err := s.stateManager.RecordFailure(ctx, job.ChannelID, err); err != nil { - slog.ErrorContext(ctx, "failed to record channel failure", "channel_id", job.ChannelID, "error", err) + if dbErr := s.stateManager.RecordFailure(ctx, job.ChannelID, err, s.now(), false, job.EmailEventID); dbErr != nil { + slog.ErrorContext(ctx, "failed to record channel failure", "channel_id", job.ChannelID, "error", dbErr) } - // Rendering errors might be transient or permanent. Assuming permanent for template bugs. - return nil + + return err } // 2. Send if err := s.sender.Send(ctx, job.RecipientEmail, subject, body); err != nil { + isPermanentUserError := errors.Is(err, workertypes.ErrUnrecoverableUserFailureEmailSending) + isPermanent := errors.Is(err, workertypes.ErrUnrecoverableSystemFailureEmailSending) || + isPermanentUserError slog.ErrorContext(ctx, "failed to send email", "recipient", job.RecipientEmail, "error", err) // Record failure in DB - if dbErr := s.stateManager.RecordFailure(ctx, job.ChannelID, err); dbErr != nil { + if dbErr := s.stateManager.RecordFailure(ctx, job.ChannelID, err, s.now(), + isPermanentUserError, job.EmailEventID); dbErr != nil { slog.ErrorContext(ctx, "failed to record channel failure", "channel_id", job.ChannelID, "error", dbErr) } + if isPermanent { + return err + } - // Return error to NACK the message and retry sending? - // Sending failures (network, rate limit) are often transient. - return err + // If not permanent, wrap with ErrTransient to trigger NACK (which will retry) + return errors.Join(event.ErrTransientFailure, err) } // 3. Success - if err := s.stateManager.RecordSuccess(ctx, job.ChannelID); err != nil { + if err := s.stateManager.RecordSuccess(ctx, job.ChannelID, s.now(), job.EmailEventID); err != nil { // Non-critical error, but good to log slog.WarnContext(ctx, "failed to record channel success", "channel_id", job.ChannelID, "error", err) } diff --git a/workers/email/pkg/sender/sender_test.go b/workers/email/pkg/sender/sender_test.go index 10218f298..b4f8a886f 100644 --- a/workers/email/pkg/sender/sender_test.go +++ b/workers/email/pkg/sender/sender_test.go @@ -20,6 +20,7 @@ import ( "testing" "time" + "github.com/GoogleChrome/webstatus.dev/lib/event" "github.com/GoogleChrome/webstatus.dev/lib/workertypes" "github.com/google/go-cmp/cmp" ) @@ -43,25 +44,37 @@ func (m *mockEmailSender) Send(_ context.Context, to, subject, body string) erro return m.sendErr } +type successCall struct { + channelID string + emailEventID string + timestamp time.Time +} + type mockChannelStateManager struct { - successCalls []string // channelIDs + successCalls []successCall failureCalls []failureCall recordErr error } type failureCall struct { - channelID string - err error + channelID string + emailEventID string + err error + isPermanentUserError bool + timestamp time.Time } -func (m *mockChannelStateManager) RecordSuccess(_ context.Context, channelID string) error { - m.successCalls = append(m.successCalls, channelID) +func (m *mockChannelStateManager) RecordSuccess(_ context.Context, channelID string, + timestamp time.Time, emailEventID string) error { + m.successCalls = append(m.successCalls, successCall{channelID, emailEventID, timestamp}) return m.recordErr } -func (m *mockChannelStateManager) RecordFailure(_ context.Context, channelID string, err error) error { - m.failureCalls = append(m.failureCalls, failureCall{channelID, err}) +func (m *mockChannelStateManager) RecordFailure(_ context.Context, channelID string, err error, + timestamp time.Time, isPermanentUserError bool, emailEventID string, +) error { + m.failureCalls = append(m.failureCalls, failureCall{channelID, emailEventID, err, isPermanentUserError, timestamp}) return m.recordErr } @@ -70,10 +83,10 @@ type mockTemplateRenderer struct { renderSubject string renderBody string renderErr error - renderInput workertypes.EmailDeliveryJob + renderInput workertypes.IncomingEmailDeliveryJob } -func (m *mockTemplateRenderer) RenderDigest(job workertypes.EmailDeliveryJob) (string, string, error) { +func (m *mockTemplateRenderer) RenderDigest(job workertypes.IncomingEmailDeliveryJob) (string, string, error) { m.renderInput = job return m.renderSubject, m.renderBody, m.renderErr @@ -95,16 +108,23 @@ func testMetadata() workertypes.DeliveryMetadata { } } +func fakeNow() time.Time { + return time.Date(2025, 1, 1, 12, 0, 0, 0, time.UTC) +} + // --- Tests --- func TestProcessMessage_Success(t *testing.T) { ctx := context.Background() - job := workertypes.EmailDeliveryJob{ - SubscriptionID: "sub-1", - Metadata: testMetadata(), - RecipientEmail: "user@example.com", - SummaryRaw: []byte("{}"), - ChannelID: "chan-1", + job := workertypes.IncomingEmailDeliveryJob{ + EmailDeliveryJob: workertypes.EmailDeliveryJob{ + SubscriptionID: "sub-1", + Metadata: testMetadata(), + RecipientEmail: "user@example.com", + SummaryRaw: []byte("{}"), + ChannelID: "chan-1", + }, + EmailEventID: "job-id", } sender := new(mockEmailSender) @@ -114,6 +134,7 @@ func TestProcessMessage_Success(t *testing.T) { renderer.renderBody = "Body" h := NewSender(sender, stateManager, renderer) + h.now = fakeNow err := h.ProcessMessage(ctx, job) if err != nil { @@ -137,19 +158,28 @@ func TestProcessMessage_Success(t *testing.T) { if len(stateManager.successCalls) != 1 { t.Errorf("Expected 1 success record, got %d", len(stateManager.successCalls)) } - if stateManager.successCalls[0] != testChannelID { - t.Errorf("Success recorded for wrong channel: %s", stateManager.successCalls[0]) + if stateManager.successCalls[0].channelID != testChannelID { + t.Errorf("Success recorded for wrong channel: %v", stateManager.successCalls[0]) + } + if stateManager.successCalls[0].emailEventID != "job-id" { + t.Errorf("Success recorded for wrong event: %v", stateManager.successCalls[0]) + } + if !stateManager.successCalls[0].timestamp.Equal(fakeNow()) { + t.Errorf("Success recorded with wrong timestamp: %v", stateManager.successCalls[0]) } } func TestProcessMessage_RenderError(t *testing.T) { ctx := context.Background() - job := workertypes.EmailDeliveryJob{ - SubscriptionID: "sub-1", - Metadata: testMetadata(), - RecipientEmail: "user@example.com", - SummaryRaw: []byte("{}"), - ChannelID: "chan-1", + job := workertypes.IncomingEmailDeliveryJob{ + EmailDeliveryJob: workertypes.EmailDeliveryJob{ + SubscriptionID: "sub-1", + Metadata: testMetadata(), + RecipientEmail: "user@example.com", + SummaryRaw: []byte("{}"), + ChannelID: "chan-1", + }, + EmailEventID: "job-id", } sender := new(mockEmailSender) @@ -158,10 +188,16 @@ func TestProcessMessage_RenderError(t *testing.T) { renderer.renderErr = errors.New("template error") h := NewSender(sender, stateManager, renderer) + h.now = fakeNow + + // Should return non transient error (ACK) for rendering error + err := h.ProcessMessage(ctx, job) + if errors.Is(err, event.ErrTransientFailure) { + t.Errorf("Expected non transient error for render failure, got %v", err) + } - // Should return nil (ACK) for rendering error (assuming permanent for now) - if err := h.ProcessMessage(ctx, job); err != nil { - t.Errorf("Expected nil error for render failure, got %v", err) + if !errors.Is(err, renderer.renderErr) { + t.Errorf("Expected configured renderer error, got %v", err) } // Should record failure @@ -177,34 +213,81 @@ func TestProcessMessage_RenderError(t *testing.T) { func TestProcessMessage_SendError(t *testing.T) { ctx := context.Background() - job := workertypes.EmailDeliveryJob{ - SubscriptionID: "sub-1", - Metadata: testMetadata(), - RecipientEmail: "user@example.com", - SummaryRaw: []byte("{}"), - ChannelID: "chan-1", + job := workertypes.IncomingEmailDeliveryJob{ + EmailDeliveryJob: workertypes.EmailDeliveryJob{ + SubscriptionID: "sub-1", + Metadata: testMetadata(), + RecipientEmail: "user@example.com", + SummaryRaw: []byte("{}"), + ChannelID: "chan-1", + }, + EmailEventID: "job-id", } - sendErr := errors.New("smtp timeout") - sender := &mockEmailSender{sendErr: sendErr, sentCalls: nil} - stateManager := new(mockChannelStateManager) - renderer := new(mockTemplateRenderer) - renderer.renderSubject = "S" - renderer.renderBody = "B" - - h := NewSender(sender, stateManager, renderer) - - // Should return error (NACK) for send failure to allow retry - err := h.ProcessMessage(ctx, job) - if !errors.Is(err, sendErr) { - t.Errorf("Expected send error to propagate, got %v", err) + testCases := []struct { + name string + sendErr error + isPermanentUserError bool + wantNack bool + }{ + { + "regular error = NACK", + errors.New("send error"), + false, + true, + }, + { + "user error = ACK", + workertypes.ErrUnrecoverableUserFailureEmailSending, + true, + false, + }, + { + "system error = ACK", + workertypes.ErrUnrecoverableSystemFailureEmailSending, + false, + false, + }, } - // Should record failure in DB as well - if len(stateManager.failureCalls) != 1 { - t.Fatal("Expected failure recording") - } - if stateManager.failureCalls[0].channelID != testChannelID { - t.Errorf("Recorded failure for wrong channel") + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + sender := &mockEmailSender{sendErr: tc.sendErr, sentCalls: nil} + stateManager := new(mockChannelStateManager) + renderer := new(mockTemplateRenderer) + renderer.renderSubject = "S" + renderer.renderBody = "B" + + h := NewSender(sender, stateManager, renderer) + h.now = fakeNow + + err := h.ProcessMessage(ctx, job) + if !errors.Is(err, tc.sendErr) { + t.Errorf("Expected send error %v, got %v", tc.sendErr, err) + } + // Should record failure in DB as well + if len(stateManager.failureCalls) != 1 { + t.Fatal("Expected failure recording") + } + if stateManager.failureCalls[0].channelID != testChannelID { + t.Errorf("Recorded failure for wrong channel") + } + if stateManager.failureCalls[0].emailEventID != "job-id" { + t.Errorf("Recorded failure for wrong event") + } + if tc.isPermanentUserError != stateManager.failureCalls[0].isPermanentUserError { + t.Errorf("Recorded failure for wrong error type") + } + if !stateManager.failureCalls[0].timestamp.Equal(fakeNow()) { + t.Errorf("Recorded failure for wrong timestamp") + } + + if tc.wantNack { + if !errors.Is(err, event.ErrTransientFailure) { + t.Errorf("Expected transient failure for NACK, got %v", err) + } + } + }) } + } From d1662da895862367fb4a46c82999317c794e3b5e Mon Sep 17 00:00:00 2001 From: James Scott Date: Mon, 29 Dec 2025 21:47:58 +0000 Subject: [PATCH 16/27] feat(email_worker): add pub/sub subscriber and spanner adapter Implements the Pub/Sub subscriber adapter for the email worker and the Spanner adapter for managing notification channel state. Changes: - **Pub/Sub Adapter**: Created `EmailWorkerSubscriberAdapter` to receive `EmailJobEvent` messages from Pub/Sub and route them to the email worker's message handler. Includes unit tests. - **Spanner Adapter**: Added `EmailWorkerChannelStateManager` to record notification channel success and failure events. Includes unit tests. - **Event Types**: Added a `ToWorkerTypeJobFrequency` method to the `JobFrequency` type in `lib/event/emailjob/v1` to convert between the event type and the worker type. --- lib/event/emailjob/v1/types.go | 15 ++ .../gcppubsubadapters/email_worker.go | 84 +++++++++ .../gcppubsubadapters/email_worker_test.go | 172 ++++++++++++++++++ .../gcppubsubadapters/event_producer.go | 8 +- .../spanneradapters/email_worker.go | 49 +++++ .../spanneradapters/email_worker_test.go | 133 ++++++++++++++ 6 files changed, 460 insertions(+), 1 deletion(-) create mode 100644 lib/gcppubsub/gcppubsubadapters/email_worker.go create mode 100644 lib/gcppubsub/gcppubsubadapters/email_worker_test.go create mode 100644 lib/gcpspanner/spanneradapters/email_worker.go create mode 100644 lib/gcpspanner/spanneradapters/email_worker_test.go diff --git a/lib/event/emailjob/v1/types.go b/lib/event/emailjob/v1/types.go index 831f6620d..50d803b98 100644 --- a/lib/event/emailjob/v1/types.go +++ b/lib/event/emailjob/v1/types.go @@ -59,6 +59,21 @@ const ( FrequencyMonthly JobFrequency = "MONTHLY" ) +func (f JobFrequency) ToWorkerTypeJobFrequency() workertypes.JobFrequency { + switch f { + case FrequencyImmediate: + return workertypes.FrequencyImmediate + case FrequencyWeekly: + return workertypes.FrequencyWeekly + case FrequencyMonthly: + return workertypes.FrequencyMonthly + case FrequencyUnknown: + return workertypes.FrequencyUnknown + } + + return workertypes.FrequencyUnknown +} + func ToJobFrequency(freq workertypes.JobFrequency) JobFrequency { switch freq { case workertypes.FrequencyImmediate: diff --git a/lib/gcppubsub/gcppubsubadapters/email_worker.go b/lib/gcppubsub/gcppubsubadapters/email_worker.go new file mode 100644 index 000000000..4e56cabbc --- /dev/null +++ b/lib/gcppubsub/gcppubsubadapters/email_worker.go @@ -0,0 +1,84 @@ +// 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" + + "github.com/GoogleChrome/webstatus.dev/lib/event" + v1 "github.com/GoogleChrome/webstatus.dev/lib/event/emailjob/v1" + "github.com/GoogleChrome/webstatus.dev/lib/workertypes" +) + +// EmailWorkerMessageHandler defines the interface for the Sender logic. +type EmailWorkerMessageHandler interface { + ProcessMessage(ctx context.Context, job workertypes.IncomingEmailDeliveryJob) error +} + +type EmailWorkerSubscriberAdapter struct { + sender EmailWorkerMessageHandler + eventSubscriber EventSubscriber + subscriptionID string + router *event.Router +} + +func NewEmailWorkerSubscriberAdapter( + sender EmailWorkerMessageHandler, + eventSubscriber EventSubscriber, + subscriptionID string, +) *EmailWorkerSubscriberAdapter { + router := event.NewRouter() + + ret := &EmailWorkerSubscriberAdapter{ + sender: sender, + eventSubscriber: eventSubscriber, + subscriptionID: subscriptionID, + router: router, + } + + event.Register(router, ret.handleEmailJobEvent) + + return ret +} + +func (a *EmailWorkerSubscriberAdapter) Subscribe(ctx context.Context) error { + return a.eventSubscriber.Subscribe(ctx, a.subscriptionID, func(ctx context.Context, + msgID string, data []byte) error { + return a.router.HandleMessage(ctx, msgID, data) + }) +} + +func (a *EmailWorkerSubscriberAdapter) handleEmailJobEvent( + ctx context.Context, msgID string, event v1.EmailJobEvent) error { + + incomingJob := workertypes.IncomingEmailDeliveryJob{ + EmailDeliveryJob: workertypes.EmailDeliveryJob{ + SubscriptionID: event.SubscriptionID, + RecipientEmail: event.RecipientEmail, + ChannelID: event.ChannelID, + SummaryRaw: event.SummaryRaw, + Metadata: workertypes.DeliveryMetadata{ + EventID: event.Metadata.EventID, + SearchID: event.Metadata.SearchID, + Query: event.Metadata.Query, + Frequency: event.Metadata.Frequency.ToWorkerTypeJobFrequency(), + GeneratedAt: event.Metadata.GeneratedAt, + }, + }, + EmailEventID: msgID, + } + + return a.sender.ProcessMessage(ctx, incomingJob) +} diff --git a/lib/gcppubsub/gcppubsubadapters/email_worker_test.go b/lib/gcppubsub/gcppubsubadapters/email_worker_test.go new file mode 100644 index 000000000..9ca6832be --- /dev/null +++ b/lib/gcppubsub/gcppubsubadapters/email_worker_test.go @@ -0,0 +1,172 @@ +// 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 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" + "sync" + "testing" + "time" + + v1 "github.com/GoogleChrome/webstatus.dev/lib/event/emailjob/v1" + "github.com/GoogleChrome/webstatus.dev/lib/workertypes" + "github.com/google/go-cmp/cmp" +) + +// --- Mocks --- + +type mockEmailWorkerMessageHandler struct { + calls []workertypes.IncomingEmailDeliveryJob + mu sync.Mutex + err error +} + +func (m *mockEmailWorkerMessageHandler) ProcessMessage( + _ context.Context, job workertypes.IncomingEmailDeliveryJob) error { + m.mu.Lock() + defer m.mu.Unlock() + m.calls = append(m.calls, job) + + return m.err +} + +// --- Tests --- + +type emailTestEnv struct { + sender *mockEmailWorkerMessageHandler + adapter *EmailWorkerSubscriberAdapter + handleFn func(context.Context, string, []byte) error + stop func() +} + +func setupEmailTestAdapter(t *testing.T) *emailTestEnv { + t.Helper() + sender := new(mockEmailWorkerMessageHandler) + subscriber := &mockSubscriber{block: make(chan struct{}), mu: sync.Mutex{}, handlers: nil} + subscriptionID := "email-sub" + + adapter := NewEmailWorkerSubscriberAdapter(sender, subscriber, subscriptionID) + + ctx, cancel := context.WithCancel(context.Background()) + + errChan := make(chan error) + go func() { + errChan <- adapter.Subscribe(ctx) + }() + + // Wait briefly for Subscribe to start and register the handler + time.Sleep(50 * time.Millisecond) + + subscriber.mu.Lock() + handleFn := subscriber.handlers[subscriptionID] + subscriber.mu.Unlock() + + if handleFn == nil { + cancel() + t.Fatalf("Subscribe did not register a handler for subscription %s", subscriptionID) + } + + return &emailTestEnv{ + sender: sender, + adapter: adapter, + handleFn: func(ctx context.Context, msgID string, data []byte) error { + return handleFn(ctx, msgID, data) + }, + stop: func() { + close(subscriber.block) + cancel() + <-errChan + }, + } +} + +func TestEmailWorkerSubscriberAdapter_RoutesEmailJobEvent(t *testing.T) { + env := setupEmailTestAdapter(t) + defer env.stop() + + now := time.Now() + emailJobEvent := v1.EmailJobEvent{ + SubscriptionID: "sub-123", + RecipientEmail: "test@example.com", + ChannelID: "chan-456", + SummaryRaw: []byte(`{"key":"value"}`), + Metadata: v1.EmailJobEventMetadata{ + EventID: "event-789", + SearchID: "search-abc", + Query: "is:open", + Frequency: v1.FrequencyMonthly, + GeneratedAt: now, + }, + } + + ceWrapper := map[string]interface{}{ + "apiVersion": "v1", + "kind": "EmailJobEvent", + "data": emailJobEvent, + } + ceBytes, err := json.Marshal(ceWrapper) + if err != nil { + t.Fatalf("Failed to marshal event: %v", err) + } + + msgID := "msg-xyz" + if err := env.handleFn(context.Background(), msgID, ceBytes); err != nil { + t.Errorf("handleFn failed: %v", err) + } + + if len(env.sender.calls) != 1 { + t.Fatalf("Expected 1 call to ProcessMessage, got %d", len(env.sender.calls)) + } + + expectedJob := workertypes.IncomingEmailDeliveryJob{ + EmailDeliveryJob: workertypes.EmailDeliveryJob{ + SubscriptionID: "sub-123", + RecipientEmail: "test@example.com", + ChannelID: "chan-456", + SummaryRaw: []byte(`{"key":"value"}`), + Metadata: workertypes.DeliveryMetadata{ + EventID: "event-789", + SearchID: "search-abc", + Query: "is:open", + Frequency: workertypes.FrequencyMonthly, + GeneratedAt: now, + }, + }, + EmailEventID: msgID, + } + + if diff := cmp.Diff(expectedJob, env.sender.calls[0]); diff != "" { + t.Errorf("ProcessMessage call mismatch (-want +got):\n%s", diff) + } +} + +func TestEmailWorkerSubscriberAdapter_ReturnsErrorOnUnknownEvent(t *testing.T) { + env := setupEmailTestAdapter(t) + defer env.stop() + + ceWrapper := map[string]interface{}{ + "apiVersion": "v1", + "kind": "UnknownEvent", + "data": map[string]string{"foo": "bar"}, + } + ceBytes, _ := json.Marshal(ceWrapper) + + err := env.handleFn(context.Background(), "msg-1", ceBytes) + if err == nil { + t.Error("Expected an error for an unknown event, but got nil") + } +} diff --git a/lib/gcppubsub/gcppubsubadapters/event_producer.go b/lib/gcppubsub/gcppubsubadapters/event_producer.go index 33ed8db62..37bef1286 100644 --- a/lib/gcppubsub/gcppubsubadapters/event_producer.go +++ b/lib/gcppubsub/gcppubsubadapters/event_producer.go @@ -159,5 +159,11 @@ func (a *EventProducerPublisherAdapter) Publish(ctx context.Context, return "", err } - return a.eventPublisher.Publish(ctx, a.topicID, b) + id, err := a.eventPublisher.Publish(ctx, a.topicID, b) + if err != nil { + return "", err + } + slog.InfoContext(ctx, "published feature diff event", "id", id, "eventID", req.EventID) + + return id, nil } diff --git a/lib/gcpspanner/spanneradapters/email_worker.go b/lib/gcpspanner/spanneradapters/email_worker.go new file mode 100644 index 000000000..74d63c68d --- /dev/null +++ b/lib/gcpspanner/spanneradapters/email_worker.go @@ -0,0 +1,49 @@ +// Copyright 2025 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package spanneradapters + +import ( + "context" + "time" +) + +type EmailWorkerSpannerClient interface { + RecordNotificationChannelSuccess(ctx context.Context, channelID string, timestamp time.Time, eventID string) error + RecordNotificationChannelFailure(ctx context.Context, channelID string, errorMsg string, timestamp time.Time, + isPermanent bool, eventID string) error +} + +type EmailWorkerChannelStateManager struct { + client EmailWorkerSpannerClient +} + +func NewEmailWorkerChannelStateManager(client EmailWorkerSpannerClient) *EmailWorkerChannelStateManager { + return &EmailWorkerChannelStateManager{client: client} +} + +func (s *EmailWorkerChannelStateManager) RecordSuccess(ctx context.Context, channelID string, + timestamp time.Time, eventID string) error { + return s.client.RecordNotificationChannelSuccess(ctx, channelID, timestamp, eventID) +} + +func (s *EmailWorkerChannelStateManager) RecordFailure(ctx context.Context, channelID string, err error, + timestamp time.Time, permanentUserFailure bool, emailEventID string) error { + msg := "" + if err != nil { + msg = err.Error() + } + + return s.client.RecordNotificationChannelFailure(ctx, channelID, msg, timestamp, permanentUserFailure, emailEventID) +} diff --git a/lib/gcpspanner/spanneradapters/email_worker_test.go b/lib/gcpspanner/spanneradapters/email_worker_test.go new file mode 100644 index 000000000..c25264dc8 --- /dev/null +++ b/lib/gcpspanner/spanneradapters/email_worker_test.go @@ -0,0 +1,133 @@ +// 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 spanneradapters + +import ( + "context" + "errors" + "testing" + "time" + + "github.com/google/go-cmp/cmp" +) + +type mockEmailWorkerSpannerClient struct { + successCalled bool + successReq struct { + ChannelID string + Timestamp time.Time + EventID string + } + successErr error + + failureCalled bool + failureReq struct { + ChannelID string + Msg string + Timestamp time.Time + IsPermanent bool + EventID string + } + failureErr error +} + +func (m *mockEmailWorkerSpannerClient) RecordNotificationChannelSuccess( + _ context.Context, channelID string, timestamp time.Time, eventID string) error { + m.successCalled = true + m.successReq.ChannelID = channelID + m.successReq.Timestamp = timestamp + m.successReq.EventID = eventID + + return m.successErr +} + +func (m *mockEmailWorkerSpannerClient) RecordNotificationChannelFailure( + _ context.Context, channelID, errorMsg string, timestamp time.Time, isPermanent bool, eventID string) error { + m.failureCalled = true + m.failureReq.ChannelID = channelID + m.failureReq.Msg = errorMsg + m.failureReq.Timestamp = timestamp + m.failureReq.IsPermanent = isPermanent + m.failureReq.EventID = eventID + + return m.failureErr +} + +func TestRecordSuccess(t *testing.T) { + mock := new(mockEmailWorkerSpannerClient) + adapter := NewEmailWorkerChannelStateManager(mock) + + ts := time.Now() + eventID := "evt-1" + err := adapter.RecordSuccess(context.Background(), "chan-1", ts, eventID) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if !mock.successCalled { + t.Error("RecordNotificationChannelSuccess not called") + } + + expectedReq := struct { + ChannelID string + Timestamp time.Time + EventID string + }{ + ChannelID: "chan-1", + Timestamp: ts, + EventID: eventID, + } + + if diff := cmp.Diff(expectedReq, mock.successReq); diff != "" { + t.Errorf("RecordNotificationChannelSuccess request mismatch (-want +got):\n%s", diff) + } +} + +func TestRecordFailure(t *testing.T) { + mock := new(mockEmailWorkerSpannerClient) + adapter := NewEmailWorkerChannelStateManager(mock) + + testErr := errors.New("smtp error") + ts := time.Now() + eventID := "evt-2" + isPermanent := true + + err := adapter.RecordFailure(context.Background(), "chan-1", testErr, ts, isPermanent, eventID) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if !mock.failureCalled { + t.Error("RecordNotificationChannelFailure not called") + } + + expectedReq := struct { + ChannelID string + Msg string + Timestamp time.Time + IsPermanent bool + EventID string + }{ + ChannelID: "chan-1", + Msg: testErr.Error(), + Timestamp: ts, + IsPermanent: isPermanent, + EventID: eventID, + } + + if diff := cmp.Diff(expectedReq, mock.failureReq); diff != "" { + t.Errorf("RecordNotificationChannelFailure request mismatch (-want +got):\n%s", diff) + } +} From cd1eb5b9d532d959c5b17749103197f5a6dcd029 Mon Sep 17 00:00:00 2001 From: James Scott Date: Tue, 30 Dec 2025 18:19:39 +0000 Subject: [PATCH 17/27] refactor(featurelistdiff): Differentiate between removed and deleted features Introduces a clear distinction between features that are truly deleted from the database versus those that are simply no longer present in a given search result (which could be due to a move, split, or rename). Previously, the reconciler would mark a feature as "Removed" and set its reason to "Deleted" if it no longer existed. This approach was ambiguous because the feature remained in the `Removed` list, which is primarily intended for features that might have been moved or split. This change introduces a new `Deleted` slice to the `FeatureDiff` struct. The reconciler logic is updated to check for a feature's existence when it's reported as removed. If it returns an `ErrEntityDoesNotExist`, the feature is moved from the `Removed` list to the new `Deleted` list. This ensures the `Removed` list now accurately contains only features that need to be further analyzed for potential moves or splits, making the diffing process more robust and the resulting data structure clearer. --- .../featurelistdiff/v1/reconciler.go | 19 ++++-- .../featurelistdiff/v1/reconciler_test.go | 67 ++++++++++++++++++- lib/blobtypes/featurelistdiff/v1/types.go | 16 ++++- .../featurelistdiff/v1/types_test.go | 35 +++++++++- lib/workertypes/types.go | 38 +++++++++++ lib/workertypes/types_test.go | 17 ++++- .../event_producer/pkg/producer/diff_test.go | 1 + .../pkg/dispatcher/dispatcher_test.go | 3 + 8 files changed, 183 insertions(+), 13 deletions(-) diff --git a/lib/blobtypes/featurelistdiff/v1/reconciler.go b/lib/blobtypes/featurelistdiff/v1/reconciler.go index cc180bc78..14b50779f 100644 --- a/lib/blobtypes/featurelistdiff/v1/reconciler.go +++ b/lib/blobtypes/featurelistdiff/v1/reconciler.go @@ -38,16 +38,18 @@ func (w *FeatureDiffWorkflow) ReconcileHistory(ctx context.Context) error { // Phase 1: Investigation // Iterate through all removed features to build a map of their historical outcomes. - for i := range w.diff.Removed { - r := &w.diff.Removed[i] - + remainingRemoved := make([]FeatureRemoved, 0, len(w.diff.Removed)) + for _, r := range w.diff.Removed { // Check the current status of the removed feature ID in the database. result, err := w.fetcher.GetFeature(ctx, r.ID) if err != nil { // If the entity is completely gone from the DB, it's a true deletion. - // We update the reason to allow for specific UI messaging (e.g. "Deleted from platform"). if errors.Is(err, backendtypes.ErrEntityDoesNotExist) { - r.Reason = ReasonDeleted + w.diff.Deleted = append(w.diff.Deleted, FeatureDeleted{ + ID: r.ID, + Name: r.Name, + Reason: ReasonDeleted, + }) continue } @@ -55,6 +57,9 @@ func (w *FeatureDiffWorkflow) ReconcileHistory(ctx context.Context) error { return err } + // If the feature was not deleted, keep it in the list to check for moves/splits. + remainingRemoved = append(remainingRemoved, r) + // Update the visitor context so it knows which OldID owns the result we are about to visit. visitor.currentID = r.ID @@ -63,6 +68,10 @@ func (w *FeatureDiffWorkflow) ReconcileHistory(ctx context.Context) error { return err } } + // Update the diff with the (now smaller) list of removed items to check. + if len(remainingRemoved) > 0 { + w.diff.Removed = remainingRemoved + } // Phase 2: Correlation // If we found any history records, try to match them with the 'Added' list. diff --git a/lib/blobtypes/featurelistdiff/v1/reconciler_test.go b/lib/blobtypes/featurelistdiff/v1/reconciler_test.go index 3f6249a89..6493de594 100644 --- a/lib/blobtypes/featurelistdiff/v1/reconciler_test.go +++ b/lib/blobtypes/featurelistdiff/v1/reconciler_test.go @@ -63,6 +63,7 @@ func TestReconcileHistory(t *testing.T) { name: "Scenario 1: Feature Moved (Rename)", initialDiff: &FeatureDiff{ Removed: []FeatureRemoved{{ID: "old-id", Name: "Old Name", Reason: ReasonUnmatched}}, + Deleted: nil, Added: []FeatureAdded{{ID: "new-id", Name: "New Name", Reason: ReasonNewMatch, Docs: nil}}, QueryChanged: false, Modified: nil, @@ -84,6 +85,7 @@ func TestReconcileHistory(t *testing.T) { QueryChanged: false, Modified: nil, Splits: nil, + Deleted: nil, }, wantErr: false, }, @@ -99,6 +101,7 @@ func TestReconcileHistory(t *testing.T) { Modified: nil, Moves: nil, Splits: nil, + Deleted: nil, }, mockResults: map[string]*backendtypes.GetFeatureResult{ "monolith": backendtypes.NewGetFeatureResult( @@ -127,6 +130,7 @@ func TestReconcileHistory(t *testing.T) { QueryChanged: false, Modified: nil, Moves: nil, + Deleted: nil, }, wantErr: false, }, @@ -142,6 +146,7 @@ func TestReconcileHistory(t *testing.T) { Modified: nil, Moves: nil, Splits: nil, + Deleted: nil, }, mockResults: map[string]*backendtypes.GetFeatureResult{ "monolith": backendtypes.NewGetFeatureResult( @@ -170,6 +175,7 @@ func TestReconcileHistory(t *testing.T) { QueryChanged: false, Modified: nil, Moves: nil, + Deleted: nil, }, wantErr: false, }, @@ -182,6 +188,7 @@ func TestReconcileHistory(t *testing.T) { Modified: nil, Moves: nil, Splits: nil, + Deleted: nil, }, mockResults: map[string]*backendtypes.GetFeatureResult{ "removed-id": backendtypes.NewGetFeatureResult( @@ -208,26 +215,29 @@ func TestReconcileHistory(t *testing.T) { Modified: nil, Moves: nil, Splits: nil, + Deleted: nil, }, wantErr: false, }, { name: "Scenario 5: Hard Delete (EntityDoesNotExist)", initialDiff: &FeatureDiff{ - Removed: []FeatureRemoved{{ID: "deleted-id", Name: "Deleted Feature", Reason: ReasonUnmatched}}, + Deleted: []FeatureDeleted{{ID: "deleted-id", Name: "Deleted Feature", Reason: ReasonDeleted}}, Added: nil, QueryChanged: false, Modified: nil, Moves: nil, Splits: nil, + Removed: nil, }, mockResults: nil, mockErrors: map[string]error{ "deleted-id": backendtypes.ErrEntityDoesNotExist, }, expectedDiff: &FeatureDiff{ - // Remains in Removed list, but Reason updated to Deleted - Removed: []FeatureRemoved{{ID: "deleted-id", Name: "Deleted Feature", Reason: ReasonDeleted}}, + // Should be moved to Deleted list + Removed: nil, + Deleted: []FeatureDeleted{{ID: "deleted-id", Name: "Deleted Feature", Reason: ReasonDeleted}}, Added: nil, QueryChanged: false, Modified: nil, @@ -247,6 +257,7 @@ func TestReconcileHistory(t *testing.T) { Modified: nil, Moves: nil, Splits: nil, + Deleted: nil, }, mockResults: map[string]*backendtypes.GetFeatureResult{ "old-id": backendtypes.NewGetFeatureResult( @@ -261,6 +272,7 @@ func TestReconcileHistory(t *testing.T) { Modified: nil, Moves: nil, Splits: nil, + Deleted: nil, }, wantErr: false, }, @@ -273,6 +285,7 @@ func TestReconcileHistory(t *testing.T) { Modified: nil, Moves: nil, Splits: nil, + Deleted: nil, }, mockResults: nil, mockErrors: map[string]error{ @@ -292,6 +305,7 @@ func TestReconcileHistory(t *testing.T) { Modified: nil, Moves: nil, Splits: nil, + Deleted: nil, }, mockResults: map[string]*backendtypes.GetFeatureResult{ "monolith": backendtypes.NewGetFeatureResult( @@ -310,6 +324,7 @@ func TestReconcileHistory(t *testing.T) { Modified: nil, Moves: nil, Splits: nil, + Deleted: nil, }, wantErr: false, }, @@ -327,6 +342,7 @@ func TestReconcileHistory(t *testing.T) { Modified: nil, Moves: nil, Splits: nil, + Deleted: nil, }, mockResults: map[string]*backendtypes.GetFeatureResult{ "old-id": backendtypes.NewGetFeatureResult( @@ -343,6 +359,51 @@ func TestReconcileHistory(t *testing.T) { QueryChanged: false, Modified: nil, Splits: nil, + Deleted: nil, + }, + wantErr: false, + }, + { + name: "Scenario 10: Mixed Removed and Deleted", + initialDiff: &FeatureDiff{ + Removed: []FeatureRemoved{ + {ID: "deleted-id", Name: "Deleted Feature", Reason: ReasonUnmatched}, + {ID: "removed-id", Name: "Removed Feature", Reason: ReasonUnmatched}, + }, + Added: nil, + QueryChanged: false, + Modified: nil, + Moves: nil, + Splits: nil, + Deleted: nil, + }, + mockResults: map[string]*backendtypes.GetFeatureResult{ + "removed-id": backendtypes.NewGetFeatureResult( + backendtypes.NewRegularFeatureResult(&backend.Feature{ + FeatureId: "removed-id", + Name: "", + Spec: nil, + Baseline: nil, + BrowserImplementations: nil, + Discouraged: nil, + Usage: nil, + Wpt: nil, + VendorPositions: nil, + DeveloperSignals: nil, + }), + ), + }, + mockErrors: map[string]error{ + "deleted-id": backendtypes.ErrEntityDoesNotExist, + }, + expectedDiff: &FeatureDiff{ + Removed: []FeatureRemoved{{ID: "removed-id", Name: "Removed Feature", Reason: ReasonUnmatched}}, + Deleted: []FeatureDeleted{{ID: "deleted-id", Name: "Deleted Feature", Reason: ReasonDeleted}}, + Added: nil, + QueryChanged: false, + Modified: nil, + Moves: nil, + Splits: nil, }, wantErr: false, }, diff --git a/lib/blobtypes/featurelistdiff/v1/types.go b/lib/blobtypes/featurelistdiff/v1/types.go index a09a1fb26..271e7b09e 100644 --- a/lib/blobtypes/featurelistdiff/v1/types.go +++ b/lib/blobtypes/featurelistdiff/v1/types.go @@ -94,6 +94,7 @@ type FeatureDiff struct { QueryChanged bool `json:"queryChanged,omitempty"` Added []FeatureAdded `json:"added,omitempty"` Removed []FeatureRemoved `json:"removed,omitempty"` + Deleted []FeatureDeleted `json:"deleted,omitempty"` Modified []FeatureModified `json:"modified,omitempty"` Moves []FeatureMoved `json:"moves,omitempty"` Splits []FeatureSplit `json:"splits,omitempty"` @@ -125,6 +126,13 @@ func (d *FeatureDiff) Sort() { return d.Removed[i].ID < d.Removed[j].ID }) + sort.Slice(d.Deleted, func(i, j int) bool { + if d.Deleted[i].Name != d.Deleted[j].Name { + return d.Deleted[i].Name < d.Deleted[j].Name + } + + return d.Deleted[i].ID < d.Deleted[j].ID + }) sort.Slice(d.Modified, func(i, j int) bool { if d.Modified[i].Name != d.Modified[j].Name { return d.Modified[i].Name < d.Modified[j].Name @@ -182,6 +190,12 @@ type FeatureRemoved struct { Reason ChangeReason `json:"reason"` } +type FeatureDeleted struct { + ID string `json:"id"` + Name string `json:"name"` + Reason ChangeReason `json:"reason"` +} + type FeatureMoved struct { FromID string `json:"fromId"` ToID string `json:"toId"` @@ -223,7 +237,7 @@ func (d FeatureDiff) HasChanges() bool { } func (d FeatureDiff) HasDataChanges() bool { - return len(d.Added) > 0 || len(d.Removed) > 0 || + return len(d.Added) > 0 || len(d.Removed) > 0 || len(d.Deleted) > 0 || len(d.Modified) > 0 || len(d.Moves) > 0 || len(d.Splits) > 0 } diff --git a/lib/blobtypes/featurelistdiff/v1/types_test.go b/lib/blobtypes/featurelistdiff/v1/types_test.go index 831ecb8a4..c75802da2 100644 --- a/lib/blobtypes/featurelistdiff/v1/types_test.go +++ b/lib/blobtypes/featurelistdiff/v1/types_test.go @@ -36,6 +36,7 @@ func TestHasChanges(t *testing.T) { Modified: nil, Moves: nil, Splits: nil, + Deleted: nil, }, expected: false, }, @@ -48,6 +49,7 @@ func TestHasChanges(t *testing.T) { Modified: nil, Moves: nil, Splits: nil, + Deleted: nil, }, expected: true, }, @@ -60,6 +62,7 @@ func TestHasChanges(t *testing.T) { Modified: nil, Moves: nil, Splits: nil, + Deleted: nil, }, expected: true, }, @@ -72,6 +75,22 @@ func TestHasChanges(t *testing.T) { Modified: nil, Moves: nil, Splits: nil, + Deleted: nil, + }, + expected: true, + }, + { + name: "Deleted", + diff: FeatureDiff{ + QueryChanged: false, + Added: nil, + Removed: nil, + Modified: nil, + Moves: nil, + Splits: nil, + Deleted: []FeatureDeleted{ + {ID: "1", Name: "A", Reason: ReasonDeleted}, + }, }, expected: true, }, @@ -101,8 +120,9 @@ func TestHasChanges(t *testing.T) { BrowserChanges: nil, DocsChange: nil, }}, - Moves: nil, - Splits: nil, + Moves: nil, + Splits: nil, + Deleted: nil, }, expected: true, }, @@ -115,6 +135,7 @@ func TestHasChanges(t *testing.T) { Modified: nil, Moves: []FeatureMoved{{FromID: "A", ToID: "B", FromName: "A", ToName: "B"}}, Splits: nil, + Deleted: nil, }, expected: true, }, @@ -127,6 +148,7 @@ func TestHasChanges(t *testing.T) { Modified: nil, Moves: nil, Splits: []FeatureSplit{{FromID: "A", FromName: "A", To: nil}}, + Deleted: nil, }, expected: true, }, @@ -176,6 +198,10 @@ func TestFeatureDiff_Sort(t *testing.T) { To: nil, }, }, + Deleted: []FeatureDeleted{ + {ID: "2", Name: "B", Reason: ReasonDeleted}, + {ID: "1", Name: "A", Reason: ReasonDeleted}, + }, } diff.Sort() @@ -190,6 +216,11 @@ func TestFeatureDiff_Sort(t *testing.T) { t.Errorf("Removed sort failed: %+v", diff.Removed) } + // Deleted: A(1), B(2) + if diff.Deleted[0].ID != "1" || diff.Deleted[1].ID != "2" { + t.Errorf("Deleted sort failed: %+v", diff.Deleted) + } + // Modified: A(1), B(2) if diff.Modified[0].ID != "1" || diff.Modified[1].ID != "2" { t.Errorf("Modified sort failed: %+v", diff.Modified) diff --git a/lib/workertypes/types.go b/lib/workertypes/types.go index d4d27f097..ab0bd3e62 100644 --- a/lib/workertypes/types.go +++ b/lib/workertypes/types.go @@ -78,6 +78,7 @@ type SummaryCategories struct { QueryChanged int `json:"query_changed,omitzero"` Added int `json:"added,omitzero"` Removed int `json:"removed,omitzero"` + Deleted int `json:"deleted,omitzero"` Moved int `json:"moved,omitzero"` Split int `json:"split,omitzero"` Updated int `json:"updated,omitzero"` @@ -138,6 +139,7 @@ const ( type BrowserValue struct { Status BrowserStatus `json:"status"` Version *string `json:"version,omitempty"` + Date *time.Time `json:"date,omitempty"` } type BrowserName string @@ -160,6 +162,7 @@ const ( SummaryHighlightTypeChanged SummaryHighlightType = "Changed" SummaryHighlightTypeMoved SummaryHighlightType = "Moved" SummaryHighlightTypeSplit SummaryHighlightType = "Split" + SummaryHighlightTypeDeleted SummaryHighlightType = "Deleted" ) type SplitChange struct { @@ -250,6 +253,10 @@ func (g FeatureDiffV1SummaryGenerator) calculateCategoriesAndText(d v1.FeatureDi parts = append(parts, fmt.Sprintf("%d features removed", len(d.Removed))) c.Removed = len(d.Removed) } + if len(d.Deleted) > 0 { + parts = append(parts, fmt.Sprintf("%d features deleted", len(d.Deleted))) + c.Deleted = len(d.Deleted) + } if len(d.Moves) > 0 { parts = append(parts, fmt.Sprintf("%d features moved/renamed", len(d.Moves))) c.Moved = len(d.Moves) @@ -298,6 +305,10 @@ func (g FeatureDiffV1SummaryGenerator) generateHighlights(d v1.FeatureDiff) ([]S return highlights, true } + if highlights, truncated = g.processDeleted(highlights, d.Deleted); truncated { + return highlights, true + } + if highlights, truncated = g.processMoves(highlights, d.Moves); truncated { return highlights, true } @@ -423,6 +434,28 @@ func (g FeatureDiffV1SummaryGenerator) processRemoved(highlights []SummaryHighli return highlights, false } +func (g FeatureDiffV1SummaryGenerator) processDeleted(highlights []SummaryHighlight, + deleted []v1.FeatureDeleted) ([]SummaryHighlight, bool) { + for _, r := range deleted { + if len(highlights) >= MaxHighlights { + return highlights, true + } + highlights = append(highlights, SummaryHighlight{ + Type: SummaryHighlightTypeDeleted, + FeatureID: r.ID, + FeatureName: r.Name, + DocLinks: nil, + Moved: nil, + Split: nil, + BaselineChange: nil, + NameChange: nil, + BrowserChanges: nil, + }) + } + + return highlights, false +} + func (g FeatureDiffV1SummaryGenerator) processMoves(highlights []SummaryHighlight, moves []v1.FeatureMoved) ([]SummaryHighlight, bool) { for _, m := range moves { @@ -524,6 +557,7 @@ func toBrowserValue(s v1.BrowserState) BrowserValue { val := BrowserValue{ Status: BrowserStatusUnknown, Version: nil, + Date: nil, } if s.Status.IsSet { switch s.Status.Value { @@ -537,6 +571,10 @@ func toBrowserValue(s v1.BrowserState) BrowserValue { val.Version = s.Version.Value } + if s.Date.IsSet { + val.Date = s.Date.Value + } + return val } diff --git a/lib/workertypes/types_test.go b/lib/workertypes/types_test.go index 3263072bb..b2919c09c 100644 --- a/lib/workertypes/types_test.go +++ b/lib/workertypes/types_test.go @@ -60,6 +60,7 @@ func TestParseEventSummary(t *testing.T) { QueryChanged: 0, Added: 0, Removed: 0, + Deleted: 0, Moved: 0, Split: 0, Updated: 0, @@ -121,6 +122,7 @@ func TestParseEventSummary(t *testing.T) { func TestGenerateJSONSummaryFeatureDiffV1(t *testing.T) { newlyDate := time.Date(2025, 1, 1, 0, 0, 0, 0, time.UTC) + browserImplDate := time.Date(2024, 1, 1, 0, 0, 0, 0, time.UTC) tests := []struct { name string diff v1.FeatureDiff @@ -136,6 +138,7 @@ func TestGenerateJSONSummaryFeatureDiffV1(t *testing.T) { Modified: nil, Moves: nil, Splits: nil, + Deleted: nil, }, expected: `{"schemaVersion":"v1","text":"No changes detected","truncated":false,"highlights":null}`, expectedError: nil, @@ -153,6 +156,9 @@ func TestGenerateJSONSummaryFeatureDiffV1(t *testing.T) { Removed: []v1.FeatureRemoved{ {ID: "3", Name: "C", Reason: v1.ReasonUnmatched}, }, + Deleted: []v1.FeatureDeleted{ + {ID: "4", Name: "D", Reason: v1.ReasonDeleted}, + }, Moves: []v1.FeatureMoved{ {FromID: "4", ToID: "5", FromName: "D", ToName: "E"}, }, @@ -192,7 +198,7 @@ func TestGenerateJSONSummaryFeatureDiffV1(t *testing.T) { Version: generic.UnsetOpt[*string](), }, To: v1.BrowserState{ Status: generic.SetOpt(v1.Available), - Date: generic.UnsetOpt[*time.Time](), + Date: generic.SetOpt(&browserImplDate), Version: generic.SetOpt(generic.ValuePtr("123")), }}, v1.ChromeAndroid: nil, @@ -220,11 +226,12 @@ func TestGenerateJSONSummaryFeatureDiffV1(t *testing.T) { expected: `{ "schemaVersion": "v1", "text": "Search criteria updated, 2 features added, 1 features removed, ` + - `1 features moved/renamed, 1 features split, 3 features updated", + `1 features deleted, 1 features moved/renamed, 1 features split, 3 features updated", "categories": { "query_changed": 1, "added": 2, "removed": 1, + "deleted": 1, "moved": 1, "split": 1, "updated": 3, @@ -258,6 +265,7 @@ func TestGenerateJSONSummaryFeatureDiffV1(t *testing.T) { "status": "unavailable" }, "to": { + "date": "2024-01-01T00:00:00Z", "status": "available", "version": "123" } @@ -294,6 +302,11 @@ func TestGenerateJSONSummaryFeatureDiffV1(t *testing.T) { "type": "Removed", "feature_id": "3", "feature_name": "C" + }, + { + "type": "Deleted", + "feature_id": "4", + "feature_name": "D" }, { "type": "Moved", diff --git a/workers/event_producer/pkg/producer/diff_test.go b/workers/event_producer/pkg/producer/diff_test.go index bfb265a48..348229a42 100644 --- a/workers/event_producer/pkg/producer/diff_test.go +++ b/workers/event_producer/pkg/producer/diff_test.go @@ -163,6 +163,7 @@ func TestV1DiffSerializer_Serialize(t *testing.T) { diff := &featurelistdiffv1.FeatureDiff{ QueryChanged: false, Added: []featurelistdiffv1.FeatureAdded{{ID: "feat-a", Name: "Feature A", Reason: "", Docs: nil}}, + Deleted: nil, Removed: nil, Modified: nil, Moves: nil, diff --git a/workers/push_delivery/pkg/dispatcher/dispatcher_test.go b/workers/push_delivery/pkg/dispatcher/dispatcher_test.go index 7d1fe8853..20db9fa6b 100644 --- a/workers/push_delivery/pkg/dispatcher/dispatcher_test.go +++ b/workers/push_delivery/pkg/dispatcher/dispatcher_test.go @@ -71,6 +71,7 @@ func createTestSummary(hasChanges bool) workertypes.EventSummary { categories := workertypes.SummaryCategories{ QueryChanged: 0, Added: 0, + Deleted: 0, Removed: 0, Moved: 0, Split: 0, @@ -429,10 +430,12 @@ func newBaselineValue(status workertypes.BaselineStatus) workertypes.BaselineVal func newBrowserValue(status workertypes.BrowserStatus) workertypes.BrowserValue { version := "100" + testDate := time.Date(2025, 1, 1, 0, 0, 0, 0, time.UTC) return workertypes.BrowserValue{ Status: status, Version: &version, + Date: &testDate, } } From f1c14b8f486a65ad8883dfe15da0bcbd3e9429c3 Mon Sep 17 00:00:00 2001 From: James Scott Date: Tue, 30 Dec 2025 21:32:26 +0000 Subject: [PATCH 18/27] feat(email): Refactor documentation handling in email templates This commit introduces a more structured approach to handling documentation links within the email templates. Previously, documentation links were represented by a `DocLinks []DocLink` slice, implicitly assuming all links were MDN. This limited the extensibility for future documentation sources. This change refactors the `DocLinks` field in `SummaryHighlight` to `Docs *Docs`, where `Docs` is a new struct containing `MDNDocs []DocLink`. This allows for explicit categorization of documentation types in the next PR. The `toDocLinks` function has been updated to construct and return the new `*Docs` struct. --- lib/workertypes/types.go | 28 +++++++++++-------- lib/workertypes/types_test.go | 16 ++++++----- .../pkg/dispatcher/dispatcher_test.go | 6 ++-- 3 files changed, 29 insertions(+), 21 deletions(-) diff --git a/lib/workertypes/types.go b/lib/workertypes/types.go index ab0bd3e62..a601576a6 100644 --- a/lib/workertypes/types.go +++ b/lib/workertypes/types.go @@ -113,6 +113,10 @@ type DocLink struct { Slug *string `json:"slug,omitempty"` } +type Docs struct { + MDNDocs []DocLink `json:"mdn_docs,omitempty"` +} + type BaselineStatus string const ( @@ -174,7 +178,7 @@ type SummaryHighlight struct { Type SummaryHighlightType `json:"type"` FeatureID string `json:"feature_id"` FeatureName string `json:"feature_name"` - DocLinks []DocLink `json:"doc_links,omitempty"` + Docs *Docs `json:"docs,omitempty"` // Strongly typed change fields to support i18n and avoid interface{} NameChange *Change[string] `json:"name_change,omitempty"` @@ -331,7 +335,7 @@ func (g FeatureDiffV1SummaryGenerator) processModified(highlights []SummaryHighl Type: SummaryHighlightTypeChanged, FeatureID: m.ID, FeatureName: m.Name, - DocLinks: toDocLinks(m.Docs), + Docs: toDocLinks(m.Docs), NameChange: nil, BaselineChange: nil, BrowserChanges: nil, @@ -400,7 +404,7 @@ func (g FeatureDiffV1SummaryGenerator) processAdded(highlights []SummaryHighligh Type: SummaryHighlightTypeAdded, FeatureID: a.ID, FeatureName: a.Name, - DocLinks: toDocLinks(a.Docs), + Docs: toDocLinks(a.Docs), NameChange: nil, BaselineChange: nil, BrowserChanges: nil, @@ -422,7 +426,7 @@ func (g FeatureDiffV1SummaryGenerator) processRemoved(highlights []SummaryHighli Type: SummaryHighlightTypeRemoved, FeatureID: r.ID, FeatureName: r.Name, - DocLinks: nil, + Docs: nil, Moved: nil, Split: nil, BaselineChange: nil, @@ -444,7 +448,7 @@ func (g FeatureDiffV1SummaryGenerator) processDeleted(highlights []SummaryHighli Type: SummaryHighlightTypeDeleted, FeatureID: r.ID, FeatureName: r.Name, - DocLinks: nil, + Docs: nil, Moved: nil, Split: nil, BaselineChange: nil, @@ -473,7 +477,7 @@ func (g FeatureDiffV1SummaryGenerator) processMoves(highlights []SummaryHighligh BrowserChanges: nil, BaselineChange: nil, NameChange: nil, - DocLinks: nil, + Docs: nil, Split: nil, }) } @@ -503,27 +507,29 @@ func (g FeatureDiffV1SummaryGenerator) processSplits(highlights []SummaryHighlig BrowserChanges: nil, BaselineChange: nil, NameChange: nil, - DocLinks: nil, + Docs: nil, }) } return highlights, false } -func toDocLinks(docs *v1.Docs) []DocLink { +func toDocLinks(docs *v1.Docs) *Docs { if docs == nil { return nil } - links := make([]DocLink, 0, len(docs.MdnDocs)) + ret := new(Docs) + mdnDocs := make([]DocLink, 0, len(docs.MdnDocs)) for _, d := range docs.MdnDocs { - links = append(links, DocLink{ + mdnDocs = append(mdnDocs, DocLink{ URL: d.URL, Title: d.Title, Slug: d.Slug, }) } + ret.MDNDocs = mdnDocs - return links + return ret } func toBaselineValue(s v1.BaselineState) BaselineValue { diff --git a/lib/workertypes/types_test.go b/lib/workertypes/types_test.go index b2919c09c..0d684dd89 100644 --- a/lib/workertypes/types_test.go +++ b/lib/workertypes/types_test.go @@ -290,13 +290,15 @@ func TestGenerateJSONSummaryFeatureDiffV1(t *testing.T) { "type": "Added", "feature_id": "2", "feature_name": "B", - "doc_links": [ - { - "url": "https://mdn.io/B", - "title": "B", - "slug": "slug-b" - } - ] + "docs": { + "mdn_docs": [ + { + "url": "https://mdn.io/B", + "title": "B", + "slug": "slug-b" + } + ] + } }, { "type": "Removed", diff --git a/workers/push_delivery/pkg/dispatcher/dispatcher_test.go b/workers/push_delivery/pkg/dispatcher/dispatcher_test.go index 20db9fa6b..d31da42c6 100644 --- a/workers/push_delivery/pkg/dispatcher/dispatcher_test.go +++ b/workers/push_delivery/pkg/dispatcher/dispatcher_test.go @@ -170,7 +170,7 @@ func TestProcessEvent_Success(t *testing.T) { Type: workertypes.SummaryHighlightTypeChanged, FeatureID: "test-feature-id", FeatureName: "Test Feature", - DocLinks: nil, + Docs: nil, NameChange: nil, BaselineChange: &workertypes.Change[workertypes.BaselineValue]{ From: newBaselineValue(workertypes.BaselineStatusLimited), @@ -445,7 +445,7 @@ func withBaselineHighlight( Type: workertypes.SummaryHighlightTypeChanged, FeatureID: "test-feature-id", FeatureName: "Test Feature", - DocLinks: nil, + Docs: nil, BaselineChange: &workertypes.Change[workertypes.BaselineValue]{ From: newBaselineValue(from), To: newBaselineValue(to), @@ -467,7 +467,7 @@ func withBrowserChangeHighlight( Type: workertypes.SummaryHighlightTypeChanged, FeatureID: "test-feature-id", FeatureName: "Test Feature", - DocLinks: nil, + Docs: nil, BaselineChange: nil, BrowserChanges: map[workertypes.BrowserName]*workertypes.Change[workertypes.BrowserValue]{ workertypes.BrowserChrome: { From fb692393847b4cdbec1f9bcfe802e43538996f15 Mon Sep 17 00:00:00 2001 From: James Scott Date: Tue, 30 Dec 2025 22:20:03 +0000 Subject: [PATCH 19/27] feat(email): Pass job triggers to email delivery jobs This commit enhances the email delivery job by including the associated job triggers. Previously, the `EmailDeliveryJob` struct did not contain trigger information, which limited the ability to customize email content based on why a notification was sent. This change introduces a `Triggers []JobTrigger` field to `lib/workertypes/EmailDeliveryJob` and `lib/event/emailjob/v1/EmailJobEvent`. Helper functions are added to `lib/event/emailjob/v1/types.go` for converting between internal `workertypes.JobTrigger` and `emailjob/v1.JobTrigger` types. Additionally, the `SummaryHighlight` struct in `lib/workertypes/types.go` now includes a `MatchesTrigger` method, encapsulating the logic for checking if a highlight satisfies a given trigger. This method is then utilized in `workers/push_delivery/pkg/dispatcher/dispatcher.go` within the `matchesTrigger` function, centralizing the trigger matching logic. The `EmailWorkerSubscriberAdapter` in `lib/gcppubsub/gcppubsubadapters/email_worker.go` and `PushDeliveryPublisher` in `lib/gcppubsub/gcppubsubadapters/push_delivery.go` are updated to correctly pass and receive these triggers. Corresponding tests have also been updated to reflect these changes. This refactoring allows the email worker to eventually render more dynamic and personalized email content based on the specific triggers that caused the notification. --- lib/event/emailjob/v1/types.go | 70 +++++++++++++++++++ .../gcppubsubadapters/email_worker.go | 1 + .../gcppubsubadapters/email_worker_test.go | 6 ++ .../gcppubsubadapters/push_delivery.go | 1 + .../gcppubsubadapters/push_delivery_test.go | 5 ++ lib/workertypes/types.go | 23 ++++++ workers/email/pkg/sender/sender_test.go | 5 ++ .../pkg/dispatcher/dispatcher.go | 26 +------ .../pkg/dispatcher/dispatcher_test.go | 1 + 9 files changed, 115 insertions(+), 23 deletions(-) diff --git a/lib/event/emailjob/v1/types.go b/lib/event/emailjob/v1/types.go index 50d803b98..45df72341 100644 --- a/lib/event/emailjob/v1/types.go +++ b/lib/event/emailjob/v1/types.go @@ -32,6 +32,8 @@ type EmailJobEvent struct { Metadata EmailJobEventMetadata `json:"metadata"` // ChannelID is the ID of the channel associated with this job. ChannelID string `json:"channel_id"` + // Triggers is a list of triggers associated with this job. + Triggers []JobTrigger `json:"triggers"` } type EmailJobEventMetadata struct { @@ -88,3 +90,71 @@ func ToJobFrequency(freq workertypes.JobFrequency) JobFrequency { return FrequencyUnknown } + +type JobTrigger string + +const ( + FeaturePromotedToNewly JobTrigger = "FEATURE_PROMOTED_TO_NEWLY" + FeaturePromotedToWidely JobTrigger = "FEATURE_PROMOTED_TO_WIDELY" + FeatureRegressedToLimited JobTrigger = "FEATURE_REGRESSED_TO_LIMITED" + BrowserImplementationAnyComplete JobTrigger = "BROWSER_IMPLEMENTATION_ANY_COMPLETE" + UnknownJobTrigger JobTrigger = "UNKNOWN" +) + +func (e EmailJobEvent) ToWorkerTypeJobTriggers() []workertypes.JobTrigger { + if e.Triggers == nil { + return nil + } + + triggers := make([]workertypes.JobTrigger, 0, len(e.Triggers)) + for _, t := range e.Triggers { + triggers = append(triggers, t.ToWorkerTypeJobTrigger()) + } + + return triggers +} + +func (t JobTrigger) ToWorkerTypeJobTrigger() workertypes.JobTrigger { + switch t { + case FeaturePromotedToNewly: + return workertypes.FeaturePromotedToNewly + case FeaturePromotedToWidely: + return workertypes.FeaturePromotedToWidely + case FeatureRegressedToLimited: + return workertypes.FeatureRegressedToLimited + case BrowserImplementationAnyComplete: + return workertypes.BrowserImplementationAnyComplete + case UnknownJobTrigger: + break + } + + return "" +} + +func ToJobTrigger(trigger workertypes.JobTrigger) JobTrigger { + switch trigger { + case workertypes.FeaturePromotedToNewly: + return FeaturePromotedToNewly + case workertypes.FeaturePromotedToWidely: + return FeaturePromotedToWidely + case workertypes.FeatureRegressedToLimited: + return FeatureRegressedToLimited + case workertypes.BrowserImplementationAnyComplete: + return BrowserImplementationAnyComplete + } + + return UnknownJobTrigger +} + +func ToJobTriggers(triggers []workertypes.JobTrigger) []JobTrigger { + if triggers == nil { + return nil + } + + jobTriggers := make([]JobTrigger, 0, len(triggers)) + for _, t := range triggers { + jobTriggers = append(jobTriggers, ToJobTrigger(t)) + } + + return jobTriggers +} diff --git a/lib/gcppubsub/gcppubsubadapters/email_worker.go b/lib/gcppubsub/gcppubsubadapters/email_worker.go index 4e56cabbc..cdcb88ddd 100644 --- a/lib/gcppubsub/gcppubsubadapters/email_worker.go +++ b/lib/gcppubsub/gcppubsubadapters/email_worker.go @@ -76,6 +76,7 @@ func (a *EmailWorkerSubscriberAdapter) handleEmailJobEvent( Frequency: event.Metadata.Frequency.ToWorkerTypeJobFrequency(), GeneratedAt: event.Metadata.GeneratedAt, }, + Triggers: event.ToWorkerTypeJobTriggers(), }, EmailEventID: msgID, } diff --git a/lib/gcppubsub/gcppubsubadapters/email_worker_test.go b/lib/gcppubsub/gcppubsubadapters/email_worker_test.go index 9ca6832be..0805f21cb 100644 --- a/lib/gcppubsub/gcppubsubadapters/email_worker_test.go +++ b/lib/gcppubsub/gcppubsubadapters/email_worker_test.go @@ -111,6 +111,9 @@ func TestEmailWorkerSubscriberAdapter_RoutesEmailJobEvent(t *testing.T) { Frequency: v1.FrequencyMonthly, GeneratedAt: now, }, + Triggers: []v1.JobTrigger{ + v1.BrowserImplementationAnyComplete, + }, } ceWrapper := map[string]interface{}{ @@ -145,6 +148,9 @@ func TestEmailWorkerSubscriberAdapter_RoutesEmailJobEvent(t *testing.T) { Frequency: workertypes.FrequencyMonthly, GeneratedAt: now, }, + Triggers: []workertypes.JobTrigger{ + workertypes.BrowserImplementationAnyComplete, + }, }, EmailEventID: msgID, } diff --git a/lib/gcppubsub/gcppubsubadapters/push_delivery.go b/lib/gcppubsub/gcppubsubadapters/push_delivery.go index c3a6703ea..0d4386747 100644 --- a/lib/gcppubsub/gcppubsubadapters/push_delivery.go +++ b/lib/gcppubsub/gcppubsubadapters/push_delivery.go @@ -49,6 +49,7 @@ func (p *PushDeliveryPublisher) PublishEmailJob(ctx context.Context, job workert Frequency: v1.ToJobFrequency(job.Metadata.Frequency), GeneratedAt: job.Metadata.GeneratedAt, }, + Triggers: v1.ToJobTriggers(job.Triggers), ChannelID: job.ChannelID, }) if err != nil { diff --git a/lib/gcppubsub/gcppubsubadapters/push_delivery_test.go b/lib/gcppubsub/gcppubsubadapters/push_delivery_test.go index e61598341..a138e2b61 100644 --- a/lib/gcppubsub/gcppubsubadapters/push_delivery_test.go +++ b/lib/gcppubsub/gcppubsubadapters/push_delivery_test.go @@ -112,6 +112,10 @@ func TestPushDeliveryPublisher_PublishEmailJob(t *testing.T) { GeneratedAt: time.Date(2025, 1, 1, 12, 0, 0, 0, time.UTC), }, ChannelID: "chan-1", + Triggers: []workertypes.JobTrigger{ + workertypes.FeaturePromotedToNewly, + workertypes.FeaturePromotedToWidely, + }, } err := publisher.PublishEmailJob(context.Background(), job) @@ -135,6 +139,7 @@ func TestPushDeliveryPublisher_PublishEmailJob(t *testing.T) { "subscription_id": "sub-1", "recipient_email": "test@example.com", "summary_raw": base64.StdEncoding.EncodeToString([]byte(`{"text": "Test Body"}`)), + "triggers": []any{"FEATURE_PROMOTED_TO_NEWLY", "FEATURE_PROMOTED_TO_WIDELY"}, "metadata": map[string]interface{}{ "event_id": "event-1", "search_id": "search-1", diff --git a/lib/workertypes/types.go b/lib/workertypes/types.go index a601576a6..a855e043c 100644 --- a/lib/workertypes/types.go +++ b/lib/workertypes/types.go @@ -96,6 +96,28 @@ type EventSummary struct { Highlights []SummaryHighlight `json:"highlights"` } +func (h SummaryHighlight) MatchesTrigger(t JobTrigger) bool { + switch t { + case FeaturePromotedToNewly: + return h.BaselineChange != nil && h.BaselineChange.To.Status == BaselineStatusNewly + case FeaturePromotedToWidely: + return h.BaselineChange != nil && h.BaselineChange.To.Status == BaselineStatusWidely + case FeatureRegressedToLimited: + return h.BaselineChange != nil && h.BaselineChange.To.Status == BaselineStatusLimited + case BrowserImplementationAnyComplete: + for _, change := range h.BrowserChanges { + if change == nil { + continue + } + if change.To.Status == BrowserStatusAvailable { + return true + } + } + } + + return false +} + // Change represents a value transition from Old to New. type Change[T any] struct { From T `json:"from"` @@ -679,6 +701,7 @@ type EmailDeliveryJob struct { SubscriptionID string RecipientEmail string ChannelID string + Triggers []JobTrigger // SummaryRaw is the opaque JSON payload describing the event. SummaryRaw []byte // Metadata contains context for links and tracking. diff --git a/workers/email/pkg/sender/sender_test.go b/workers/email/pkg/sender/sender_test.go index b4f8a886f..7a891bb9e 100644 --- a/workers/email/pkg/sender/sender_test.go +++ b/workers/email/pkg/sender/sender_test.go @@ -123,6 +123,9 @@ func TestProcessMessage_Success(t *testing.T) { RecipientEmail: "user@example.com", SummaryRaw: []byte("{}"), ChannelID: "chan-1", + Triggers: []workertypes.JobTrigger{ + workertypes.BrowserImplementationAnyComplete, + }, }, EmailEventID: "job-id", } @@ -178,6 +181,7 @@ func TestProcessMessage_RenderError(t *testing.T) { RecipientEmail: "user@example.com", SummaryRaw: []byte("{}"), ChannelID: "chan-1", + Triggers: nil, }, EmailEventID: "job-id", } @@ -220,6 +224,7 @@ func TestProcessMessage_SendError(t *testing.T) { RecipientEmail: "user@example.com", SummaryRaw: []byte("{}"), ChannelID: "chan-1", + Triggers: nil, }, EmailEventID: "job-id", } diff --git a/workers/push_delivery/pkg/dispatcher/dispatcher.go b/workers/push_delivery/pkg/dispatcher/dispatcher.go index aeb00a9ff..dbd889460 100644 --- a/workers/push_delivery/pkg/dispatcher/dispatcher.go +++ b/workers/push_delivery/pkg/dispatcher/dispatcher.go @@ -154,6 +154,7 @@ func (g *deliveryJobGenerator) VisitV1(s workertypes.EventSummary) error { SummaryRaw: g.rawSummary, Metadata: deliveryMetadata, ChannelID: sub.ChannelID, + Triggers: sub.Triggers, }) } @@ -202,29 +203,8 @@ func shouldNotifyV1(triggers []workertypes.JobTrigger, summary workertypes.Event func matchesTrigger(t workertypes.JobTrigger, summary workertypes.EventSummary) bool { for _, h := range summary.Highlights { - switch t { - case workertypes.FeaturePromotedToNewly: - if h.BaselineChange != nil && h.BaselineChange.To.Status == workertypes.BaselineStatusNewly { - return true - } - case workertypes.FeaturePromotedToWidely: - if h.BaselineChange != nil && h.BaselineChange.To.Status == workertypes.BaselineStatusWidely { - return true - } - case workertypes.FeatureRegressedToLimited: - if h.BaselineChange != nil && h.BaselineChange.To.Status == workertypes.BaselineStatusLimited { - return true - } - case workertypes.BrowserImplementationAnyComplete: - // BrowserChanges is a map, so we iterate values - for _, change := range h.BrowserChanges { - if change == nil { - continue - } - if change.To.Status == workertypes.BrowserStatusAvailable { - return true - } - } + if h.MatchesTrigger(t) { + return true } } diff --git a/workers/push_delivery/pkg/dispatcher/dispatcher_test.go b/workers/push_delivery/pkg/dispatcher/dispatcher_test.go index d31da42c6..813cc8e29 100644 --- a/workers/push_delivery/pkg/dispatcher/dispatcher_test.go +++ b/workers/push_delivery/pkg/dispatcher/dispatcher_test.go @@ -206,6 +206,7 @@ func TestProcessEvent_Success(t *testing.T) { SubscriptionID: "sub-1", RecipientEmail: "user1@example.com", SummaryRaw: summaryBytes, + Triggers: []workertypes.JobTrigger{workertypes.FeaturePromotedToNewly}, Metadata: workertypes.DeliveryMetadata{ EventID: eventID, SearchID: searchID, From b67a8e44bd0deefdbc554e558a9cb1c5bd68d38a Mon Sep 17 00:00:00 2001 From: James Scott Date: Tue, 30 Dec 2025 06:18:47 +0000 Subject: [PATCH 20/27] feat(email): Add email digest templates, styles, and components This commit introduces a comprehensive email delivery system, complete with structured templates, reusable components, and a dedicated styling system. New email templates, including a `defaultEmailTemplate`, have been added, leveraging Go's `html/template` package for dynamic content rendering. These templates are designed with a component-based architecture, making them modular and easy to maintain. A key feature of this commit is the introduction of reusable email components, such as badges, banners, and stat boxes. These components are defined in `workers/email/pkg/digest/components.go` and are designed to be easily configurable and reusable across different email types. To ensure a consistent and polished look, a dedicated styling system has been implemented in `workers/email/pkg/digest/styles.go`. This file contains all the CSS styles used in the email templates, defined as template snippets. This approach centralizes all styling information, making it easy to manage and update the visual appearance of the emails. A golden file, `digest.golden.html`, has been added to the test data to ensure that the email rendering remains consistent and predictable. The `.prettierignore` and `Makefile` have been updated to exclude this file from formatting and license checks. Additionally, a suite of email icons and logos has been added to `frontend/src/static/img/email` to enhance the visual appeal of the emails. --- .prettierignore | 1 + Makefile | 3 +- frontend/src/static/img/email/chrome.png | Bin 0 -> 4219 bytes frontend/src/static/img/email/edge.png | Bin 0 -> 4981 bytes frontend/src/static/img/email/firefox.png | Bin 0 -> 5896 bytes frontend/src/static/img/email/limited.png | Bin 0 -> 2060 bytes frontend/src/static/img/email/newly.png | Bin 0 -> 2088 bytes frontend/src/static/img/email/safari.png | Bin 0 -> 7115 bytes frontend/src/static/img/email/widely.png | Bin 0 -> 2152 bytes workers/email/pkg/digest/components.go | 230 ++++++++ workers/email/pkg/digest/renderer.go | 398 +++++++++++++ workers/email/pkg/digest/renderer_test.go | 525 ++++++++++++++++++ workers/email/pkg/digest/styles.go | 54 ++ workers/email/pkg/digest/templates.go | 162 ++++++ .../pkg/digest/testdata/digest.golden.html | 155 ++++++ 15 files changed, 1527 insertions(+), 1 deletion(-) create mode 100644 frontend/src/static/img/email/chrome.png create mode 100644 frontend/src/static/img/email/edge.png create mode 100644 frontend/src/static/img/email/firefox.png create mode 100644 frontend/src/static/img/email/limited.png create mode 100644 frontend/src/static/img/email/newly.png create mode 100644 frontend/src/static/img/email/safari.png create mode 100644 frontend/src/static/img/email/widely.png create mode 100644 workers/email/pkg/digest/components.go create mode 100644 workers/email/pkg/digest/renderer.go create mode 100644 workers/email/pkg/digest/renderer_test.go create mode 100644 workers/email/pkg/digest/styles.go create mode 100644 workers/email/pkg/digest/templates.go create mode 100644 workers/email/pkg/digest/testdata/digest.golden.html diff --git a/.prettierignore b/.prettierignore index 3573c80a9..bfb12f32a 100644 --- a/.prettierignore +++ b/.prettierignore @@ -7,6 +7,7 @@ jsonschema/mdn_browser-compat-data jsonschema/web-platform-dx_web-features jsonschema/web-platform-dx_web-features-mappings docs/schema +workers/email/pkg/digest/testdata/digest.golden.html workflows/steps/services/bcd_consumer/pkg/data/testdata/data.json workflows/steps/services/web_feature_consumer/pkg/data/testdata/v3.data.json workflows/steps/services/developer_signals_consumer/pkg/data/testdata/web-features-signals.json diff --git a/Makefile b/Makefile index e7aa27c9c..0410bfc46 100644 --- a/Makefile +++ b/Makefile @@ -337,7 +337,8 @@ ADDLICENSE_ARGS := -c "${COPYRIGHT_NAME}" \ -ignore 'node_modules/**' \ -ignore 'infra/storage/spanner/schema.sql' \ -ignore 'antlr/.antlr/**' \ - -ignore '.devcontainer/cache/**' + -ignore '.devcontainer/cache/**' \ + -ignore 'workers/email/pkg/digest/testdata/digest.golden.html' license-check: go-install-tools go tool addlicense -check $(ADDLICENSE_ARGS) . diff --git a/frontend/src/static/img/email/chrome.png b/frontend/src/static/img/email/chrome.png new file mode 100644 index 0000000000000000000000000000000000000000..eb9dc42ff3c90f6fc6efe5dca797e391bb8e50ee GIT binary patch literal 4219 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¨_6kPSo`Dza1Pqc4U0>cl!;P zuY28|3n(jlaQkbo{Pp|1)!}bt?-0Gz9JSmFEMA(>dzZG%6}?ysKQD8tD5|apB*WJ| zYrs5g&A(e&eY$ab&afIJ5`+k`etrHXB<5%T{p^;*gZ-ENBR1LnPB z`~I$t(}yw7^dW6NyZf0pcE6>cUR&M))?bK3XK6sG&&ThCam>CE^xpQRt!U%=;|xei zMIor=dgGo@C0kqie?E&h-N^nck*h%)<03Wh6lmkA*GWe&IyIkm)ufxHX1%(YRqv|x z(_?QPCAw5ex+v5@SL&G%`lzh{bFVgvfPyL!bs-2TRb*VL!`G=dzJh(1lO}CwpvuK- zFCgY9%;@NP&C+Muas>Lk1n$(CF}<_?N?fPMPBr>-@QP$i(MK78)=tQvph$o^2ZD;T zDzOHocV6>iGBAvifi~U`RClq8;~f~DNMR0LmXKxEtJ7!8*;l&00BC2fq}Cx+r#I?1 zfVp8FD+x$$p`=nmZ9>?rd{9MUwZ-9U}9kjGSGLWD?8^7y3y6S6R8qD;A41Z1^sJ>qR=+;3nt6nNyZ-p*O zQ#w6VDyzirR{0j2HzOko@LF73mIf@&IS_hvt(V?tRF^q2rbzs}@-;BDY#hP7-wQw0lmFzq8anVSVJ z)l(VQABBoai7fNA z+MZp3QimD*X z*h#Ns0@KM)GVi@x&AUHdQFcOPg`P@IMqwn;|gVC&fWAT@XQdEi}iK_KBP<_@f zyPry=)?Q81oJUza;r{p;d<*_(s{Q~kl1v1w!z}Ak=QQ9EDIcTeFDTwt74m0z8np?4 zf;9+#I3IndPZ{u6_=75+nwXSP*3Lg_0iw_P!9!~MAbY#CK`nKcxPZpBbNpa@%Nf`I z#Zy#03olo79d#RaQ&rRo2UR@~Pf+!a)O+Kdscnkh;g8eCr)Ah~=$lWB@qkU~fG@^- zS9z$IQ-)0lq&ll@{m_FAH@t>%i{2RuaS_TnAV$2Uy2E@q-~?{RNfgch0C4C9 ze=!|-`;Q&*zi@y2lv3+WSchPq?LFiw!;u?g+GX5UeU=u)-Bj*mZaus{x2pO!X}=`~ zOwOaRhQaxC<%|D%I{G#rHQ=XVfsk6iCoBtUR$B+{KH@EGzxH{w5rJiXuW81h=2Xq! zD*3UbHOwxcF&SJ)hhFk`^O3jy@CAay%W)Vgrj!w9&&9NBy6+>k6GyGCdlmaGQ=LE? z5R`jR6GpwO-oJqM+bym2d8b^2a^;8okc-vPxBHk86W$en%JOs|XhLMU8f?Acg;s~( zc)Ar@lQu}!AQaR&b5fgYs?Pabr%KhJ`i=VhiFlf1!0aM5X54nMI`q>Q_0s!yj!)d6u4n#qKwYE<*<)co;M zpKMs&rSOnLAMum!itcDa&@d-jl`j|H5FQPdRvb76P)b<}5^r_I{-UqM=;_R85t&Bc7d?DUP1OmfA+C1EL zbB#B@!Fc=4$=)tPYh)-1Ny1=Vby`oQezT_Ie~4We#)BK@{n7z^=nrZjCOs8I_@4a&G z{ttZ(j#+f&v;VR6X(#9?_mFbM(tvwfx4+c~H#E`F-31+b`X9FWiu*R3Uy%6!8o&mO zxBlQnB7!8KAR&Eb+D+y?nRdz@&a_)7_p<&UgC{PIYo5R^00000NkvXXu0mjf&vOiz literal 0 HcmV?d00001 diff --git a/frontend/src/static/img/email/firefox.png b/frontend/src/static/img/email/firefox.png new file mode 100644 index 0000000000000000000000000000000000000000..b7c3dd3644412afce47c1c871b149902b9200fb1 GIT binary patch literal 5896 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 literal 0 HcmV?d00001 diff --git a/frontend/src/static/img/email/newly.png b/frontend/src/static/img/email/newly.png new file mode 100644 index 0000000000000000000000000000000000000000..d163041813a5adcd8796332e381ebc2dc4c86fbb GIT binary patch literal 2088 zcmc(g`#)599L8siOY1UtX=~NlDQ35gMw7;7s$py+Q*ODnOD~%wr&(4Fi$)g)X|&2T zl_IsNRF;Y%mz6LL4Q4Q`l3E#eCJK}LZNKN(zhL+EYJd8i=ktD^&-4B5oON?$>S?di z#$YgdyLZ`pU@&SB27@K3f1c{}CJwcB|Yp_%sTYsQ4^qir9iPPc# za*j*W_U1qV_B~CN&$=PvTE5x}g5(RsD66~?s$^Nso&OS52=Tm|yyw_T z6Y%d)doKPXYG3Je{DFp+mo%7(#7g_`J|+Z11j8CKjm)- zhRf7_FGXsab@$4Lm6nvF&u;pwNn~ZlF_}6LMYXOh!3eQ(;>m28EPZ5@k9eN*;t|hh zYraH0@JZoX6c%1W;nj;M^z%XC&n%?N<<)yr#3M9zM7sQQuA^25$JhY)?dgyrv@$(U zBRMU~sAvEU(ke~RtDL1uu^)Psuc?hx(x~Pr^cJFb82u=u^lIRx^t^`49q%g+u;$-5 zPd^N`o8Rmi9Sq4Uq?bD&t(M%)z19-!e7f`WFhHiB(I+4|z43Ad7N51|a9syR>*F3u z@6lMCl9x7CT#218aGp+AaA(}_&AG7c7Qgab*h0;SzF-!!8j?rcn>)|8OGz8Eeun)q zu&zT}%S&JuvjLLty*KxeZTC4i@UMVHur+jWdrN7g*u<6;=AYha5aZZew?%AH&Khq9 zBl=8jVaOTA+^*K7H@du%giATd5VcCd;LH(Qi;eN4Y&SBS0(CVBeLG=lRg%Wjs(N-} zt83VKaE<)+xT&?+WWSGe?~yYME31J(Fdjdti9i%%Fac3@wJC`5P~JsUTU{Zd{NGh0 zYC!q|3`ilDw(Ih!Z;7o>jPapUPGk-!WM%k{f^Y@rbY`gjfFw^e~{S4%I1 zmy=FdMuxSuI?*Z{;qLK-6a1ZF^l5uy;$FZc!s4O;%8wv{Du z%qyf>&8s6df_%D2@3uSxSh}e`I1QS*BW+^{>TfiSIXVFU@q=m>PC`0k@uC-MkqBqk^^U_>o z5jvAiDfL7C*{kRD$sw=~#b#l8ZzXloFmG*C!>AHy(v&^UES< zx@O==a;;Ns?nXAO3M9UL`MkX!np~39aww0qquomzq=W2jkA@-xIQbmT7EJY)N zZK&HsVBHc>Z3LvcO}MR?2w!=c6+t`cGjW?lI|#Q)dV~gAncc$WOk09e=%@V{{m{`~ z)V=Il;D>AqcvenwhR?*KZ2@rG6XpNDTy&Y&lF#MZ#lYI@!2cbyd#9`Yqa6q0{stvT B^`-y- literal 0 HcmV?d00001 diff --git a/frontend/src/static/img/email/safari.png b/frontend/src/static/img/email/safari.png new file mode 100644 index 0000000000000000000000000000000000000000..ba9254d185141d611a71762670ff5ba2546fa5b0 GIT binary patch literal 7115 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 Q;LibTV@P +
+
{{.Title}}
+ {{- if .Description -}} +
{{.Description}}
+ {{- end -}} +
+ +{{- end -}}` + +const introTextComponent = `{{- define "intro_text" -}} +
+

{{.Subject}}

+
+ Here is your update for the saved search '{{.Query}}'. + {{.SummaryText}}. +
+
+{{- end -}}` + +const changeDetailComponent = `{{- define "change_detail" -}} +
+
+ {{.Label}} + ({{.From}} → {{.To}}) +
+
+{{- end -}}` + +const baselineChangeItemComponent = `{{- define "baseline_change_item" -}} +
+
+
+ {{.To}} +
+
+ Baseline + {{.To}} +
+
+
+
+
+ {{.FeatureName}} +
+ +
+
+
+{{- end -}}` + +const browserItemComponent = `{{- define "browser_item" -}} +
+
+
+ {{.Name}} +
+
+ {{.Name}}: {{ template "browser_status_detail" .From }} → {{ template "browser_status_detail" .To -}} +
+
+ {{- if .FeatureName -}} + + {{- end -}} +
+{{- end -}}` + +const buttonComponent = `{{- define "button" -}} + +{{- end -}}` + +const footerComponent = `{{- define "footer" -}} +
+
+
+ You can + unsubscribe + or change any of your alerts on + webstatus.dev +
+
+{{- end -}}` + +const bannerComponents = `{{- define "banner_baseline_widely" -}} +
+
+ Widely Available +
+
+ Baseline + Widely available +
+
+{{- end -}} +{{- define "banner_baseline_newly" -}} +
+
+ Newly Available +
+
+ Baseline + Newly available +
+
+{{- end -}} +{{- define "banner_baseline_regression" -}} +
+
+ Regressed +
+
+ Regressed + to limited availability +
+
+{{- end -}} +{{- define "banner_browser_implementation" -}} +
+
+ {{- /* Always display the 4 main browser logos as requested */ -}} +
+ +
+
+ +
+
+ +
+
+ +
+
+
Browser support changed
+
+{{- end -}} +{{- define "banner_generic" -}} +
+
+ {{.Type}} +
+
+{{- end -}}` +const featureTitleRowComponent = `{{- define "feature_title_row" -}} +
+
+ {{.Name}} + {{- with .Docs -}} + {{- if .MDNDocs -}} + ( + {{- range $i, $doc := .MDNDocs }} + {{- if $i }}, {{ end -}} + MDN + {{- end -}} + ) + {{- end -}} + {{- end -}} +
+ {{- if .Date -}} +
{{.Date}}
+ {{- end -}} +
+{{- end -}}` + +const browserStatusDetailComponent = `{{- define "browser_status_detail" -}} + {{- formatBrowserStatus .Status -}} + {{- if .Version -}} + in {{.Version}} + {{- end -}} + {{- if .Date -}} + (on {{ formatDate .Date -}}) + {{- end -}} +{{- end -}}` + +const EmailComponents = badgeComponent + + introTextComponent + + changeDetailComponent + + baselineChangeItemComponent + + browserItemComponent + + buttonComponent + + footerComponent + + bannerComponents + + featureTitleRowComponent + + browserStatusDetailComponent diff --git a/workers/email/pkg/digest/renderer.go b/workers/email/pkg/digest/renderer.go new file mode 100644 index 000000000..c80d75b3f --- /dev/null +++ b/workers/email/pkg/digest/renderer.go @@ -0,0 +1,398 @@ +// 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 digest + +import ( + "bytes" + "errors" + "fmt" + "html/template" + "strings" + "time" + + "github.com/GoogleChrome/webstatus.dev/lib/workertypes" +) + +// HTMLRenderer implements the email_handler.TemplateRenderer interface. +type HTMLRenderer struct { + tmpl *template.Template + webStatusBaseURL string +} + +// NewHTMLRenderer creates a new renderer with the default template. +func NewHTMLRenderer(webStatusBaseURL string) (*HTMLRenderer, error) { + r := &HTMLRenderer{ + webStatusBaseURL: webStatusBaseURL, + tmpl: nil, + } + + // Register helper functions for the template + funcMap := template.FuncMap{ + "toLower": strings.ToLower, + "browserLogoURL": r.browserLogoURL, + "browserDisplayName": r.browserDisplayName, + "statusLogoURL": r.statusLogoURL, + "dict": dict, + "list": list, + "append": appendList, + "formatDate": formatDate, + "formatBrowserStatus": r.formatBrowserStatus, + "formatBaselineStatus": r.formatBaselineStatus, + "badgeBackgroundColor": badgeBackgroundColor, + } + + // Parse both the components and the main template + tmpl, err := template.New("email").Funcs(funcMap).Parse( + EmailStyles + componentStyles + EmailComponents + defaultEmailTemplate) + if err != nil { + return nil, fmt.Errorf("failed to parse email templates: %w", err) + } + r.tmpl = tmpl + + return r, nil +} + +// dict helper function to creating maps in templates. +func dict(values ...any) (map[string]any, error) { + if len(values)%2 != 0 { + return nil, errors.New("invalid dict call: odd number of arguments") + } + dict := make(map[string]any, len(values)/2) + for i := 0; i < len(values); i += 2 { + key, ok := values[i].(string) + if !ok { + return nil, errors.New("dict keys must be strings") + } + dict[key] = values[i+1] + } + + return dict, nil +} + +func list(values ...any) []any { + return values +} + +func appendList(l []any, v any) []any { + return append(l, v) +} + +func formatDate(t *time.Time) string { + if t == nil { + return "" + } + + return t.Format("2006-01-02") +} + +func (r *HTMLRenderer) formatBrowserStatus(status workertypes.BrowserStatus) string { + switch status { + case workertypes.BrowserStatusAvailable: + return "Available" + case workertypes.BrowserStatusUnavailable: + return "Unavailable" + case workertypes.BrowserStatusUnknown: + break + } + + return "Unknown" +} + +func (r *HTMLRenderer) formatBaselineStatus(status workertypes.BaselineStatus) string { + switch status { + case workertypes.BaselineStatusLimited: + return "Limited" + case workertypes.BaselineStatusNewly: + return "Newly" + case workertypes.BaselineStatusWidely: + return "Widely" + case workertypes.BaselineStatusUnknown: + break + } + + return "Unknown" +} + +func badgeBackgroundColor(title string) string { + switch title { + case "Added": + return "#E6F4EA" // Green + case "Removed": + return "#E4E4E7" // Neutral Gray + case "Deleted": + return "#FCE8E6" // Red + default: + return "#E8F0FE" // Default: Moved/Split (Blue-ish) + } +} + +// templateData is the struct passed to the HTML template. +type BrowserChangeRenderData struct { + BrowserName workertypes.BrowserName + Change *workertypes.Change[workertypes.BrowserValue] + FeatureName string + FeatureID string +} + +type templateData struct { + Subject string + Query string + SummaryText string + BaselineNewlyChanges []workertypes.SummaryHighlight + BaselineWidelyChanges []workertypes.SummaryHighlight + BaselineRegressionChanges []workertypes.SummaryHighlight + AllBrowserChanges []BrowserChangeRenderData + AddedFeatures []workertypes.SummaryHighlight + RemovedFeatures []workertypes.SummaryHighlight + DeletedFeatures []workertypes.SummaryHighlight + MovedFeatures []workertypes.SummaryHighlight + SplitFeatures []workertypes.SummaryHighlight + Truncated bool + BaseURL string + UnsubscribeURL string +} + +// RenderDigest processes the delivery job and returns the subject and HTML body. +func (r *HTMLRenderer) RenderDigest(job workertypes.IncomingEmailDeliveryJob) (string, string, error) { + // 1. Generate Subject + subject := r.generateSubject(job.Metadata.Frequency, job.Metadata.Query) + + // 2. Prepare Template Data using the visitor + generator := new(templateDataGenerator) + generator.job = job + generator.baseURL = r.webStatusBaseURL + generator.subject = subject + + if err := workertypes.ParseEventSummary(job.SummaryRaw, generator); err != nil { + return "", "", fmt.Errorf("failed to parse event summary: %w", err) + } + + // 3. Render Body + var body bytes.Buffer + if err := r.tmpl.Execute(&body, generator.data); err != nil { + return "", "", fmt.Errorf("failed to execute email template: %w", err) + } + + return subject, body.String(), nil +} + +// templateDataGenerator implements workertypes.SummaryVisitor to prepare the data for the template. +type templateDataGenerator struct { + job workertypes.IncomingEmailDeliveryJob + subject string + baseURL string + data templateData +} + +// VisitV1 is called when a V1 summary is parsed. +func (g *templateDataGenerator) VisitV1(summary workertypes.EventSummary) error { + g.data = templateData{ + Subject: g.subject, + Query: g.job.Metadata.Query, + SummaryText: summary.Text, + Truncated: summary.Truncated, + BaseURL: g.baseURL, + UnsubscribeURL: fmt.Sprintf("%s/subscriptions/%s?action=unsubscribe", + g.baseURL, g.job.SubscriptionID), + BaselineNewlyChanges: nil, + BaselineWidelyChanges: nil, + BaselineRegressionChanges: nil, + AllBrowserChanges: nil, + AddedFeatures: nil, + RemovedFeatures: nil, + DeletedFeatures: nil, + SplitFeatures: nil, + MovedFeatures: nil, + } + // 2. Filter Content (Content Filtering) + // We only show highlights that match the user's specific triggers. + filteredHighlights := filterHighlights(summary.Highlights, g.job.Triggers) + if len(filteredHighlights) != 0 { + // As long as we have some filtered highlights, override it. + // This should be the common case unless there's some logic error. + summary.Highlights = filteredHighlights + } + + g.categorizeHighlights(summary.Highlights) + + return nil +} + +func (g *templateDataGenerator) categorizeHighlights(highlights []workertypes.SummaryHighlight) { + for _, highlight := range highlights { + g.processHighlight(highlight) + } +} + +func (g *templateDataGenerator) processHighlight(highlight workertypes.SummaryHighlight) { + g.routeHighlightToCategory(highlight) +} + +func (g *templateDataGenerator) routeHighlightToCategory(highlight workertypes.SummaryHighlight) { + switch highlight.Type { + case workertypes.SummaryHighlightTypeMoved: + g.data.MovedFeatures = append(g.data.MovedFeatures, highlight) + case workertypes.SummaryHighlightTypeSplit: + g.data.SplitFeatures = append(g.data.SplitFeatures, highlight) + case workertypes.SummaryHighlightTypeAdded: + g.data.AddedFeatures = append(g.data.AddedFeatures, highlight) + case workertypes.SummaryHighlightTypeRemoved: + g.data.RemovedFeatures = append(g.data.RemovedFeatures, highlight) + case workertypes.SummaryHighlightTypeDeleted: + g.data.DeletedFeatures = append(g.data.DeletedFeatures, highlight) + case workertypes.SummaryHighlightTypeChanged: + g.processChangedData(highlight) + } +} + +func (g *templateDataGenerator) processChangedData(highlight workertypes.SummaryHighlight) { + // Consolidate browser changes into their own list + if len(highlight.BrowserChanges) > 0 { + for browserName, change := range highlight.BrowserChanges { + // If a feature regresses AND loses a browser impl, it will be in two sections. + if change == nil { + continue + } + g.data.AllBrowserChanges = append(g.data.AllBrowserChanges, BrowserChangeRenderData{ + BrowserName: browserName, + Change: change, + FeatureName: highlight.FeatureName, + FeatureID: highlight.FeatureID, + }) + } + } + + if highlight.BaselineChange != nil { + g.processBaselineChange(highlight) + } +} + +func (g *templateDataGenerator) processBaselineChange(highlight workertypes.SummaryHighlight) { + switch highlight.BaselineChange.To.Status { + case workertypes.BaselineStatusNewly: + g.data.BaselineNewlyChanges = append(g.data.BaselineNewlyChanges, highlight) + case workertypes.BaselineStatusWidely: + g.data.BaselineWidelyChanges = append(g.data.BaselineWidelyChanges, highlight) + case workertypes.BaselineStatusLimited: + g.data.BaselineRegressionChanges = append(g.data.BaselineRegressionChanges, highlight) + case workertypes.BaselineStatusUnknown: + // Do nothing + } +} + +func filterHighlights( + highlights []workertypes.SummaryHighlight, triggers []workertypes.JobTrigger) []workertypes.SummaryHighlight { + // If no triggers are specified (e.g. legacy or "all"), return everything. + if len(triggers) == 0 { + return highlights + } + + var filtered []workertypes.SummaryHighlight + for _, h := range highlights { + matched := false + for _, t := range triggers { + if h.MatchesTrigger(t) { + matched = true + + break + } + } + if matched { + filtered = append(filtered, h) + } + } + + return filtered +} + +func (r *HTMLRenderer) generateSubject(frequency workertypes.JobFrequency, query string) string { + prefix := "Update:" + switch frequency { + case workertypes.FrequencyWeekly: + prefix = "Weekly Digest:" + case workertypes.FrequencyMonthly: + prefix = "Monthly Digest:" + case workertypes.FrequencyImmediate: + // Do nothing + case workertypes.FrequencyUnknown: + // Do nothing + } + + displayQuery := query + if len(displayQuery) > 50 { + displayQuery = displayQuery[:47] + "..." + } + + return fmt.Sprintf("%s %s", prefix, displayQuery) +} + +// browserToString helps handle the any passed from templates which could be +// string or workertypes.BrowserName. +func (r *HTMLRenderer) browserToString(browser any) string { + switch v := browser.(type) { + case string: + return v + case workertypes.BrowserName: + return string(v) + default: + return fmt.Sprintf("%v", v) + } +} + +// browserLogoURL returns the URL for the browser logo. +// Maps mobile browsers to their desktop equivalents since we share logos. +func (r *HTMLRenderer) browserLogoURL(browser any) string { + b := strings.ToLower(r.browserToString(browser)) + + switch b { + case "chrome_android": + b = "chrome" + case "firefox_android": + b = "firefox" + case "safari_ios": + b = "safari" + } + + return fmt.Sprintf("%s/public/img/email/%s.png", r.webStatusBaseURL, b) +} + +// browserDisplayName returns a human-readable name for the browser. +func (r *HTMLRenderer) browserDisplayName(browser any) string { + b := strings.ToLower(r.browserToString(browser)) + + switch b { + case "chrome": + return "Chrome" + case "chrome_android": + return "Chrome Android" + case "edge": + return "Edge" + case "firefox": + return "Firefox" + case "firefox_android": + return "Firefox Android" + case "safari": + return "Safari" + case "safari_ios": + return "Safari iOS" + } + // Fallback for unknown + return r.browserToString(browser) +} + +func (r *HTMLRenderer) statusLogoURL(status string) string { + return fmt.Sprintf("%s/public/img/email/%s.png", r.webStatusBaseURL, strings.ToLower(status)) + +} diff --git a/workers/email/pkg/digest/renderer_test.go b/workers/email/pkg/digest/renderer_test.go new file mode 100644 index 000000000..e3b0153dc --- /dev/null +++ b/workers/email/pkg/digest/renderer_test.go @@ -0,0 +1,525 @@ +// 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 digest + +import ( + "encoding/json" + "flag" + "os" + "path/filepath" + "testing" + "time" + + "github.com/GoogleChrome/webstatus.dev/lib/generic" + "github.com/GoogleChrome/webstatus.dev/lib/workertypes" + "github.com/google/go-cmp/cmp" +) + +// nolint:gochecknoglobals // WONTFIX - used for testing only +var updateGolden = flag.Bool("update", false, "update golden files") + +func TestRenderDigest_Golden(t *testing.T) { + newlyDate := time.Date(2025, 1, 1, 0, 0, 0, 0, time.UTC) + widelyDate := time.Date(2025, 12, 27, 0, 0, 0, 0, time.UTC) + + // Setup complex test data to exercise all templates + summary := workertypes.EventSummary{ + SchemaVersion: "v1", + Text: "11 features changed", + Categories: workertypes.SummaryCategories{ + Updated: 5, + Added: 2, + Removed: 1, + Moved: 1, + Split: 1, + Deleted: 1, + QueryChanged: 0, + UpdatedImpl: 0, + UpdatedRename: 0, + UpdatedBaseline: 3, + }, + Truncated: false, + Highlights: []workertypes.SummaryHighlight{ + { + // Case 1: Baseline Widely (with multiple docs) + Type: workertypes.SummaryHighlightTypeChanged, + FeatureName: "Container queries", + FeatureID: "container-queries", + Docs: &workertypes.Docs{ + MDNDocs: []workertypes.DocLink{ + {URL: "https://developer.mozilla.org/docs/Web/CSS/CSS_Container_Queries", Title: nil, Slug: nil}, + {URL: "https://developer.mozilla.org/docs/Web/CSS/container-queries", Title: nil, Slug: nil}, + }, + }, + BaselineChange: &workertypes.Change[workertypes.BaselineValue]{ + From: workertypes.BaselineValue{Status: workertypes.BaselineStatusNewly, LowDate: &newlyDate, + HighDate: nil}, + To: workertypes.BaselineValue{Status: workertypes.BaselineStatusWidely, LowDate: &newlyDate, + HighDate: &widelyDate}, + }, + NameChange: nil, + BrowserChanges: nil, + Moved: nil, + Split: nil, + }, + { + // Case 2: Baseline Newly + Type: workertypes.SummaryHighlightTypeChanged, + FeatureName: "Newly Available Feature", + FeatureID: "newly-feature", + BaselineChange: &workertypes.Change[workertypes.BaselineValue]{ + From: workertypes.BaselineValue{Status: workertypes.BaselineStatusLimited, LowDate: nil, + HighDate: nil}, + To: workertypes.BaselineValue{Status: workertypes.BaselineStatusNewly, LowDate: &newlyDate, + HighDate: nil}, + }, + Docs: nil, + NameChange: nil, + BrowserChanges: nil, + Moved: nil, + Split: nil, + }, + { + // Case 3: Baseline Regression + Type: workertypes.SummaryHighlightTypeChanged, + FeatureName: "Regressed Feature", + FeatureID: "regressed-feature", + BaselineChange: &workertypes.Change[workertypes.BaselineValue]{ + From: workertypes.BaselineValue{Status: workertypes.BaselineStatusWidely, + LowDate: &newlyDate, HighDate: &widelyDate}, + To: workertypes.BaselineValue{Status: workertypes.BaselineStatusLimited, + LowDate: nil, HighDate: nil}, + }, + BrowserChanges: map[workertypes.BrowserName]*workertypes.Change[workertypes.BrowserValue]{ + workertypes.BrowserChrome: { + From: workertypes.BrowserValue{Status: workertypes.BrowserStatusAvailable, + Version: generic.ValuePtr("120"), Date: nil}, + To: workertypes.BrowserValue{Status: workertypes.BrowserStatusUnavailable, Version: nil, + Date: nil}, + }, + workertypes.BrowserFirefox: nil, + workertypes.BrowserChromeAndroid: nil, + workertypes.BrowserEdge: nil, + workertypes.BrowserFirefoxAndroid: nil, + workertypes.BrowserSafari: nil, + workertypes.BrowserSafariIos: nil, + }, + Docs: nil, + NameChange: nil, + Moved: nil, + Split: nil, + }, + { + // Case 4: Browser Implementation with version + Type: workertypes.SummaryHighlightTypeChanged, + FeatureName: "content-visibility", + FeatureID: "content-visibility", + BrowserChanges: map[workertypes.BrowserName]*workertypes.Change[workertypes.BrowserValue]{ + workertypes.BrowserSafariIos: { + From: workertypes.BrowserValue{Status: workertypes.BrowserStatusUnavailable, Version: nil, + Date: nil}, + To: workertypes.BrowserValue{Status: workertypes.BrowserStatusAvailable, + Version: generic.ValuePtr("17.2"), + // Purposefully set to nil to test that it doesn't crash. + Date: nil}, + }, + workertypes.BrowserChrome: nil, + workertypes.BrowserChromeAndroid: nil, + workertypes.BrowserEdge: nil, + workertypes.BrowserFirefoxAndroid: nil, + workertypes.BrowserSafari: nil, + workertypes.BrowserFirefox: nil, + }, + Docs: nil, + NameChange: nil, + BaselineChange: nil, + Moved: nil, + Split: nil, + }, + { + // Case 5: Browser Implementation with date + Type: workertypes.SummaryHighlightTypeChanged, + FeatureName: "another-feature", + FeatureID: "another-feature", + BrowserChanges: map[workertypes.BrowserName]*workertypes.Change[workertypes.BrowserValue]{ + workertypes.BrowserChrome: { + From: workertypes.BrowserValue{Status: workertypes.BrowserStatusUnavailable, + Date: nil, Version: nil}, + To: workertypes.BrowserValue{Status: workertypes.BrowserStatusAvailable, Date: &newlyDate, + // Purposefully set to nil so that we can see that it doesn't crash. + Version: nil}, + }, + workertypes.BrowserFirefox: nil, + workertypes.BrowserChromeAndroid: nil, + workertypes.BrowserEdge: nil, + workertypes.BrowserFirefoxAndroid: nil, + workertypes.BrowserSafari: nil, + workertypes.BrowserSafariIos: nil, + }, + Docs: nil, + NameChange: nil, + BaselineChange: nil, + Moved: nil, + Split: nil, + }, + { + // Case 6: Added + Type: workertypes.SummaryHighlightTypeAdded, + FeatureName: "New Feature", + FeatureID: "new-feature", + Docs: nil, + NameChange: nil, + BaselineChange: nil, + BrowserChanges: nil, + Moved: nil, + Split: nil, + }, + { + // Case 6b: Another Added + Type: workertypes.SummaryHighlightTypeAdded, + FeatureName: "Another New Feature", + FeatureID: "another-new-feature", + Docs: nil, + NameChange: nil, + BaselineChange: nil, + BrowserChanges: nil, + Moved: nil, + Split: nil, + }, + { + // Case 7: Removed + Type: workertypes.SummaryHighlightTypeRemoved, + FeatureName: "Removed Feature", + FeatureID: "removed-feature", + Docs: nil, + NameChange: nil, + BaselineChange: nil, + BrowserChanges: nil, + Moved: nil, + Split: nil, + }, + { + // Case 8: Moved + Type: workertypes.SummaryHighlightTypeMoved, + FeatureName: "New Cool Name", + FeatureID: "new-cool-name", + Moved: &workertypes.Change[workertypes.FeatureRef]{ + From: workertypes.FeatureRef{ID: "old-name", Name: "Old Name"}, + To: workertypes.FeatureRef{ID: "new-cool-name", Name: "New Cool Name"}, + }, + Docs: nil, + NameChange: nil, + BaselineChange: nil, + BrowserChanges: nil, + Split: nil, + }, + { + // Case 9: Split + Type: workertypes.SummaryHighlightTypeSplit, + FeatureName: "Feature To Split", + FeatureID: "feature-to-split", + Split: &workertypes.SplitChange{ + From: workertypes.FeatureRef{ID: "feature-to-split", Name: "Feature To Split"}, + To: []workertypes.FeatureRef{ + {ID: "sub-feature-1", Name: "Sub Feature 1"}, + {ID: "sub-feature-2", Name: "Sub Feature 2"}, + }, + }, + Docs: nil, + NameChange: nil, + BaselineChange: nil, + BrowserChanges: nil, + Moved: nil, + }, + { + // Case 10: Browser Implementation with version and date + Type: workertypes.SummaryHighlightTypeChanged, + FeatureName: "new-browser-feature", + FeatureID: "new-browser-feature", + BrowserChanges: map[workertypes.BrowserName]*workertypes.Change[workertypes.BrowserValue]{ + workertypes.BrowserFirefox: { + From: workertypes.BrowserValue{Status: workertypes.BrowserStatusUnavailable, + Version: nil, Date: nil}, + To: workertypes.BrowserValue{Status: workertypes.BrowserStatusAvailable, + Version: generic.ValuePtr("123"), Date: &newlyDate}, + }, + workertypes.BrowserChrome: nil, + workertypes.BrowserChromeAndroid: nil, + workertypes.BrowserEdge: nil, + workertypes.BrowserFirefoxAndroid: nil, + workertypes.BrowserSafari: nil, + workertypes.BrowserSafariIos: nil, + }, + NameChange: nil, + BaselineChange: nil, + Moved: nil, + Split: nil, + Docs: nil, + }, + { + // Case 11: Deleted + Type: workertypes.SummaryHighlightTypeDeleted, + FeatureName: "Deleted Feature", + FeatureID: "deleted-feature", + Docs: nil, + NameChange: nil, + BaselineChange: nil, + BrowserChanges: nil, + Moved: nil, + Split: nil, + }, + }, + } + summaryBytes, _ := json.Marshal(summary) + + job := workertypes.IncomingEmailDeliveryJob{ + EmailDeliveryJob: workertypes.EmailDeliveryJob{ + SummaryRaw: summaryBytes, + RecipientEmail: "rick@example.com", + SubscriptionID: "sub-123", + ChannelID: "chan-1", + Metadata: workertypes.DeliveryMetadata{ + Query: "group:css", + Frequency: workertypes.FrequencyWeekly, + EventID: "evt-123", + SearchID: "s-1", + GeneratedAt: time.Now(), + }, + Triggers: nil, + }, + EmailEventID: "email-event-id", + } + + // Initialize Renderer + renderer, err := NewHTMLRenderer("http://localhost:5555") + if err != nil { + t.Fatalf("NewHTMLRenderer failed: %v", err) + } + + // Execute + _, body, err := renderer.RenderDigest(job) + if err != nil { + t.Fatalf("RenderDigest failed: %v", err) + } + + goldenFile := filepath.Join("testdata", "digest.golden.html") + + if *updateGolden { + if err := os.MkdirAll("testdata", 0755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(goldenFile, []byte(body), 0600); err != nil { + t.Fatal(err) + } + } + + expected, err := os.ReadFile(goldenFile) + if err != nil { + t.Fatalf("failed to read golden file: %v", err) + } + + if diff := cmp.Diff(string(expected), body); diff != "" { + t.Errorf("HTML mismatch (-want +got):\n%s", diff) + } +} + +func TestFilterHighlights(t *testing.T) { + newlyDate := time.Date(2025, 1, 1, 0, 0, 0, 0, time.UTC) + widelyDate := time.Date(2025, 12, 27, 0, 0, 0, 0, time.UTC) + availableDate := time.Date(2025, 12, 28, 0, 0, 0, 0, time.UTC) + + // Reusable highlight definitions + hNewly := workertypes.SummaryHighlight{ + Type: workertypes.SummaryHighlightTypeChanged, + FeatureID: "h1", + FeatureName: "Newly Feature", + BaselineChange: &workertypes.Change[workertypes.BaselineValue]{ + From: workertypes.BaselineValue{Status: workertypes.BaselineStatusLimited, LowDate: nil, HighDate: nil}, + To: workertypes.BaselineValue{Status: workertypes.BaselineStatusNewly, LowDate: &newlyDate, + HighDate: nil}, + }, + Docs: nil, + NameChange: nil, + BrowserChanges: nil, + Moved: nil, + Split: nil, + } + hWidely := workertypes.SummaryHighlight{ + Type: workertypes.SummaryHighlightTypeChanged, + FeatureID: "h2", + FeatureName: "Widely Feature", + BaselineChange: &workertypes.Change[workertypes.BaselineValue]{ + From: workertypes.BaselineValue{Status: workertypes.BaselineStatusNewly, LowDate: &newlyDate, + HighDate: nil}, + To: workertypes.BaselineValue{Status: workertypes.BaselineStatusWidely, LowDate: &newlyDate, + HighDate: &widelyDate}, + }, + Docs: nil, + NameChange: nil, + BrowserChanges: nil, + Moved: nil, + Split: nil, + } + hRegression := workertypes.SummaryHighlight{ + Type: workertypes.SummaryHighlightTypeChanged, + FeatureID: "h3", + FeatureName: "Regression Feature", + BaselineChange: &workertypes.Change[workertypes.BaselineValue]{ + From: workertypes.BaselineValue{Status: workertypes.BaselineStatusWidely, LowDate: &newlyDate, + HighDate: &widelyDate}, + To: workertypes.BaselineValue{Status: workertypes.BaselineStatusLimited, LowDate: nil, HighDate: nil}, + }, + Docs: nil, + NameChange: nil, + BrowserChanges: nil, + Moved: nil, + Split: nil, + } + hBrowser := workertypes.SummaryHighlight{ + Type: workertypes.SummaryHighlightTypeChanged, + FeatureID: "h4", + FeatureName: "Browser Feature", + BrowserChanges: map[workertypes.BrowserName]*workertypes.Change[workertypes.BrowserValue]{ + workertypes.BrowserChrome: { + From: workertypes.BrowserValue{Status: workertypes.BrowserStatusUnavailable, Version: nil, Date: nil}, + To: workertypes.BrowserValue{Status: workertypes.BrowserStatusAvailable, Version: nil, + Date: &availableDate}, + }, + workertypes.BrowserEdge: nil, + workertypes.BrowserFirefox: nil, + workertypes.BrowserSafari: nil, + workertypes.BrowserChromeAndroid: nil, + workertypes.BrowserFirefoxAndroid: nil, + workertypes.BrowserSafariIos: nil, + }, + BaselineChange: nil, + Docs: nil, + NameChange: nil, + Moved: nil, + Split: nil, + } + hGenericAdded := workertypes.SummaryHighlight{ + Type: workertypes.SummaryHighlightTypeAdded, + FeatureID: "h5", + FeatureName: "Generic Added", + Docs: nil, + NameChange: nil, + BaselineChange: nil, + BrowserChanges: nil, + Moved: nil, + Split: nil, + } + + allHighlights := []workertypes.SummaryHighlight{hNewly, hWidely, hRegression, hBrowser, hGenericAdded} + + tests := []struct { + name string + triggers []workertypes.JobTrigger + // IDs of expected highlights + wantIDs []string + }{ + { + name: "No Triggers (Default) - Should Return All", + triggers: nil, + wantIDs: []string{"h1", "h2", "h3", "h4", "h5"}, + }, + { + name: "Empty Triggers List - Should Return All (Same as nil)", + triggers: []workertypes.JobTrigger{}, + wantIDs: []string{"h1", "h2", "h3", "h4", "h5"}, + }, + { + name: "Newly Trigger", + triggers: []workertypes.JobTrigger{workertypes.FeaturePromotedToNewly}, + wantIDs: []string{"h1"}, + }, + { + name: "Widely Trigger", + triggers: []workertypes.JobTrigger{workertypes.FeaturePromotedToWidely}, + wantIDs: []string{"h2"}, + }, + { + name: "Regression Trigger", + triggers: []workertypes.JobTrigger{workertypes.FeatureRegressedToLimited}, + wantIDs: []string{"h3"}, + }, + { + name: "Browser Implementation Trigger", + triggers: []workertypes.JobTrigger{workertypes.BrowserImplementationAnyComplete}, + wantIDs: []string{"h4"}, + }, + { + name: "Multiple Triggers (Newly + Widely)", + triggers: []workertypes.JobTrigger{ + workertypes.FeaturePromotedToNewly, + workertypes.FeaturePromotedToWidely, + }, + wantIDs: []string{"h1", "h2"}, + }, + { + name: "No Matches", + triggers: []workertypes.JobTrigger{workertypes.FeaturePromotedToWidely}, // Only h2 matches + // Pass in only h1 (Newly) + wantIDs: nil, // If input is only h1, result is empty. + // But for this test, we run against 'allHighlights' by default unless customized. + // Let's customize the logic below to handle "wantIDs subset of allHighlights" + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + input := allHighlights + // Special case for "No Matches" test to be clear: pass in only items that definitely don't match + if tc.name == "No Matches" { + input = []workertypes.SummaryHighlight{hNewly} + } + + got := filterHighlights(input, tc.triggers) + + if len(got) != len(tc.wantIDs) { + t.Errorf("Count mismatch: got %d, want %d", len(got), len(tc.wantIDs)) + } + + for i, h := range got { + if i < len(tc.wantIDs) && h.FeatureID != tc.wantIDs[i] { + t.Errorf("Index %d mismatch: got ID %s, want %s", i, h.FeatureID, tc.wantIDs[i]) + } + } + }) + } +} + +func TestRenderDigest_InvalidJSON(t *testing.T) { + var metadata workertypes.DeliveryMetadata + + job := workertypes.IncomingEmailDeliveryJob{ + EmailDeliveryJob: workertypes.EmailDeliveryJob{ + SummaryRaw: []byte("invalid-json"), + SubscriptionID: "", + RecipientEmail: "", + Metadata: metadata, + ChannelID: "", + Triggers: nil, + }, + EmailEventID: "email-event-id", + } + + renderer, _ := NewHTMLRenderer("https://test.dev") + _, _, err := renderer.RenderDigest(job) + + if err == nil { + t.Error("Expected error for invalid JSON, got nil") + } +} diff --git a/workers/email/pkg/digest/styles.go b/workers/email/pkg/digest/styles.go new file mode 100644 index 000000000..6a013f7bb --- /dev/null +++ b/workers/email/pkg/digest/styles.go @@ -0,0 +1,54 @@ +// 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. + +// nolint:lll // WONTFIX - for readability +package digest + +const styleSnippets = `{{- define "font_family_main" -}}font-family: SF Pro, system-ui, sans-serif;{{- end -}} +{{- define "font_family_monospace" -}}font-family: Menlo, monospace;{{- end -}} +{{- define "font_weight_bold" -}}font-weight: 700;{{- end -}} +{{- define "font_weight_normal" -}}font-weight: 400;{{- end -}} +{{- define "color_text_dark" -}}color: #18181B;{{- end -}} +{{- define "color_text_medium" -}}color: #52525B;{{- end -}} +{{- define "color_bg_success" -}}background: #E6F4EA;{{- end -}} +{{- define "color_bg_info" -}}background: #E8F0FE;{{- end -}} +{{- define "color_bg_neutral" -}}background: #E4E4E7;{{- end -}} +{{- define "color_bg_light_neutral" -}}background: #F4F4F5;{{- end -}}` + +const layoutStyles = `{{- define "style_body_wrapper" -}}max-width: 600px; margin: 0 auto; padding: 20px;{{- end -}} +{{- define "style_subject_header" -}}{{- template "style_text_normal" -}}; align-self: stretch; margin: 0;{{- end -}} +{{- define "style_query_text" -}}{{- template "style_text_normal" -}}; align-self: stretch;{{- end -}} +{{- define "style_section_wrapper" -}}align-self: stretch; padding-top: 8px; padding-bottom: 8px; flex-direction: column; justify-content: flex-start; align-items: flex-start; display: flex;{{- end -}} +{{- define "style_card_body" -}}align-self: stretch; padding-top: 12px; padding-bottom: 15px; padding-left: 15px; padding-right: 15px; overflow: hidden; border-bottom-right-radius: 4px; border-bottom-left-radius: 4px; border-left: 1px solid #E4E4E7; border-right: 1px solid #E4E4E7; border-bottom: 1px solid #E4E4E7; flex-direction: column; justify-content: center; align-items: flex-start; display: flex; background: #FFFFFF;{{- end -}} +{{- define "style_button_link" -}}display: inline-block; padding: 10px 20px; background: #18181B; color: white; text-decoration: none; border-radius: 4px; {{- template "font_family_main" -}}; font-weight: 500; font-size: 14px;{{- end -}}` + +const composedTextStyles = `{{- define "style_text_badge_title" -}}{{- template "color_text_dark" -}}; font-size: 14px; {{- template "font_family_main" -}}; {{- template "font_weight_bold" -}}; word-wrap: break-word;{{- end -}} +{{- define "style_text_badge_description" -}}{{- template "color_text_medium" -}}; font-size: 12px; {{- template "font_family_main" -}}; {{- template "font_weight_normal" -}}; word-wrap: break-word;{{- end -}} +{{- define "style_text_normal" -}}{{- template "color_text_dark" -}}; font-size: 14px; {{- template "font_family_main" -}}; {{- template "font_weight_normal" -}}; line-height: 21px; word-wrap: break-word;{{- end -}} +{{- define "style_text_body" -}}{{- template "color_text_dark" -}}; font-size: 16px; {{- template "font_family_main" -}}; {{- template "font_weight_normal" -}}; line-height: 30.40px; word-wrap: break-word;{{- end -}} +{{- define "style_text_body_subtle" -}}{{- template "color_text_medium" -}}; font-size: 14px; {{- template "font_family_main" -}}; {{- template "font_weight_normal" -}}; line-height: 26.60px; word-wrap: break-word;{{- end -}} +{{- define "style_text_banner_bold" -}}{{- template "color_text_dark" -}}; font-size: 14px; {{- template "font_family_main" -}}; {{- template "font_weight_bold" -}}; word-wrap: break-word;{{- end -}} +{{- define "style_text_banner_normal" -}}{{- template "color_text_dark" -}}; font-size: 14px; {{- template "font_family_main" -}}; {{- template "font_weight_normal" -}}; word-wrap: break-word;{{- end -}} +{{- define "style_text_feature_link" -}}{{- template "color_text_dark" -}}; font-size: 16px; {{- template "font_family_main" -}}; {{- template "font_weight_normal" -}}; text-decoration: underline; line-height: 30.40px; word-wrap: break-word;{{- end -}} +{{- define "style_text_doc_link" -}}{{- template "color_text_medium" -}}; font-size: 14px; {{- template "font_family_main" -}}; {{- template "font_weight_normal" -}}; text-decoration: underline; line-height: 26.60px;{{- end -}} +{{- define "style_text_doc_punctuation" -}}{{- template "color_text_medium" -}}; font-size: 14px; {{- template "font_family_main" -}}; {{- template "font_weight_normal" -}}; line-height: 26.60px;{{- end -}} +{{- define "style_text_date" -}}{{- template "color_text_medium" -}}; font-size: 12px; {{- template "font_family_monospace" -}}; {{- template "font_weight_normal" -}}; word-wrap: break-word;{{- end -}} +{{- define "style_text_browser_item" -}}{{- template "color_text_dark" -}}; font-size: 14px; {{- template "font_family_main" -}}; {{- template "font_weight_normal" -}}; word-wrap: break-word;{{- end -}} +{{- define "style_text_footer" -}}{{- template "color_text_medium" -}}; font-size: 11px; {{- template "font_weight_normal" -}}; word-wrap: break-word;{{- end -}} +{{- define "style_text_footer_link" -}}{{- template "color_text_medium" -}}; font-size: 11px; {{- template "font_weight_normal" -}}; text-decoration: underline; word-wrap: break-word;{{- end -}}` + +const EmailStyles = styleSnippets + layoutStyles + composedTextStyles + `{{- define "style_body" -}}{{- template "font_family_main" -}}; line-height: 1.5; color: #333; margin: 0; padding: 0;{{- end -}} +{{- define "style_change_detail_div" -}}align-self: stretch; justify-content: flex-start; align-items: center; gap: 10px; display: inline-flex; width: 100%;{{- end -}} +{{- define "style_change_detail_inner_div" -}}flex: 1 1 0;{{- end -}} +{{- define "style_split_into" -}}flex: 1 1 0;{{- end -}}` diff --git a/workers/email/pkg/digest/templates.go b/workers/email/pkg/digest/templates.go new file mode 100644 index 000000000..480f6dc04 --- /dev/null +++ b/workers/email/pkg/digest/templates.go @@ -0,0 +1,162 @@ +// 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 digest + +// defaultEmailTemplate is the main layout. It uses {{template "name" .}} to include +// the components defined in emailComponents. +// nolint: lll // WONTFIX - Keeping for readability. +const defaultEmailTemplate = ` + + + + {{.Subject}} + + +
+ {{- template "intro_text" . -}} + + {{- if .BaselineNewlyChanges -}} +
+ {{- template "banner_baseline_newly" dict "LogoURL" (statusLogoURL "newly") -}} + {{- range .BaselineNewlyChanges -}} + {{- $date := "" -}} + {{- if .BaselineChange.To.LowDate -}} + {{- $date = formatDate .BaselineChange.To.LowDate -}} + {{- end -}} +
+ {{- template "feature_title_row" dict "Name" .FeatureName "URL" (printf "%s/features/%s" $.BaseURL .FeatureID) "Docs" .Docs "Date" $date -}} +
+ {{- end -}} +
+ {{- end -}} + + {{- if .BaselineWidelyChanges -}} +
+ {{- template "banner_baseline_widely" dict "LogoURL" (statusLogoURL "widely") -}} + {{- range .BaselineWidelyChanges -}} + {{- $date := "" -}} + {{- if .BaselineChange.To.HighDate -}} + {{- $date = formatDate .BaselineChange.To.HighDate -}} + {{- end -}} +
+ {{- template "feature_title_row" dict "Name" .FeatureName "URL" (printf "%s/features/%s" $.BaseURL .FeatureID) "Docs" .Docs "Date" $date -}} +
+ {{- end -}} +
+ {{- end -}} + + {{- if .BaselineRegressionChanges -}} +
+ {{- template "banner_baseline_regression" dict "LogoURL" (statusLogoURL "limited") -}} + {{- range .BaselineRegressionChanges -}} + {{- $date := "" -}} +
+ {{- template "feature_title_row" dict "Name" .FeatureName "URL" (printf "%s/features/%s" $.BaseURL .FeatureID) "Docs" .Docs "Date" $date -}} +
+
+ + {{- with .BaselineChange -}} + From {{formatBaselineStatus .From.Status}} + {{- end -}} + +
+
+
+ {{- end -}} +
+ {{- end -}} + + {{- if .AllBrowserChanges -}} +
+ {{- template "banner_browser_implementation" -}} + {{- range .AllBrowserChanges -}} + {{- template "browser_item" dict "Name" (browserDisplayName .BrowserName) "LogoURL" (browserLogoURL .BrowserName) "From" .Change.From "To" .Change.To "FeatureName" .FeatureName "FeatureURL" (printf "%s/features/%s" $.BaseURL .FeatureID) -}} + {{- end -}} +
+ {{- end -}} + + {{- if .AddedFeatures -}} +
+ {{- template "badge" (dict "Title" "Added" "Description" "These features now match your search criteria.") -}} + {{- range .AddedFeatures -}} +
+ {{- template "feature_title_row" dict "Name" .FeatureName "URL" (printf "%s/features/%s" $.BaseURL .FeatureID) "Docs" .Docs -}} +
+ {{- end -}} +
+ {{- end -}} + + {{- if .RemovedFeatures -}} +
+ {{- template "badge" (dict "Title" "Removed" "Description" "These features no longer match your search criteria.") -}} + {{- range .RemovedFeatures -}} +
+ {{- template "feature_title_row" dict "Name" .FeatureName "URL" (printf "%s/features/%s" $.BaseURL .FeatureID) "Docs" .Docs -}} +
+ {{- end -}} +
+ {{- end -}} + + {{- if .DeletedFeatures -}} +
+ {{- template "badge" (dict "Title" "Deleted" "Description" "These features have been removed from the web platform.") -}} + {{- range .DeletedFeatures -}} +
+ {{- template "feature_title_row" dict "Name" .FeatureName "URL" (printf "%s/features/%s" $.BaseURL .FeatureID) "Docs" .Docs -}} +
+ {{- end -}} +
+ {{- end -}} + + {{- if .MovedFeatures -}} +
+ {{- template "badge" (dict "Title" "Moved" "Description" "These features have been renamed or merged with another feature.") -}} + {{- range .MovedFeatures -}} +
+ {{- template "feature_title_row" dict "Name" .FeatureName "URL" (printf "%s/features/%s" $.BaseURL .FeatureID) "Docs" .Docs -}} + {{- template "change_detail" dict "Label" "Moved from" "From" .Moved.From.Name "To" .Moved.To.Name -}} +
+ {{- end -}} +
+ {{- end -}} + + {{- if .SplitFeatures -}} +
+ {{- template "badge" (dict "Title" "Split" "Description" "This feature has been split into multiple, more granular features.") -}} + {{- range .SplitFeatures -}} +
+ {{- template "feature_title_row" dict "Name" .FeatureName "URL" (printf "%s/features/%s" $.BaseURL .FeatureID) "Docs" .Docs -}} +
+
+ Split into + {{ range $i, $feature := .Split.To -}} + {{- if $i }}, {{ end -}} + {{$feature.Name}} + {{- end -}} +
+
+
+ {{- end -}} +
+ {{- end -}} + + {{- if .Truncated -}} + {{- template "button" dict "URL" (printf "%s/saved-searches" $.BaseURL) "Text" "View All Changes" -}} + {{- end -}} + + {{- template "footer" dict "UnsubscribeURL" $.UnsubscribeURL "ManageURL" (printf "%s/saved-searches" $.BaseURL) -}} +
+ +` diff --git a/workers/email/pkg/digest/testdata/digest.golden.html b/workers/email/pkg/digest/testdata/digest.golden.html new file mode 100644 index 000000000..bb69497c8 --- /dev/null +++ b/workers/email/pkg/digest/testdata/digest.golden.html @@ -0,0 +1,155 @@ + + + + + Weekly Digest: group:css + + +
+

Weekly Digest: group:css

+
+ Here is your update for the saved search 'group:css'. + 11 features changed. +
+
+
+ Newly Available +
+
+ Baseline + Newly available +
+
+
+ Widely Available +
+
+ Baseline + Widely available +
+
+
2025-12-27
+
+ Regressed +
+
+ Regressed + to limited availability +
+
+
+ From Widely +
+
+
+
+ +
+
+ +
+
+ +
+
+ +
+
+
Browser support changed
+
+
+
+ Chrome +
+
+ Chrome: Available in 120 → Unavailable
+
+
+
+ Safari iOS +
+
+ Safari iOS: Unavailable → Available in 17.2
+
+
+
+ Chrome +
+
+ Chrome: Unavailable → Available (on 2025-01-01)
+
+ +
+
+
+ Firefox +
+
+ Firefox: Unavailable → Available in 123 (on 2025-01-01)
+
+
+
Added
These features now match your search criteria.
+
+
+
Removed
These features no longer match your search criteria.
+
+
+
Deleted
These features have been removed from the web platform.
+
+
+
Moved
These features have been renamed or merged with another feature.
+
+
+ Moved from + (Old Name → New Cool Name) +
+
+
+
Split
This feature has been split into multiple, more granular features.
+
+
+ Split into + Sub Feature 1, Sub Feature 2
+
+
+
+
+ You can + unsubscribe + or change any of your alerts on + webstatus.dev +
+
+ + \ No newline at end of file From 5d336cd42ddf02f159a4c3f48b73befe38235026 Mon Sep 17 00:00:00 2001 From: James Scott Date: Wed, 31 Dec 2025 04:38:31 +0000 Subject: [PATCH 21/27] feat(email): Add Chime email sending service Implements a new email sending service using the Chime API. - Adds a new Chime client in `lib/email/chime` for handling API requests, authentication, and error classification (transient, permanent user, permanent system). - Introduces an adapter in `lib/email/chime/chimeadapters` to integrate the new client with the existing email worker. - Updates the email worker `Send` interface to include a unique ID per message, which is passed to Chime's `external_id` field for deduplication in the future. --- lib/email/chime/chimeadapters/email_worker.go | 58 +++ .../chime/chimeadapters/email_worker_test.go | 100 +++++ lib/email/chime/client.go | 367 ++++++++++++++++++ lib/email/chime/client_test.go | 202 ++++++++++ workers/email/pkg/sender/sender.go | 4 +- workers/email/pkg/sender/sender_test.go | 18 +- 6 files changed, 744 insertions(+), 5 deletions(-) create mode 100644 lib/email/chime/chimeadapters/email_worker.go create mode 100644 lib/email/chime/chimeadapters/email_worker_test.go create mode 100644 lib/email/chime/client.go create mode 100644 lib/email/chime/client_test.go diff --git a/lib/email/chime/chimeadapters/email_worker.go b/lib/email/chime/chimeadapters/email_worker.go new file mode 100644 index 000000000..326b9f250 --- /dev/null +++ b/lib/email/chime/chimeadapters/email_worker.go @@ -0,0 +1,58 @@ +// 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 chimeadapters + +import ( + "context" + "errors" + + "github.com/GoogleChrome/webstatus.dev/lib/email/chime" + "github.com/GoogleChrome/webstatus.dev/lib/workertypes" +) + +type EmailSender interface { + Send(ctx context.Context, id string, to string, subject string, htmlBody string) error +} + +type EmailWorkerChimeAdapter struct { + chimeSender EmailSender +} + +// NewEmailWorkerChimeAdapter creates a new adapter for the email worker to use Chime. +func NewEmailWorkerChimeAdapter(chimeSender EmailSender) *EmailWorkerChimeAdapter { + return &EmailWorkerChimeAdapter{ + chimeSender: chimeSender, + } +} + +// Send implements the EmailSender interface for the email worker. +func (a *EmailWorkerChimeAdapter) Send(ctx context.Context, id string, to string, + subject string, htmlBody string) error { + err := a.chimeSender.Send(ctx, id, to, subject, htmlBody) + if err != nil { + if errors.Is(err, chime.ErrPermanentUser) { + return errors.Join(workertypes.ErrUnrecoverableUserFailureEmailSending, err) + } else if errors.Is(err, chime.ErrPermanentSystem) { + return errors.Join(workertypes.ErrUnrecoverableSystemFailureEmailSending, err) + } else if errors.Is(err, chime.ErrDuplicate) { + return errors.Join(workertypes.ErrUnrecoverableSystemFailureEmailSending, err) + } + + // Will be recorded as a transient error + return err + } + + return nil +} diff --git a/lib/email/chime/chimeadapters/email_worker_test.go b/lib/email/chime/chimeadapters/email_worker_test.go new file mode 100644 index 000000000..181682222 --- /dev/null +++ b/lib/email/chime/chimeadapters/email_worker_test.go @@ -0,0 +1,100 @@ +// 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 chimeadapters + +import ( + "context" + "errors" + "testing" + + "github.com/GoogleChrome/webstatus.dev/lib/email/chime" + "github.com/GoogleChrome/webstatus.dev/lib/workertypes" +) + +// mockChimeSender is a mock implementation of the EmailSender for testing. +type mockChimeSender struct { + sendErr error +} + +func (m *mockChimeSender) Send(_ context.Context, _ string, _ string, _ string, _ string) error { + return m.sendErr +} + +var errTest = errors.New("test error") + +func TestEmailWorkerChimeAdapter_Send(t *testing.T) { + ctx := context.Background() + testCases := []struct { + name string + chimeError error + expectedError error + }{ + { + name: "Success", + chimeError: nil, + expectedError: nil, + }, + { + name: "Permanent User Error", + chimeError: chime.ErrPermanentUser, + expectedError: workertypes.ErrUnrecoverableUserFailureEmailSending, + }, + { + name: "Permanent System Error", + chimeError: chime.ErrPermanentSystem, + expectedError: workertypes.ErrUnrecoverableSystemFailureEmailSending, + }, + { + name: "Duplicate Error", + chimeError: chime.ErrDuplicate, + expectedError: workertypes.ErrUnrecoverableSystemFailureEmailSending, + }, + { + name: "Transient Error", + chimeError: chime.ErrTransient, + expectedError: chime.ErrTransient, // Should be passed through + }, + { + name: "Other Error", + chimeError: errTest, + expectedError: errTest, // Should be passed through + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + // Setup + mockSender := &mockChimeSender{sendErr: tc.chimeError} + adapter := NewEmailWorkerChimeAdapter(mockSender) + + // Execute + err := adapter.Send(ctx, "test-id", "to@example.com", "Test Subject", "

Hello

") + + // Verify + if tc.expectedError != nil { + if err == nil { + t.Fatal("Expected an error, but got nil") + } + if !errors.Is(err, tc.expectedError) { + t.Errorf("Expected error wrapping %v, but got %v", tc.expectedError, err) + } + } else { + if err != nil { + t.Errorf("Expected no error, but got %v", err) + } + } + }) + } +} diff --git a/lib/email/chime/client.go b/lib/email/chime/client.go new file mode 100644 index 000000000..4a0ac0ba1 --- /dev/null +++ b/lib/email/chime/client.go @@ -0,0 +1,367 @@ +// 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 chime + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "fmt" + "io" + "log/slog" + "net/http" + "strings" + "time" + + "golang.org/x/oauth2" + "golang.org/x/oauth2/google" +) + +// Env type for environment selection. +type Env int + +const ( + // EnvAutopush uses the autopush environment. + EnvAutopush Env = iota + // EnvProd uses the production environment. + EnvProd +) + +func getChimeURL(env Env) string { + switch env { + case EnvAutopush: + return "https://autopush-notifications-pa-googleapis.sandbox.google.com" + case EnvProd: + return "https://notifications-pa.googleapis.com" + default: + return "" + } +} + +// ClientID and other constants. +const ( + clientID = "webstatus_dev" + notificationType = "SUBSCRIPTION_NOTIFICATION" + defaultFromAddr = "noreply-webstatus-dev@google.com" +) + +// Sentinel Errors. +var ( + ErrPermanentUser = errors.New("permanent error due to user/target issue") + ErrPermanentSystem = errors.New("permanent error due to system/config issue") + ErrTransient = errors.New("transient error, can be retried") + ErrDuplicate = errors.New("duplicate notification") +) + +// HTTPClient interface to allow mocking http.Client. +type HTTPClient interface { + Do(req *http.Request) (*http.Response, error) +} + +type Sender struct { + bcc []string + tokenSource oauth2.TokenSource + httpClient HTTPClient + fromAddress string + baseURL string +} + +// NewChimeSender creates a new ChimeSender instance. +func NewChimeSender(ctx context.Context, env Env, bcc []string, fromAddr string, + customHTTPClient HTTPClient) (*Sender, error) { + baseURL := getChimeURL(env) + if baseURL == "" { + return nil, fmt.Errorf("%w: invalid ChimeEnv: %v", ErrPermanentSystem, env) + } + + ts, err := google.FindDefaultCredentials(ctx, "https://www.googleapis.com/auth/notifications") + if err != nil { + return nil, fmt.Errorf("%w: failed to find default credentials: %w", ErrPermanentSystem, err) + } + + httpClient := customHTTPClient + if httpClient == nil { + client := oauth2.NewClient(ctx, ts.TokenSource) + client.Timeout = 30 * time.Second + httpClient = client + } + + if fromAddr == "" { + fromAddr = defaultFromAddr + } + + return &Sender{ + bcc: bcc, + tokenSource: ts.TokenSource, + httpClient: httpClient, + fromAddress: fromAddr, + baseURL: baseURL, + }, nil +} + +type NotifyTargetSyncRequest struct { + Notification Notification `json:"notification"` + Target Target `json:"target"` +} +type Notification struct { + ClientID string `json:"client_id"` + ExternalID string `json:"external_id"` + TypeID string `json:"type_id"` + Payload Payload `json:"payload"` +} +type Source struct { + SystemName string `json:"system_name"` +} +type Payload struct { + TypeURL string `json:"@type"` + EmailMessage EmailMessage `json:"email_message"` +} +type EmailMessage struct { + FromAddress string `json:"from_address"` + Subject string `json:"subject"` + BodyPart []BodyPart `json:"body_part"` + BccRecipient []string `json:"bcc_recipient,omitempty"` +} +type BodyPart struct { + Content string `json:"content"` + ContentType string `json:"content_type"` +} +type Target struct { + ChannelType string `json:"channel_type"` + DeliveryAddress DeliveryAddress `json:"delivery_address"` +} +type DeliveryAddress struct { + EmailAddress EmailAddress `json:"email_address"` +} +type EmailAddress struct { + ToAddress string `json:"to_address"` +} +type NotifyTargetSyncResponse struct { + ExternalID string `json:"externalId"` + Identifier string `json:"identifier"` + Details struct { + Outcome string `json:"outcome"` + Reason string `json:"reason"` + } `json:"details"` +} + +// --- Send method and its helpers --- + +func (s *Sender) Send(ctx context.Context, id string, to string, subject string, htmlBody string) error { + if id == "" { + return fmt.Errorf("%w: id (externalID) cannot be empty", ErrPermanentSystem) + } + + reqBodyData, err := s.buildRequestBody(id, to, subject, htmlBody) + if err != nil { + return err + } + + httpReq, err := s.createHTTPRequest(ctx, reqBodyData) + if err != nil { + return err + } + + resp, bodyBytes, err := s.executeRequest(httpReq) + if err != nil { + return err // errors from executeRequest are already wrapped + } + defer resp.Body.Close() + + err = s.handleResponse(ctx, resp, bodyBytes, id) + handleSendResult(ctx, err, id) + + return err +} + +func (s *Sender) buildRequestBody(id string, to string, subject string, htmlBody string) ([]byte, error) { + reqBody := NotifyTargetSyncRequest{ + Notification: Notification{ + ClientID: clientID, + ExternalID: id, + TypeID: notificationType, + Payload: Payload{ + TypeURL: "type.googleapis.com/notifications.backend.common.message.RenderedMessage", + EmailMessage: EmailMessage{ + FromAddress: s.fromAddress, + Subject: subject, + BodyPart: []BodyPart{ + {Content: htmlBody, ContentType: "text/html"}, + }, + BccRecipient: s.bcc, + }, + }, + }, + Target: Target{ + ChannelType: "EMAIL", + DeliveryAddress: DeliveryAddress{ + EmailAddress: EmailAddress{ToAddress: to}, + }, + }, + } + jsonData, err := json.Marshal(reqBody) + if err != nil { + return nil, fmt.Errorf("%w: failed to marshal request body: %w", ErrPermanentSystem, err) + } + + return jsonData, nil +} + +func (s *Sender) createHTTPRequest(ctx context.Context, body []byte) (*http.Request, error) { + apiURL := fmt.Sprintf("%s/v1/notifytargetsync", s.baseURL) + req, err := http.NewRequestWithContext(ctx, http.MethodPost, apiURL, bytes.NewBuffer(body)) + if err != nil { + return nil, fmt.Errorf("%w: failed to create HTTP request: %w", ErrPermanentSystem, err) + } + + token, err := s.tokenSource.Token() + if err != nil { + return nil, fmt.Errorf("%w: failed to retrieve access token: %w", ErrPermanentSystem, err) + } + req.Header.Set("Authorization", "Bearer "+token.AccessToken) + req.Header.Set("Content-Type", "application/json") + + return req, nil +} + +func (s *Sender) executeRequest(req *http.Request) (*http.Response, []byte, error) { + resp, err := s.httpClient.Do(req) + if err != nil { + return nil, nil, fmt.Errorf("%w: network error sending to Chime: %w", ErrTransient, err) + } + if resp.Body != nil { + defer resp.Body.Close() + } + + bodyBytes, err := io.ReadAll(resp.Body) + if err != nil { + return nil, nil, fmt.Errorf("%w: failed to read response body: %w", ErrTransient, err) + } + + return resp, bodyBytes, nil +} + +func (s *Sender) handleResponse(ctx context.Context, + resp *http.Response, bodyBytes []byte, externalID string) error { + bodyStr := string(bodyBytes) + + if resp.StatusCode == http.StatusConflict { // 409 + return fmt.Errorf("%w: external_id %s: %s", ErrDuplicate, externalID, bodyStr) + } + + if resp.StatusCode >= 400 && resp.StatusCode < 500 { + return classifyHTTPClientError(resp.StatusCode, bodyStr) + } else if resp.StatusCode >= 500 { + return fmt.Errorf("%w: Chime server error (%d): %s", ErrTransient, resp.StatusCode, bodyStr) + } + + var responseBody NotifyTargetSyncResponse + if err := json.Unmarshal(bodyBytes, &responseBody); err != nil { + // Chime accepted it, but response is not what we expected. Log and treat as success. + slog.WarnContext(ctx, "Chime call OK, but failed to parse response body", + "externalID", externalID, "error", err, "body", bodyStr) + + return nil + } + + return classifyChimeOutcome(ctx, externalID, responseBody) +} + +func classifyHTTPClientError(statusCode int, bodyStr string) error { + switch statusCode { + case http.StatusBadRequest: // 400 + return fmt.Errorf("%w: bad request (400): %s", ErrPermanentSystem, bodyStr) + case http.StatusUnauthorized: // 401 + return fmt.Errorf("%w: unauthorized (401): %s", ErrPermanentSystem, bodyStr) + case http.StatusForbidden: // 403 + return fmt.Errorf("%w: forbidden (403): %s", ErrPermanentSystem, bodyStr) + default: + return fmt.Errorf("%w: client error (%d): %s", ErrPermanentSystem, statusCode, bodyStr) + } +} + +func classifyChimeOutcome(ctx context.Context, externalID string, responseBody NotifyTargetSyncResponse) error { + outcome := responseBody.Details.Outcome + reason := responseBody.Details.Reason + chimeID := responseBody.Identifier + slog.DebugContext(ctx, "Chime Response", "externalID", externalID, + "chimeID", chimeID, "outcome", outcome, "reason", reason) + + switch outcome { + case "SENT": + return nil // Success + case "PREFERENCE_DROPPED", "INVALID_AUTH_SUB_TOKEN_DROPPED": + return fmt.Errorf("%w: outcome %s, reason: %s", ErrPermanentUser, outcome, reason) + case "EXPLICITLY_DROPPED", "MESSAGE_TOO_LARGE_DROPPED", "INVALID_REQUEST_DROPPED": + return fmt.Errorf("%w: outcome %s, reason: %s", ErrPermanentSystem, outcome, reason) + case "DELIVERY_FAILURE_DROPPED": + if isUserCausedDeliveryFailure(reason) { + return fmt.Errorf("%w: outcome %s, reason: %s", ErrPermanentUser, outcome, reason) + } else if isSystemCausedDeliveryFailure(reason) { + return fmt.Errorf("%w: outcome %s, reason: %s", ErrPermanentSystem, outcome, reason) + } + + return fmt.Errorf("%w: outcome %s, reason: %s", ErrTransient, outcome, reason) + case "QUOTA_DROPPED": + return fmt.Errorf("%w: outcome %s, reason: %s", ErrTransient, outcome, reason) + default: // Unknown outcome + return fmt.Errorf("%w: unknown outcome %s, reason: %s", ErrTransient, outcome, reason) + } +} + +func isUserCausedDeliveryFailure(reason string) bool { + userKeywords := []string{"invalid_mailbox", "no such user", "invalid_domain", "domain not found", "unroutable address"} + lowerReason := strings.ToLower(reason) + for _, kw := range userKeywords { + if strings.Contains(lowerReason, kw) { + return true + } + } + + return strings.Contains(lowerReason, "perm_fail") && !isSystemCausedDeliveryFailure(reason) +} + +func isSystemCausedDeliveryFailure(reason string) bool { + systemKeywords := []string{"perm_fail_sender_denied", "mail loop"} + lowerReason := strings.ToLower(reason) + for _, kw := range systemKeywords { + if strings.Contains(lowerReason, kw) { + return true + } + } + + return false +} + +func handleSendResult(ctx context.Context, err error, externalID string) { + if err == nil { + slog.InfoContext(ctx, "Email sending process initiated and reported as SENT.", "externalID", externalID) + + return + } + slog.ErrorContext(ctx, "Error sending email", "externalID", externalID, "error", err) + if errors.Is(err, ErrDuplicate) { + slog.ErrorContext(ctx, "Result: This was a DUPLICATE send.", "externalID", externalID) + } else if errors.Is(err, ErrPermanentUser) { + slog.ErrorContext(ctx, "Result: PERMANENT error due to USER issue.", "externalID", externalID) + } else if errors.Is(err, ErrPermanentSystem) { + slog.ErrorContext(ctx, "Result: PERMANENT error due to SYSTEM issue.", "externalID", externalID) + } else if errors.Is(err, ErrTransient) { + slog.ErrorContext(ctx, "Result: TRANSIENT error.", "externalID", externalID) + } else { + slog.ErrorContext(ctx, "Result: Unknown error type.", "externalID", externalID) + } +} diff --git a/lib/email/chime/client_test.go b/lib/email/chime/client_test.go new file mode 100644 index 000000000..d6eed6bb9 --- /dev/null +++ b/lib/email/chime/client_test.go @@ -0,0 +1,202 @@ +// 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 chime + +import ( + "context" + "errors" + "fmt" + "io" + "net/http" + "strings" + "testing" + + "golang.org/x/oauth2" +) + +// mockHTTPClient allows faking HTTP responses for tests. +type mockHTTPClient struct { + response *http.Response + err error +} + +func (m *mockHTTPClient) Do(_ *http.Request) (*http.Response, error) { + return m.response, m.err +} + +// mockTokenSource is a dummy token source for tests. +type mockTokenSource struct { + token *oauth2.Token + err error +} + +func (m *mockTokenSource) Token() (*oauth2.Token, error) { + return m.token, m.err +} + +func newTestSender(mockClient HTTPClient) *Sender { + return &Sender{ + bcc: []string{"bcc@example.com"}, + // nolint:exhaustruct // WONTFIX - external struct. + tokenSource: &mockTokenSource{token: &oauth2.Token{AccessToken: "fake-token"}, err: nil}, + httpClient: mockClient, + fromAddress: "test-from@example.com", + baseURL: "https://fake-chime.googleapis.com", + } +} + +func TestSend(t *testing.T) { + ctx := context.Background() + testCases := []struct { + name string + mockClient *mockHTTPClient + id string + expectedError error + }{ + { + name: "Success - SENT outcome", + mockClient: &mockHTTPClient{ + // nolint:exhaustruct // WONTFIX - external struct. + response: &http.Response{ + StatusCode: http.StatusOK, + Body: io.NopCloser(strings.NewReader(`{"details": {"outcome": "SENT"}}`)), + Header: make(http.Header), + }, + err: nil, + }, + id: "success-id", + expectedError: nil, + }, + { + name: "Duplicate Notification - 409 Conflict", + mockClient: &mockHTTPClient{ + // nolint:exhaustruct // WONTFIX - external struct. + response: &http.Response{ + StatusCode: http.StatusConflict, + Body: io.NopCloser(strings.NewReader("Duplicate")), + Header: make(http.Header), + }, + err: nil, + }, + id: "duplicate-id", + expectedError: ErrDuplicate, + }, + { + name: "Permanent User Error - PREFERENCE_DROPPED", + mockClient: &mockHTTPClient{ + // nolint:exhaustruct // WONTFIX - external struct. + response: &http.Response{ + StatusCode: http.StatusOK, + Body: io.NopCloser(strings.NewReader(`{"details": {"outcome": "PREFERENCE_DROPPED"}}`)), + Header: make(http.Header), + }, + err: nil, + }, + id: "user-error-id", + expectedError: ErrPermanentUser, + }, + { + name: "Permanent System Error - INVALID_REQUEST_DROPPED", + mockClient: &mockHTTPClient{ + // nolint:exhaustruct // WONTFIX - external struct. + response: &http.Response{ + StatusCode: http.StatusOK, + Body: io.NopCloser(strings.NewReader(`{"details": {"outcome": "INVALID_REQUEST_DROPPED"}}`)), + Header: make(http.Header), + }, + err: nil, + }, + id: "system-error-id", + expectedError: ErrPermanentSystem, + }, + { + name: "Permanent System Error - 400 Bad Request", + mockClient: &mockHTTPClient{ + // nolint:exhaustruct // WONTFIX - external struct. + response: &http.Response{ + StatusCode: http.StatusBadRequest, + Body: io.NopCloser(strings.NewReader("Bad Request")), + Header: make(http.Header), + }, + err: nil, + }, + id: "bad-request-id", + expectedError: ErrPermanentSystem, + }, + { + name: "Transient Error - QUOTA_DROPPED", + mockClient: &mockHTTPClient{ + // nolint:exhaustruct // WONTFIX - external struct. + response: &http.Response{ + StatusCode: http.StatusOK, + Body: io.NopCloser(strings.NewReader(`{"details": {"outcome": "QUOTA_DROPPED"}}`)), + Header: make(http.Header), + }, + err: nil, + }, + id: "transient-quota-id", + expectedError: ErrTransient, + }, + { + name: "Transient Error - 503 Server Error", + mockClient: &mockHTTPClient{ + // nolint:exhaustruct // WONTFIX - external struct. + response: &http.Response{ + StatusCode: http.StatusServiceUnavailable, + Body: io.NopCloser(strings.NewReader("Server Error")), + Header: make(http.Header), + }, + err: nil, + }, + id: "transient-server-error-id", + expectedError: ErrTransient, + }, + { + name: "Network Error", + mockClient: &mockHTTPClient{ + response: nil, + err: fmt.Errorf("network connection failed"), + }, + id: "network-error-id", + expectedError: ErrTransient, + }, + { + name: "Empty ID Error", + mockClient: &mockHTTPClient{response: nil, err: nil}, + id: "", + expectedError: ErrPermanentSystem, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + sender := newTestSender(tc.mockClient) + err := sender.Send(ctx, tc.id, "to@example.com", "Test Subject", "

Test

") + + if tc.expectedError != nil { + if err == nil { + t.Fatalf("Expected error but got nil") + } + if !errors.Is(err, tc.expectedError) { + t.Errorf("Expected error wrapping %v, but got %v", tc.expectedError, err) + } + } else { + if err != nil { + t.Errorf("Expected no error, but got %v", err) + } + } + }) + } +} diff --git a/workers/email/pkg/sender/sender.go b/workers/email/pkg/sender/sender.go index 139e2a4d9..eda8f1c69 100644 --- a/workers/email/pkg/sender/sender.go +++ b/workers/email/pkg/sender/sender.go @@ -25,7 +25,7 @@ import ( ) type EmailSender interface { - Send(ctx context.Context, to string, subject string, htmlBody string) error + Send(ctx context.Context, id string, to string, subject string, htmlBody string) error } type ChannelStateManager interface { @@ -71,7 +71,7 @@ func (s *Sender) ProcessMessage(ctx context.Context, job workertypes.IncomingEma } // 2. Send - if err := s.sender.Send(ctx, job.RecipientEmail, subject, body); err != nil { + if err := s.sender.Send(ctx, job.EmailEventID, job.RecipientEmail, subject, body); err != nil { isPermanentUserError := errors.Is(err, workertypes.ErrUnrecoverableUserFailureEmailSending) isPermanent := errors.Is(err, workertypes.ErrUnrecoverableSystemFailureEmailSending) || isPermanentUserError diff --git a/workers/email/pkg/sender/sender_test.go b/workers/email/pkg/sender/sender_test.go index 7a891bb9e..7cbe0bb41 100644 --- a/workers/email/pkg/sender/sender_test.go +++ b/workers/email/pkg/sender/sender_test.go @@ -33,13 +33,14 @@ type mockEmailSender struct { } type sentCall struct { + id string to string subject string body string } -func (m *mockEmailSender) Send(_ context.Context, to, subject, body string) error { - m.sentCalls = append(m.sentCalls, sentCall{to, subject, body}) +func (m *mockEmailSender) Send(_ context.Context, id, to, subject, body string) error { + m.sentCalls = append(m.sentCalls, sentCall{id, to, subject, body}) return m.sendErr } @@ -114,6 +115,8 @@ func fakeNow() time.Time { // --- Tests --- +const testEmailEventID = "job-id" + func TestProcessMessage_Success(t *testing.T) { ctx := context.Background() job := workertypes.IncomingEmailDeliveryJob{ @@ -127,7 +130,7 @@ func TestProcessMessage_Success(t *testing.T) { workertypes.BrowserImplementationAnyComplete, }, }, - EmailEventID: "job-id", + EmailEventID: testEmailEventID, } sender := new(mockEmailSender) @@ -156,6 +159,15 @@ func TestProcessMessage_Success(t *testing.T) { if sender.sentCalls[0].to != "user@example.com" { t.Errorf("Recipient mismatch: %s", sender.sentCalls[0].to) } + if sender.sentCalls[0].id != testEmailEventID { + t.Errorf("Event ID mismatch: %s", sender.sentCalls[0].id) + } + if sender.sentCalls[0].subject != "Subject" { + t.Errorf("Subject mismatch: %s", sender.sentCalls[0].subject) + } + if sender.sentCalls[0].body != "Body" { + t.Errorf("Body mismatch: %s", sender.sentCalls[0].body) + } // Verify State if len(stateManager.successCalls) != 1 { From b4750c5e4a075b2bab18d1908e3d7888b8a7a2f2 Mon Sep 17 00:00:00 2001 From: James Scott Date: Thu, 1 Jan 2026 21:12:15 +0000 Subject: [PATCH 22/27] feat(emailworker): Wire up email worker subscriber This commit connects the email worker's Pub/Sub subscriber to the message handler. It initializes the HTML renderer and the sender, and starts the subscription to process incoming email jobs. The frontend base URL is now configured via an environment variable to support link generation in emails. --- workers/email/cmd/job/main.go | 35 ++++++++++++++++++++++++++++---- workers/email/go.mod | 15 ++++++++++++++ workers/email/go.sum | 7 +++++++ workers/email/manifests/pod.yaml | 2 ++ 4 files changed, 55 insertions(+), 4 deletions(-) diff --git a/workers/email/cmd/job/main.go b/workers/email/cmd/job/main.go index 36a8b15f3..91bf8ec39 100644 --- a/workers/email/cmd/job/main.go +++ b/workers/email/cmd/job/main.go @@ -17,10 +17,16 @@ package main import ( "context" "log/slog" + "net/url" "os" + "github.com/GoogleChrome/webstatus.dev/lib/email/chime/chimeadapters" "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/workers/email/pkg/digest" + "github.com/GoogleChrome/webstatus.dev/workers/email/pkg/sender" ) func main() { @@ -48,6 +54,18 @@ func main() { spannerClient.SetMisingOneImplementationQuery(gcpspanner.LocalMissingOneImplementationQuery{}) } + baseURL := os.Getenv("FRONTEND_BASE_URL") + if baseURL == "" { + slog.ErrorContext(ctx, "FRONTEND_BASE_URL is not set. exiting...") + os.Exit(1) + } + + parsedBaseURL, err := url.Parse(baseURL) + if err != nil { + slog.ErrorContext(ctx, "failed to parse FRONTEND_BASE_URL", "error", err.Error()) + os.Exit(1) + } + // For subscribing to email events emailSubID := os.Getenv("EMAIL_SUBSCRIPTION_ID") if emailSubID == "" { @@ -61,11 +79,20 @@ func main() { os.Exit(1) } - // TODO: https://github.com/GoogleChrome/webstatus.dev/issues/1852 - // Nil handler for now. Will fix later - err = queueClient.Subscribe(ctx, emailSubID, nil) + renderer, err := digest.NewHTMLRenderer(parsedBaseURL.String()) if err != nil { - slog.ErrorContext(ctx, "unable to connect to subscription", "error", err) + // If the template is not valid, the renderer will fail. + slog.ErrorContext(ctx, "unable to create renderer", "error", err) + os.Exit(1) + } + + listener := gcppubsubadapters.NewEmailWorkerSubscriberAdapter(sender.NewSender( + chimeadapters.NewEmailWorkerChimeAdapter(nil), + spanneradapters.NewEmailWorkerChannelStateManager(spannerClient), + renderer, + ), queueClient, emailSubID) + if err := listener.Subscribe(ctx); err != nil { + slog.ErrorContext(ctx, "worker subscriber failed", "error", err) os.Exit(1) } } diff --git a/workers/email/go.mod b/workers/email/go.mod index 050017a66..f1e99c9b6 100644 --- a/workers/email/go.mod +++ b/workers/email/go.mod @@ -16,10 +16,15 @@ require ( cloud.google.com/go v0.123.0 // indirect cloud.google.com/go/auth v0.17.0 // indirect cloud.google.com/go/auth/oauth2adapt v0.2.8 // indirect + cloud.google.com/go/cloudtasks v1.13.7 // indirect cloud.google.com/go/compute/metadata v0.9.0 // indirect + cloud.google.com/go/datastore v1.21.0 // indirect cloud.google.com/go/iam v1.5.3 // indirect + 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 github.com/GoogleChrome/webstatus.dev/lib/gen v0.0.0-20251119220853-b545639c35ae // indirect github.com/GoogleCloudPlatform/grpc-gcp-go/grpcgcp v1.5.3 // indirect @@ -28,6 +33,7 @@ require ( github.com/apapsch/go-jsonmerge/v2 v2.0.0 // indirect github.com/cespare/xxhash/v2 v2.3.0 // indirect github.com/cncf/xds/go v0.0.0-20251110193048-8bfbf64dc13e // indirect + github.com/deckarep/golang-set v1.8.0 // indirect github.com/envoyproxy/go-control-plane/envoy v1.36.0 // indirect github.com/envoyproxy/protoc-gen-validate v1.2.1 // indirect github.com/felixge/httpsnoop v1.0.4 // indirect @@ -38,10 +44,16 @@ require ( github.com/go-openapi/jsonpointer v0.22.3 // indirect github.com/go-openapi/swag/jsonname v0.25.3 // indirect github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 // indirect + github.com/gomodule/redigo v1.9.3 // indirect + github.com/google/go-github/v77 v77.0.0 // indirect + github.com/google/go-querystring v1.1.0 // indirect github.com/google/s2a-go v0.1.9 // indirect github.com/google/uuid v1.6.0 // indirect github.com/googleapis/enterprise-certificate-proxy v0.3.7 // indirect github.com/googleapis/gax-go/v2 v2.15.0 // indirect + github.com/gorilla/handlers v1.5.2 // indirect + github.com/gorilla/mux v1.8.1 // indirect + github.com/gorilla/securecookie v1.1.2 // indirect github.com/josharian/intern v1.0.0 // indirect github.com/mailru/easyjson v0.9.1 // indirect github.com/mohae/deepcopy v0.0.0-20170929034955-c48cc78d4826 // indirect @@ -50,7 +62,9 @@ require ( github.com/oasdiff/yaml3 v0.0.0-20250309153720-d2182401db90 // indirect github.com/perimeterx/marshmallow v1.1.5 // indirect github.com/planetscale/vtprotobuf v0.6.1-0.20240319094008-0393e58bdf10 // indirect + github.com/sirupsen/logrus v1.9.3 // indirect github.com/spiffe/go-spiffe/v2 v2.6.0 // indirect + github.com/web-platform-tests/wpt.fyi v0.0.0-20251118162843-54f805c8a632 // indirect github.com/woodsbury/decimal128 v1.4.0 // indirect go.opencensus.io v0.24.0 // indirect go.opentelemetry.io/auto/sdk v1.2.1 // indirect @@ -76,4 +90,5 @@ require ( google.golang.org/genproto/googleapis/rpc v0.0.0-20251111163417-95abcf5c77ba // indirect google.golang.org/grpc v1.77.0 // indirect google.golang.org/protobuf v1.36.10 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect ) diff --git a/workers/email/go.sum b/workers/email/go.sum index 7f043218c..672b53cf0 100644 --- a/workers/email/go.sum +++ b/workers/email/go.sum @@ -842,6 +842,8 @@ github.com/google/go-github/v77 v77.0.0 h1:9DsKKbZqil5y/4Z9mNpZDQnpli6PJbqipSuuN github.com/google/go-github/v77 v77.0.0/go.mod h1:c8VmGXRUmaZUqbctUcGEDWYnMrtzZfJhDSylEf1wfmA= github.com/google/go-querystring v1.1.0 h1:AnCroh3fv4ZBgVIf1Iwtovgjaw/GiKJo8M8yD/fhyJ8= github.com/google/go-querystring v1.1.0/go.mod h1:Kcdr2DB4koayq7X8pmAG4sNG59So17icRSOU623lUBU= +github.com/google/gofuzz v1.2.0 h1:xRy4A+RhZaiKjJ1bPfwQ8sedCA+YS2YcCHW6ec7JMi0= +github.com/google/gofuzz v1.2.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= github.com/google/martian v2.1.0+incompatible/go.mod h1:9I4somxYTbIHy5NJKHRl3wXiIaQGbYVAs8BPL6v8lEs= github.com/google/martian/v3 v3.0.0/go.mod h1:y5Zk1BBys9G+gd6Jrk0W3cC1+ELVxBWuIGO+w/tUAp0= github.com/google/martian/v3 v3.1.0/go.mod h1:y5Zk1BBys9G+gd6Jrk0W3cC1+ELVxBWuIGO+w/tUAp0= @@ -973,6 +975,8 @@ github.com/opencontainers/image-spec v1.1.1 h1:y0fUlFfIZhPF1W537XOLg0/fcx6zcHCJw github.com/opencontainers/image-spec v1.1.1/go.mod h1:qpqAh3Dmcf36wStyyWU+kCeDgrGnAve2nCC8+7h8Q0M= github.com/perimeterx/marshmallow v1.1.5 h1:a2LALqQ1BlHM8PZblsDdidgv1mWi1DgC2UmX50IvK2s= github.com/perimeterx/marshmallow v1.1.5/go.mod h1:dsXbUu8CRzfYP5a87xpp0xq9S3u0Vchtcl8we9tYaXw= +github.com/phayes/freeport v0.0.0-20220201140144-74d24b5ae9f5 h1:Ii+DKncOVM8Cu1Hc+ETb5K+23HdAMvESYE3ZJ5b5cMI= +github.com/phayes/freeport v0.0.0-20220201140144-74d24b5ae9f5/go.mod h1:iIss55rKnNBTvrwdmkUpLnDpZoAHvWaiq5+iMmen4AE= github.com/phpdave11/gofpdf v1.4.2/go.mod h1:zpO6xFn9yxo3YLyMvW8HcKWVdbNqgIfOOp2dXMnm1mY= github.com/phpdave11/gofpdi v1.0.12/go.mod h1:vBmVV0Do6hSBHC8uKUQ71JGW+ZGQq74llk/7bXwjDoI= github.com/phpdave11/gofpdi v1.0.13/go.mod h1:vBmVV0Do6hSBHC8uKUQ71JGW+ZGQq74llk/7bXwjDoI= @@ -1083,6 +1087,8 @@ go.opentelemetry.io/otel/trace v1.38.0/go.mod h1:j1P9ivuFsTceSWe1oY+EeW3sc+Pp42s go.opentelemetry.io/proto/otlp v0.7.0/go.mod h1:PqfVotwruBrMGOCsRd/89rSnXhoiJIqeYNgFYFoEGnI= go.opentelemetry.io/proto/otlp v0.15.0/go.mod h1:H7XAot3MsfNsj7EXtrA2q5xSNQ10UqI405h3+duxN4U= go.opentelemetry.io/proto/otlp v0.19.0/go.mod h1:H7XAot3MsfNsj7EXtrA2q5xSNQ10UqI405h3+duxN4U= +go.uber.org/mock v0.6.0 h1:hyF9dfmbgIX5EfOdasqLsWD6xqpNZlXblLB/Dbnwv3Y= +go.uber.org/mock v0.6.0/go.mod h1:KiVJ4BqZJaMj4svdfmHM0AUx4NJYO8ZNpPnZn1Z+BBU= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= golang.org/x/crypto v0.0.0-20190510104115-cbcb75029529/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= golang.org/x/crypto v0.0.0-20190605123033-f99c8df09eb5/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= @@ -1328,6 +1334,7 @@ golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBc golang.org/x/sys v0.0.0-20220610221304-9f5ed59c137d/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220615213510-4f61da869c0c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220624220833-87e55d714810/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220728004956-3c1f35247d10/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= diff --git a/workers/email/manifests/pod.yaml b/workers/email/manifests/pod.yaml index b3ab7b3d5..d16c68011 100644 --- a/workers/email/manifests/pod.yaml +++ b/workers/email/manifests/pod.yaml @@ -36,6 +36,8 @@ spec: value: 'pubsub:8060' - name: EMAIL_SUBSCRIPTION_ID value: 'chime-delivery-sub-id' + - name: FRONTEND_BASE_URL + value: 'http://localhost:5555' resources: limits: cpu: 250m From 97c9792a19085556cdde679488f52ca4750a6d8c Mon Sep 17 00:00:00 2001 From: James Scott Date: Fri, 2 Jan 2026 21:22:47 +0000 Subject: [PATCH 23/27] feat(frontend): add notification channels page Implements the new "Notification Channels" page, providing users with a centralized location to manage how they receive updates. This feature is a foundational step towards enabling subscriptions and personalized notifications. Key changes include: - **New Page Component:** Adds a new page at `/settings/notification-channels`, accessible only to authenticated users. - **Component Architecture:** The page is composed of a main container and three distinct panel components for each notification type (Email, RSS, Webhook), following a composition-over-inheritance pattern. - **Base Panel Component:** A new reusable `` was created to ensure a consistent look and feel across all panels and to handle loading states gracefully with skeleton placeholders. - **Email Channel Display:** The page now fetches and displays the user's email notification channels, which are synced from their verified GitHub emails via the existing `pingUser` flow. A tooltip has been added to inform users of this behavior. - **Coming Soon Placeholders:** The RSS and Webhook panels are included as placeholders with "Coming soon" messages and disabled "Create" buttons, preparing the UI for future implementation. - **Sidebar Refactoring:** The main sidebar has been refactored to use a `renderNavItem` helper function for top-level links, improving code consistency. The "Notification Channels" link now appears as a top-level item under a "Settings" section that is only visible to logged-in users. - **Testing:** Comprehensive unit tests for the new page and all panel components have been added. A new end-to-end (Playwright) test ensures the page functions correctly for both authenticated and unauthenticated users, and includes visual regression snapshots. --- e2e/tests/notification-channels.spec.ts | 70 +++++++++++ ...-channels-authenticated-chromium-linux.png | Bin 0 -> 26083 bytes ...n-channels-authenticated-firefox-linux.png | Bin 0 -> 42305 bytes ...on-channels-authenticated-webkit-linux.png | Bin 0 -> 30950 bytes .../sidebar-authenticated-chromium-linux.png | Bin 25204 -> 25403 bytes .../sidebar-authenticated-firefox-linux.png | Bin 42207 -> 45975 bytes .../sidebar-authenticated-webkit-linux.png | Bin 42501 -> 46123 bytes frontend/nginx.conf | 4 + .../img/shoelace/assets/icons/envelope.svg | 3 + .../shoelace/assets/icons/mailbox-flag.svg | 4 + .../img/shoelace/assets/icons/plus-lg.svg | 3 + .../static/img/shoelace/assets/icons/rss.svg | 4 + .../img/shoelace/assets/icons/webhook.svg | 5 + frontend/src/static/js/api/client.ts | 23 ++++ ...status-notification-email-channels.test.ts | 86 +++++++++++++ .../test/webstatus-notification-panel.test.ts | 117 +++++++++++++++++ ...ebstatus-notification-rss-channels.test.ts | 59 +++++++++ ...atus-notification-webhook-channels.test.ts | 59 +++++++++ .../webstatus-notification-channels-page.ts | 118 ++++++++++++++++++ .../webstatus-notification-email-channels.ts | 97 ++++++++++++++ .../webstatus-notification-panel.ts | 84 +++++++++++++ .../webstatus-notification-rss-channels.ts | 47 +++++++ ...webstatus-notification-webhook-channels.ts | 47 +++++++ .../js/components/webstatus-sidebar-menu.ts | 35 +++++- frontend/src/static/js/utils/app-router.ts | 5 + 25 files changed, 869 insertions(+), 1 deletion(-) create mode 100644 e2e/tests/notification-channels.spec.ts create mode 100644 e2e/tests/notification-channels.spec.ts-snapshots/notification-channels-authenticated-chromium-linux.png create mode 100644 e2e/tests/notification-channels.spec.ts-snapshots/notification-channels-authenticated-firefox-linux.png create mode 100644 e2e/tests/notification-channels.spec.ts-snapshots/notification-channels-authenticated-webkit-linux.png create mode 100644 frontend/src/static/img/shoelace/assets/icons/envelope.svg create mode 100644 frontend/src/static/img/shoelace/assets/icons/mailbox-flag.svg create mode 100644 frontend/src/static/img/shoelace/assets/icons/plus-lg.svg create mode 100644 frontend/src/static/img/shoelace/assets/icons/rss.svg create mode 100644 frontend/src/static/img/shoelace/assets/icons/webhook.svg create mode 100644 frontend/src/static/js/components/test/webstatus-notification-email-channels.test.ts create mode 100644 frontend/src/static/js/components/test/webstatus-notification-panel.test.ts create mode 100644 frontend/src/static/js/components/test/webstatus-notification-rss-channels.test.ts create mode 100644 frontend/src/static/js/components/test/webstatus-notification-webhook-channels.test.ts create mode 100644 frontend/src/static/js/components/webstatus-notification-channels-page.ts create mode 100644 frontend/src/static/js/components/webstatus-notification-email-channels.ts create mode 100644 frontend/src/static/js/components/webstatus-notification-panel.ts create mode 100644 frontend/src/static/js/components/webstatus-notification-rss-channels.ts create mode 100644 frontend/src/static/js/components/webstatus-notification-webhook-channels.ts diff --git a/e2e/tests/notification-channels.spec.ts b/e2e/tests/notification-channels.spec.ts new file mode 100644 index 000000000..d45d5113f --- /dev/null +++ b/e2e/tests/notification-channels.spec.ts @@ -0,0 +1,70 @@ +/** + * 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 {loginAsUser, BASE_URL} from './utils'; + +test.describe('Notification Channels Page', () => { + test('redirects unauthenticated user to home and shows toast', async ({ + page, + }) => { + await page.goto(`${BASE_URL}/settings/notification-channels`); + + // Expect to be redirected to the home page. + await expect(page).toHaveURL(BASE_URL); + // FYI: We do not assert the toast because it flashes on the screen due to the redirect. + }); + + test('authenticated user sees their email channel and coming soon messages', async ({ + page, + }) => { + // Log in as a test user + await loginAsUser(page, 'test user 1'); + + // Navigate to the notification channels page + await page.goto(`${BASE_URL}/settings/notification-channels`); + + // Move the mouse to a neutral position to avoid hover effects on the screenshot + await page.mouse.move(0, 0); + + // Expect the URL to be correct + await expect(page).toHaveURL(`${BASE_URL}/settings/notification-channels`); + + // Verify Email panel content + const emailPanel = page.locator('webstatus-notification-email-channels'); + await expect(emailPanel).toBeVisible(); + await expect(emailPanel).toContainText('test.user.1@example.com'); + await expect(emailPanel).toContainText('Enabled'); + + // Verify RSS panel content + const rssPanel = page.locator('webstatus-notification-rss-channels'); + await expect(rssPanel).toBeVisible(); + await expect(rssPanel).toContainText('Coming soon'); + + // Verify Webhook panel content + const webhookPanel = page.locator( + 'webstatus-notification-webhook-channels', + ); + await expect(webhookPanel).toBeVisible(); + await expect(webhookPanel).toContainText('Coming soon'); + + // Take a screenshot for visual regression + const pageContainer = page.locator('.page-container'); + await expect(pageContainer).toHaveScreenshot( + 'notification-channels-authenticated.png', + ); + }); +}); diff --git a/e2e/tests/notification-channels.spec.ts-snapshots/notification-channels-authenticated-chromium-linux.png b/e2e/tests/notification-channels.spec.ts-snapshots/notification-channels-authenticated-chromium-linux.png new file mode 100644 index 0000000000000000000000000000000000000000..c9922b22526602ad2a7bedf8063920d0c3ac8b53 GIT binary patch literal 26083 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⪙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 C>TGQQ literal 0 HcmV?d00001 diff --git a/e2e/tests/notification-channels.spec.ts-snapshots/notification-channels-authenticated-firefox-linux.png b/e2e/tests/notification-channels.spec.ts-snapshots/notification-channels-authenticated-firefox-linux.png new file mode 100644 index 0000000000000000000000000000000000000000..a60ecc594e1d100730b0d859773f1463a2535794 GIT binary patch literal 42305 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%0Dbun6XF#+VQong(|QY{QR!ZNi#a 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 literal 0 HcmV?d00001 diff --git a/e2e/tests/sidebar.spec.ts-snapshots/sidebar-authenticated-chromium-linux.png b/e2e/tests/sidebar.spec.ts-snapshots/sidebar-authenticated-chromium-linux.png index 2dcb90025659fccf33fc4209dab446ce05b82aaa..3290834fb3fbedc8c34fb91a2527bdb724ddebd0 100644 GIT binary patch literal 25403 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 G5Bx8)Yk89Z literal 25204 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 diff --git a/e2e/tests/sidebar.spec.ts-snapshots/sidebar-authenticated-firefox-linux.png b/e2e/tests/sidebar.spec.ts-snapshots/sidebar-authenticated-firefox-linux.png index 7b3d6e2cbf90df974213d834ba907613c52dde96..efbde13eb66112bfb273842660456212247286c4 100644 GIT binary patch literal 45975 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 diff --git a/e2e/tests/sidebar.spec.ts-snapshots/sidebar-authenticated-webkit-linux.png b/e2e/tests/sidebar.spec.ts-snapshots/sidebar-authenticated-webkit-linux.png index 95dfba4cde7cefb6935da3b8e095bdb2ac7ddd99..860e2a41b896a7f6a9b337a718662d2afb1ac0d4 100644 GIT binary patch delta 12008 zcmcI~cRbbq-?vgqgEBHQlFH5=nT1rc$=;hdR`&SlD~XH>A*&LSm23xxkiC%RZ%(Q!WK{dvD%@7L?Og4>Q~|2h7O=1kdX5+WiZbPJbg z0ZUvkLYDur z_E;QC_MA2=&|O(yx2S*dp}x^6?LjnV<)@~~V_pD!#p>}@gDq(ynKiFl|L>`w%Idbxr_sbo^V zyna?j1~HM83_!cKw6?NxalNdmskwUZS*|wMrI#m}nNzRUIl>Y)HY%6qzbtx4+=x!7 zsR=Z{mt0s_*xcTJ{mPXKy!qc-+0&cagv|KLaD8$2IMcuCa{pc=`|GdcFV7z=4>*Xl zSr?Y@Of+It3ayn5mpCVS7?I*VY)1Cu z!=dyt&*8X@6mMSM{Q2tT!)?_i>P(^KSKI6BTx+8mHSO)n^Q6PdC=^Q6@9QdIh0??; ztE)Fx^P1Zo3hTy3U-BL*qdmwEJ5MrS!^=|H?XFQfjNql1n3$AO@OxFL4n`JifQ!ik zL`ms84tErZbLFBf%h4-*F4DA*QTWdLOl5XjJN3f%*k>lEli#eI{h&z^!16?q@WF$f z;4ThQ%mwl5V>Ggv@bo`1fB%n=E{>?yxaei~%g-)s2jX#rAE`L_Z$0w=VsWtl!AGl& z`@IDgG{16omyAX4zStl1rwK^wK7HHXeo#3#Kj!gn^F$4%j`4&sxN3@IogS34vMZAU8YKJ1p(HN1?|Epz5SOH+O7 zwyiBYTnAT!dG-!i6OzC;#7k8S+vj&+$ZCK(-a(kbv*^m%*p;J!hgdfM|cG`i4Xz`XjvgPONmEc|F6 z69o-|{gITE&y$Hi-6!7in$)g$%D7>z;3mr)`Mp@5;U!W-BOW~Ogm%x81U(H8S1NHF zifk}9>(7(=`Q=gckBH0P+-6W;bn+F{)GqjLO{UI9GAY+MlyntX_&Nq`&h-W2iJP7h zpSpz{9zS;MP{?ucnPM!bz2jH?Tm~`cS6n&)4Rv+57YB<&!yQi4l)KFgl)0RxqoX5q zvpYe1FtY6pK(;3RTt4tJ})j+9Gb#m8}y|+vdN&>N-}WrZPuK_5`m@$q_1*jxJ%a zb^wdL?cpKfyR|SeSgggVopXDC%W-vOXHjSMd+^I^$TfyKhms5Q`jKyWn6I$2t0iJA z8c}nx!C&8@9z##I&ackkLS4x&9}90!ZW;d}VCR(?rIN!vjo#i5y#Dyh

k3GJt+ol$YX9I(TG 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

3ZaN4>&Bj44nEo-2n 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 delta 8358 zcmb`Nc{r5s-}j}>7Lg)Zl0x>7eJMhAvKAq-B-yu_VJ=$K+kE)#~#h1SYs|G-8jcsX4fvOp%FskYCnBCAJwX2V{;yl&rdRBet8gZ5{_9ZqvtH-r{RsGt{=PW?ru<< zjZN%!Xm*k;+82kdOWN95)G3&JGLQUZE_CYb*#sE0YV~;~b!h(JAm9uGnKp_A8`u_Z ziXD;k>4ECwrN0NNO?M#>2y4Hu`_IQm1@-N}zuVs47TuV6(A3O`>7ynmaFl@l#{Zv;CVtg8q3`hXfDLhLv0B`6Ts!7!yIZ%c2>`*8M@1)Eh?k>9?p5534IS85|n3VX7Igxo}QjVpPpQJ zjzc1}Ne79eixu^c+pH`z=P~%8`sKszqzX{tq-TbPx1Zs^UOstt3#A#iPXRXH)1lA$ z{%eg}#)V8)g>^5at}HC@ISp?jeTJdu<=rhkJxM>)GdKU9v{l5T2Jxhyevy0^F2 z4Q>G*`4)Slv%klo@@0QN4>(e{Z0=xF%eGy$G>23l^!iB~&*AntI5~gLK<%Q27gOli=eizBy%_EoC^pm78P)4fxh>;0Cqxm>fU=cL`8!xDL6MyN z@L;p=1l^gF+1c4G-@oKJ4?y>Q6y^PR`#wF9G%K^5{8J%$hCU#^s>)kI!5fVI6sQ0V z4UN-a$xYoD7Ctt$W6 z9_O2$ojsoLa5W<6+c%05Q^EbM<@u>rZGZS;M07MS@5hJ9`(t^KrX47qrsj}#s#HZ- z*r}865)<{{dzxJi67SzLf;nF2=P&4;1qz^i@F_;n>~qAMHxt2UhtIRJX04dPNKxyD zyk!S%F({9zmYYe>=)>c1R;lelCk~y)>IQ_%o12?M3kp)6I?l(-D)UTVYkqZ@pc;&BvI=p zT_5H(b#!doV;O!gY=Tq1u~gaPG24+KzJ1q_d9KI$N0K!}zLKsNV^u!C(R0)~T1#ud zd^)cEo~h}j)2Cm~%*{=?%K)+Zpa5elHoXCKGNyU*>GwCa0i*$({R+C(w$VmR+-uI# z(<^FQmivjK+t*vEpq!MH2by~L<{hFa_#U&ub+Av~T#0f5q zUiJwId2mEtSs1Q%EPSS(Yj|1mZ%;?oUC0#dSr75$TVVWLF+_~TXhKTFy2j=DlU?}E`4-?3TTxPMczVWrIpxTCYzRQJavRv zu3+$Z-PA8d8|iwe&v2g*&%MoLtpw{YF16w>hP_)S-H*SJK3o+xn^3suQBr_3ltR00 z^`zX+R0@RmW~<77)Pbch^r1YK%-J-e*{2V;S;mahA3jVP`fQ)NH{>Mw+2=VEnM~eX z9;xpV=a5&r-xduqfF-__{N~THuZpVbfFNZ5^r_{{XzfPTvkL@qj1R#aXpHjA>?|Xv z)oAUXTCcN74#IbeO@$*~7f>>zjh%7PIht|bcXoGAOe`;}fm3*kmCn9QnVma5v? zGX4HdDzwIO!ZFb&PVSkRm)(2NOaF@gtp-ZawP4X(ZmuWOXJip4fTo93n&3bnSfyO3 zfa&L}|3^)ZleR!vL{TLCui#?Fr+>`I)=mTO7>Zb-ix3EwHGRGo$sHq9NpkiUK zUd2B`4iueYVj8TpX=JbFA^!b6t*|{Brd2MXppX`EflthHhANs%->xls^|Gudd(Is@aU zH{|6l0x!3YjA$$iixN&J06sl#OyyXzC=6zHq9$OM2CQ%F&Q48g!`s4==5Qm}*3i6+ z$5fu&a(`i;d8rgBxvsoiltt9R7Cqu2!`@$Dn7+Ar;Y0T&cKWl&t>{;_an{ZjRG4!p z#imPlSv)N-Z?*rDK3EnO5zDIr0!2;h6o>oDO+_Zu?6RKBsJ?a}B(VKuzgwx@2}RcN z-NLDdF$%r3?&aGDd^)VnB@Z|L)OgTF=70zR6LFNrUM^~XfxtY6)ji14EpF_%9E0Xc-6xCv?fF~#!|2PQp`jn-{myU&tlip}6TU7fc?vX{fPl0;Jk(6;IO)CE9}MUj zJksb;nTj}f~0lbR>L*@!W*vIZrY z5lw~{yg`kLTU%!$-59$ZXz$()_V@RXw@4at$^P`|T~^ke>ZLItb7E||;Em$wK9?^O z-g4AbYiA=Oc`Fdx3;<4;g4Ex#^)>`rz&HP@ zKYco*h*#l8){lDriVg`;ap=j=_gr5gh~O{?GhTfpwY7Do77i-qr_nR@GeG3vY_M1d5HIfTo&l&!^{qV)1vZM~-(UVxZusxns^Jk)h_qXF z>ZV2H8`?@UGwnuSH+wf0CBvH=0g41{4JCm=CG5VB9~&~BeLo&>+E1QoA@)E>9e@8n z7(6>se%jQGY=qLQgn3<~fZkq_&a=iUDIBb+nwOe}goFf5Sx_=CtZi@4_ZLdA%XxoD z5X7Bl*_L5sG!dxg1mc*J<6NgxY=n~H=5Bn$sTh2LPtW7%aQj5-355Wegi6OA+sdJ5 z{RQ06j;a?)YVu&0Hcb%`?Yqorv%SJq<4?Ty<6afJNmgLaWr*@`SR~dqaTuyv*MOQxSOSG_p+TlxEQy({SDB5^X^4-fb#sSvW)%T#KXU;WzFo6q$=BO^K~iAB(icdbLPk6%E5 zGE!co?F}qAUEYroG;7263B+k@+wPQVXT|n*1dv+^AZIg8FX;e+wEw7~x?_bm(cv{p z9&xE)z6I>QX{pw?@o}#3=Jq$2y>=Ii%LnX19X=xu&)@?#UaYT|5ZVN@$M^bT_1sb1yMk;aob|Z1MP&2%=oI%ygUvOf{fpaf`sEwz=RanO_C@=zY@WY(j36*yjljyJrCHkA zru+s6m_p8H@s8!_c|fsu!s(ZHdZ31E)6x{3)GYycAaPeppokYb+NYEATeGYuCBaC)kSaENJ3y|QUH(qb92GV|9}__4s3QG z?9-EG2c@Hc+Tkf`YHEj-Q8cUKUiqTbV|}pxV6$rVWGGVn9joU96eGq;2foBa1}omM z>u?*K82{yJY+;}baO4j^O>kAc7*SVTh#F}wZOTkfKN*BZOGAJ(1uYvugmmik=_itG zvWPEJ^i51{(HxHCgO`hr#RKZ_J!(L6ATlvZCG2l=<=M5JrKY8Ao^+=)z`%V{q}|jX zJ(2?jG9);S$(bJ3IuG0j`PN-HT#iBMPvY{3pO|fnSkk-J7Rj~+@7N4vtnYFpqAxd> zP~2Ev0s-;?j$1VMpUkLuAQCWx9x4vBqiZ}o6i!3y3?gW`fg%$yoPmMC9grx&28tK| z6(qxv_AW!8-%d7Xes5Mqajt&&F!w=+_h*J;IX*Jt_>-+YxnBH(B8!1GFd$0~m6s=8 zLGXN@Yem1ZRS~3(?Qzt^!a}*As1{}7)xTWnHDDqnw5z`WR;`~UJ=D+<)%fP zhA`KQWaoh*&y`VFnjW7rdFR6Nu%i3eEObER17jKlsFjtxeoY1TT>;W{a0&T}fvbD4 zR1;Vpi}aP{Wz{2cjF>}L!2=&l#6>^3bQvg}27V~IN&~bPsL0x;kWz5MCQtmQk*HO# z@@4nF1=p{%4*wSt+b#VsB)(EcfP)AwS;m8sM);CN3_wV^)Fa!*lz+wm}mlIRbeBZK}YuQ~Vg|7^TVm-2yT*O|3mW%p*s~dH2bt(>Xag0%;7b5QkrTdwngy`N$l15aBfJyP`Vb zdn82I5!vr{IK$DK9R5EpQl{lros~UGC?#1&bc{v1-YWJ;3BI+|m?leWUiDWP1cQLe zC7sr^=dIJ_st4LMZg2)xi4GKv?HsOGZ^9LO6t~SXU=GX?5hH(xHe&jVF1A{@b=xoW zLNVrbn>=7esn4C8eGBZ-;hxXHXFMJcwCdGlvxZ%TxmaqqPNtIXvJC-`q21lx)k)i- z2@Vc^>gd>4>%|8=|7@M&a`kguj&7F<4Oug2Y=+*0{D?MjBIQVCOv_7T3cJko&uSo z&)Ba0?*;FRN|76DYge!5ZElVIoJcB}hi8{MDNU>o+Q9_mqNldp>(^9?76Ea5*p{_P z=m}x)MSh4fe6wb6*o6GNRT?;aTCEUjVB0tL>pu@IclcWg&*%RD#ACP z%>R6xKX>sGbm_Cp5FaZYdcMcnI;PuU`p5fRzW_xQ%g*JdqpguBr-7myfR#n$B; zFE3e;dZ$Xgm<6HNbzWCd6YP1vNQ;*ckTnp*C%_oUpxsNr);se*?(gnGzstvYTt;hZ zdkG$L=_OW}L_kFbl@8}X2QwGQx&fk?3cx=Bfq}B#{0X4F9CyB~np!2h@0N+8N6Zie z9ULA${5+#N*NxCtJdu~5J~8Gu?pN67A|(NmS#gOyrcTl)ol1@+J~(o*-(=cPNLe^AWGng4kZNJZ!V(};Bnv0S|9Gv=^g z{$eQhK6n>31H;KIkVSgSaUizI=FUU(9K*%aXa17la=tF*(H#D36HnxP1Af_mdsAe> z69KptR7wD&s4`0HdJ&1SvYrcy2Q(;_DlGZT(>i%pLBTQ{(U1Xf7WBW7JLcy6AUwaX zIn+!(JnPh(=aI|-adVgPg%0IDrAd$Bbl zGn0z~58EOyBNx)9Z!W<(P47R1Q`B7tNxGb<9Ab#WbLV|^PSnYi7<;$0gRR9uW0aAaf^_m~5 zdRF|XK|4v0T{+`tqVNcL`%6|F=udW1sRMC_)z0sMx^FG-Q-cM!Y=91Z<2vCOOdz=b z$jQq3)Rh=^-Y>-BCDyc*pVL3#jZ#rQ8ZtycqEVyH*I`cF@%7H9C$x=JK!N3CKfE$Kk5W3gn&4pebC`iAPnh@0R2G z(5rU<3Rp}csXXLs#_A8j1)E{+cSca?Bx_{DD0lkCdYL$4D**UG^C~B9Jl@LBC6!r? zTX{SPg+g^&gyVi!r84&+&$X2)mL5eP2FqFIF2EgN6_}Yc@gM9b~ty{H{oXMYZ4Msr55N4 z$DSCF#n9;ME1qM!*9XYH&Tm!g-n|3_CPL-Fq1I>T<$1qo3!fco?iEgI=)W2m{o8rb f|9ewR=K8Z<819Fmht+_;MxfAC)4NxuViWQo%7g=D diff --git a/frontend/nginx.conf b/frontend/nginx.conf index 981aece17..7c88c7fa8 100644 --- a/frontend/nginx.conf +++ b/frontend/nginx.conf @@ -44,6 +44,10 @@ http { try_files $uri $uri/ /index.html; } + location = /settings/notification-channels { + try_files $uri $uri/ /index.html; + } + location = / { try_files $uri $uri/ =404; } diff --git a/frontend/src/static/img/shoelace/assets/icons/envelope.svg b/frontend/src/static/img/shoelace/assets/icons/envelope.svg new file mode 100644 index 000000000..78bf1ded1 --- /dev/null +++ b/frontend/src/static/img/shoelace/assets/icons/envelope.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/frontend/src/static/img/shoelace/assets/icons/mailbox-flag.svg b/frontend/src/static/img/shoelace/assets/icons/mailbox-flag.svg new file mode 100644 index 000000000..8e24db05f --- /dev/null +++ b/frontend/src/static/img/shoelace/assets/icons/mailbox-flag.svg @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/frontend/src/static/img/shoelace/assets/icons/plus-lg.svg b/frontend/src/static/img/shoelace/assets/icons/plus-lg.svg new file mode 100644 index 000000000..531e86cd0 --- /dev/null +++ b/frontend/src/static/img/shoelace/assets/icons/plus-lg.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/frontend/src/static/img/shoelace/assets/icons/rss.svg b/frontend/src/static/img/shoelace/assets/icons/rss.svg new file mode 100644 index 000000000..18dc9f1be --- /dev/null +++ b/frontend/src/static/img/shoelace/assets/icons/rss.svg @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/frontend/src/static/img/shoelace/assets/icons/webhook.svg b/frontend/src/static/img/shoelace/assets/icons/webhook.svg new file mode 100644 index 000000000..6fbfb0543 --- /dev/null +++ b/frontend/src/static/img/shoelace/assets/icons/webhook.svg @@ -0,0 +1,5 @@ + + webhook + + + diff --git a/frontend/src/static/js/api/client.ts b/frontend/src/static/js/api/client.ts index 07d33430f..c60a39efe 100644 --- a/frontend/src/static/js/api/client.ts +++ b/frontend/src/static/js/api/client.ts @@ -54,6 +54,7 @@ export type BrowsersParameter = components['parameters']['browserPathParam']; type PageablePath = | '/v1/features' | '/v1/features/{feature_id}/stats/wpt/browsers/{browser}/channels/{channel}/{metric_view}' + | '/v1/users/me/notification-channels' | '/v1/stats/features/browsers/{browser}/feature_counts' | '/v1/users/me/saved-searches' | '/v1/stats/baseline_status/low_date_feature_counts'; @@ -421,6 +422,28 @@ export class APIClient { }); } + public async listNotificationChannels( + token: string, + ): Promise { + type NotificationChannelPage = SuccessResponsePageableData< + paths['/v1/users/me/notification-channels']['get'], + FetchOptions< + FilterKeys + >, + 'application/json', + '/v1/users/me/notification-channels' + >; + + return this.getAllPagesOfData< + '/v1/users/me/notification-channels', + NotificationChannelPage + >('/v1/users/me/notification-channels', { + headers: { + Authorization: `Bearer ${token}`, + }, + }); + } + public async pingUser( token: string, pingOptions?: {githubToken?: string}, diff --git a/frontend/src/static/js/components/test/webstatus-notification-email-channels.test.ts b/frontend/src/static/js/components/test/webstatus-notification-email-channels.test.ts new file mode 100644 index 000000000..22faf8ac6 --- /dev/null +++ b/frontend/src/static/js/components/test/webstatus-notification-email-channels.test.ts @@ -0,0 +1,86 @@ +/** + * 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 {assert, fixture, html} from '@open-wc/testing'; +import type {WebstatusNotificationEmailChannels} from '../../components/webstatus-notification-email-channels.js'; +import '../../components/webstatus-notification-email-channels.js'; +import {components} from 'webstatus.dev-backend'; +import {WebstatusNotificationPanel} from '../webstatus-notification-panel.js'; + +type NotificationChannelResponse = + components['schemas']['NotificationChannelResponse']; + +describe('webstatus-notification-email-channels', () => { + it('renders email channels correctly', async () => { + const mockChannels: NotificationChannelResponse[] = [ + { + id: '1', + type: 'email', + value: 'test1@example.com', + name: 'Email 1', + status: 'enabled', + created_at: '2023-01-01T00:00:00Z', + updated_at: '2023-01-01T00:00:00Z', + }, + { + id: '2', + type: 'email', + value: 'test2@example.com', + name: 'Email 2', + status: 'disabled', + created_at: '2023-01-01T00:00:00Z', + updated_at: '2023-01-01T00:00:00Z', + }, + ]; + + const el = await fixture(html` + + `); + + const emailItems = el.shadowRoot!.querySelectorAll('.channel-item'); + assert.equal(emailItems.length, mockChannels.length); + + // Test first email channel + const email1Name = emailItems[0].querySelector('.name'); + assert.include(email1Name!.textContent, 'test1@example.com'); + const email1Badge = emailItems[0].querySelector('sl-badge'); + assert.isNotNull(email1Badge); + assert.include(email1Badge!.textContent, 'Enabled'); + + // Test second email channel (disabled, so no badge) + const email2Name = emailItems[1].querySelector('.name'); + assert.include(email2Name!.textContent, 'test2@example.com'); + const email2Badge = emailItems[1].querySelector('sl-badge'); + assert.isNotNull(email2Badge); + assert.include(email2Badge!.textContent, 'Disabled'); + }); + + it('passes loading state to the base panel', async () => { + const el = await fixture(html` + + `); + + const basePanel = el.shadowRoot!.querySelector( + 'webstatus-notification-panel', + ); + assert.isNotNull(basePanel); + assert.isTrue(basePanel!.loading); + }); +}); diff --git a/frontend/src/static/js/components/test/webstatus-notification-panel.test.ts b/frontend/src/static/js/components/test/webstatus-notification-panel.test.ts new file mode 100644 index 000000000..cfda47b22 --- /dev/null +++ b/frontend/src/static/js/components/test/webstatus-notification-panel.test.ts @@ -0,0 +1,117 @@ +/** + * 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 {assert, fixture, html} from '@open-wc/testing'; +import '../../components/webstatus-notification-panel.js'; +import type {WebstatusNotificationPanel} from '../../components/webstatus-notification-panel.js'; + +describe('webstatus-notification-panel', () => { + it('renders content in the content slot', async () => { + const el = await fixture(html` + +

Test Content
+
+ `); + const contentSlot = el.shadowRoot!.querySelector( + 'slot[name="content"]', + ); + assert.isNotNull(contentSlot); + const assignedNodes = contentSlot.assignedNodes({ + flatten: true, + }) as HTMLElement[]; + assert.include(assignedNodes[0].textContent, 'Test Content'); + }); + + it('renders an icon in the icon slot', async () => { + const el = await fixture(html` + + + + `); + const iconSlot = + el.shadowRoot!.querySelector('slot[name="icon"]'); + assert.isNotNull(iconSlot); + const assignedNodes = iconSlot.assignedNodes({ + flatten: true, + }) as HTMLElement[]; + assert.equal(assignedNodes[0].tagName, 'SL-ICON'); + assert.equal(assignedNodes[0].getAttribute('name'), 'test-icon'); + }); + + it('renders a title in the title slot', async () => { + const el = await fixture(html` + + Test Title + + `); + const titleSlot = + el.shadowRoot!.querySelector('slot[name="title"]'); + assert.isNotNull(titleSlot); + const assignedNodes = titleSlot.assignedNodes({ + flatten: true, + }) as HTMLElement[]; + assert.include(assignedNodes[0].textContent, 'Test Title'); + }); + + it('renders actions in the actions slot', async () => { + const el = await fixture(html` + + Action Button + + `); + const actionsSlot = el.shadowRoot!.querySelector( + 'slot[name="actions"]', + ); + assert.isNotNull(actionsSlot); + const assignedNodes = actionsSlot.assignedNodes({ + flatten: true, + }) as HTMLElement[]; + assert.equal(assignedNodes[0].tagName, 'SL-BUTTON'); + assert.include(assignedNodes[0].textContent, 'Action Button'); + }); + + it('displays skeletons when loading is true and hides content', async () => { + const el = await fixture(html` + +
Test Content
+
+ `); + const skeletons = el.shadowRoot!.querySelectorAll('sl-skeleton'); + assert.equal(skeletons.length, 2); + const contentSlot = el.shadowRoot!.querySelector( + 'slot[name="content"]', + ); + assert.isNull(contentSlot); + }); + + it('hides skeletons when loading is false and shows content', async () => { + const el = await fixture(html` + +
Test Content
+
+ `); + const skeletons = el.shadowRoot!.querySelectorAll('sl-skeleton'); + assert.equal(skeletons.length, 0); + const contentSlot = el.shadowRoot!.querySelector( + 'slot[name="content"]', + ); + assert.isNotNull(contentSlot); + const assignedNodes = contentSlot.assignedNodes({ + flatten: true, + }) as HTMLElement[]; + assert.include(assignedNodes[0].textContent, 'Test Content'); + }); +}); diff --git a/frontend/src/static/js/components/test/webstatus-notification-rss-channels.test.ts b/frontend/src/static/js/components/test/webstatus-notification-rss-channels.test.ts new file mode 100644 index 000000000..93ab3fec8 --- /dev/null +++ b/frontend/src/static/js/components/test/webstatus-notification-rss-channels.test.ts @@ -0,0 +1,59 @@ +/** + * 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 {assert, fixture, html} from '@open-wc/testing'; +import type {WebstatusNotificationRssChannels} from '../../components/webstatus-notification-rss-channels.js'; +import '../../components/webstatus-notification-rss-channels.js'; +import '../../components/webstatus-notification-panel.js'; + +describe('webstatus-notification-rss-channels', () => { + it('displays "Coming soon" message', async () => { + const el = await fixture(html` + + `); + + const basePanel = el.shadowRoot!.querySelector( + 'webstatus-notification-panel', + ); + assert.isNotNull(basePanel); + + const comingSoonText = basePanel!.querySelector( + '[slot="content"] p', + ) as HTMLParagraphElement; + assert.isNotNull(comingSoonText); + assert.include(comingSoonText.textContent, 'Coming soon'); + }); + + it('displays "Create RSS channel" button', async () => { + const el = await fixture(html` + + `); + + const basePanel = el.shadowRoot!.querySelector( + 'webstatus-notification-panel', + ); + assert.isNotNull(basePanel); + + const createButton = basePanel!.querySelector( + '[slot="actions"] sl-button', + ) as HTMLButtonElement; + assert.isNotNull(createButton); + assert.include( + createButton.textContent!.trim().replace(/\s+/g, ' '), + 'Create RSS channel', + ); + }); +}); diff --git a/frontend/src/static/js/components/test/webstatus-notification-webhook-channels.test.ts b/frontend/src/static/js/components/test/webstatus-notification-webhook-channels.test.ts new file mode 100644 index 000000000..933cda626 --- /dev/null +++ b/frontend/src/static/js/components/test/webstatus-notification-webhook-channels.test.ts @@ -0,0 +1,59 @@ +/** + * 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 {assert, fixture, html} from '@open-wc/testing'; +import type {WebstatusNotificationWebhookChannels} from '../../components/webstatus-notification-webhook-channels.js'; +import '../../components/webstatus-notification-webhook-channels.js'; +import '../../components/webstatus-notification-panel.js'; + +describe('webstatus-notification-webhook-channels', () => { + it('displays "Coming soon" message', async () => { + const el = await fixture(html` + + `); + + const basePanel = el.shadowRoot!.querySelector( + 'webstatus-notification-panel', + ); + assert.isNotNull(basePanel); + + const comingSoonText = basePanel!.querySelector( + '[slot="content"] p', + ) as HTMLParagraphElement; + assert.isNotNull(comingSoonText); + assert.include(comingSoonText.textContent, 'Coming soon'); + }); + + it('displays "Create Webhook channel" button', async () => { + const el = await fixture(html` + + `); + + const basePanel = el.shadowRoot!.querySelector( + 'webstatus-notification-panel', + ); + assert.isNotNull(basePanel); + + const createButton = basePanel!.querySelector( + '[slot="actions"] sl-button', + ) as HTMLButtonElement; + assert.isNotNull(createButton); + assert.include( + createButton.textContent!.trim().replace(/\s+/g, ' '), + 'Create Webhook channel', + ); + }); +}); diff --git a/frontend/src/static/js/components/webstatus-notification-channels-page.ts b/frontend/src/static/js/components/webstatus-notification-channels-page.ts new file mode 100644 index 000000000..c524be7d1 --- /dev/null +++ b/frontend/src/static/js/components/webstatus-notification-channels-page.ts @@ -0,0 +1,118 @@ +/** + * 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 of a 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 {LitElement, css, html} from 'lit'; +import {customElement, state} from 'lit/decorators.js'; +import {User} from 'firebase/auth'; +import {Task} from '@lit/task'; + +import {firebaseUserContext} from '../contexts/firebase-user-context.js'; +import {apiClientContext} from '../contexts/api-client-context.js'; +import {APIClient} from '../api/client.js'; +import {components} from 'webstatus.dev-backend'; +import {toast} from '../utils/toast.js'; +import {navigateToUrl} from '../utils/app-router.js'; + +import './webstatus-notification-email-channels.js'; +import './webstatus-notification-rss-channels.js'; +import './webstatus-notification-webhook-channels.js'; + +type NotificationChannelResponse = + components['schemas']['NotificationChannelResponse']; + +@customElement('webstatus-notification-channels-page') +export class WebstatusNotificationChannelsPage extends LitElement { + static styles = css` + .container { + display: flex; + flex-direction: column; + gap: 16px; + } + `; + + @consume({context: firebaseUserContext, subscribe: true}) + @state() + user: User | null | undefined; + + @consume({context: apiClientContext}) + @state() + apiClient!: APIClient; + + @state() + private emailChannels: NotificationChannelResponse[] = []; + + private _channelsTask = new Task(this, { + task: async () => { + if (this.user === null) { + navigateToUrl('/'); + void toast('You must be logged in to view this page.', 'danger'); + return; + } + if (this.user === undefined) { + return; + } + + const token = await this.user.getIdToken(); + const channels = await this.apiClient + .listNotificationChannels(token) + .catch(e => { + const errorMessage = e instanceof Error ? e.message : 'unknown error'; + void toast( + `Failed to load notification channels: ${errorMessage}`, + 'danger', + ); + return []; + }); + this.emailChannels = channels.filter(c => c.type === 'email'); + }, + args: () => [this.user], + }); + + render() { + return html` +
+ ${this._channelsTask.render({ + pending: () => html` + + + + + + + + + `, + + complete: () => html` + + + + + + + `, + error: e => { + const errorMessage = + e instanceof Error ? e.message : 'unknown error'; + return html`

Error: ${errorMessage}

`; + }, + })} +
+ `; + } +} diff --git a/frontend/src/static/js/components/webstatus-notification-email-channels.ts b/frontend/src/static/js/components/webstatus-notification-email-channels.ts new file mode 100644 index 000000000..60223adf8 --- /dev/null +++ b/frontend/src/static/js/components/webstatus-notification-email-channels.ts @@ -0,0 +1,97 @@ +/** + * 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 {LitElement, css, html} from 'lit'; +import {customElement, property} from 'lit/decorators.js'; +import {repeat} from 'lit/directives/repeat.js'; +import {components} from 'webstatus.dev-backend'; +import './webstatus-notification-panel.js'; + +type NotificationChannelResponse = + components['schemas']['NotificationChannelResponse']; + +@customElement('webstatus-notification-email-channels') +export class WebstatusNotificationEmailChannels extends LitElement { + static styles = css` + .channel-item { + background-color: #f9f9f9; + display: flex; + align-items: center; + justify-content: space-between; + padding: 8px 16px; + border-bottom: 1px solid #e4e4e7; + } + + .channel-item:last-child { + border-bottom: none; + } + + .channel-info { + display: flex; + flex-direction: column; + } + + .channel-info .name { + font-size: 14px; + } + + .info-icon-button { + font-size: 1.2rem; + } + `; + + @property({type: Array}) + channels: NotificationChannelResponse[] = []; + + @property({type: Boolean}) + loading = false; + + render() { + return html` + + + Email +
+ + + +
+
+ ${repeat( + this.channels, + channel => channel.id, + channel => html` +
+
+ ${channel.value} +
+ ${channel.status === 'enabled' + ? html`Enabled` + : html`Disabled`} +
+ `, + )} +
+
+ `; + } +} diff --git a/frontend/src/static/js/components/webstatus-notification-panel.ts b/frontend/src/static/js/components/webstatus-notification-panel.ts new file mode 100644 index 000000000..c1d577000 --- /dev/null +++ b/frontend/src/static/js/components/webstatus-notification-panel.ts @@ -0,0 +1,84 @@ +/** + * Copyright 2024 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, css, html} from 'lit'; +import {customElement, property} from 'lit/decorators.js'; + +@customElement('webstatus-notification-panel') +export class WebstatusNotificationPanel extends LitElement { + static styles = css` + .card { + border: 1px solid #e4e4e7; + border-radius: 4px; + overflow: hidden; + } + + .card-header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 8px 16px; + } + + .card-header .title { + display: flex; + align-items: center; + gap: 8px; + font-weight: bold; + font-size: 16px; + } + + .card-body { + padding: 0 20px 20px 20px; + } + + .loading-skeleton { + display: flex; + flex-direction: column; + gap: 10px; + padding: 16px; + } + `; + + @property({type: Boolean}) + loading = false; + + render() { + return html` +
+
+
+ + +
+
+ +
+
+
+ ${this.loading + ? html` +
+ + +
+ ` + : html``} +
+
+ `; + } +} diff --git a/frontend/src/static/js/components/webstatus-notification-rss-channels.ts b/frontend/src/static/js/components/webstatus-notification-rss-channels.ts new file mode 100644 index 000000000..a7d25d04b --- /dev/null +++ b/frontend/src/static/js/components/webstatus-notification-rss-channels.ts @@ -0,0 +1,47 @@ +/** + * 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 {LitElement, css, html} from 'lit'; +import {customElement} from 'lit/decorators.js'; +import './webstatus-notification-panel.js'; + +@customElement('webstatus-notification-rss-channels') +export class WebstatusNotificationRssChannels extends LitElement { + static styles = css` + .card-body { + padding: 20px; + color: #71717a; + } + `; + + render() { + return html` + + + RSS +
+ Create RSS + channel +
+
+

Coming soon

+
+
+ `; + } +} diff --git a/frontend/src/static/js/components/webstatus-notification-webhook-channels.ts b/frontend/src/static/js/components/webstatus-notification-webhook-channels.ts new file mode 100644 index 000000000..ac02f9b0a --- /dev/null +++ b/frontend/src/static/js/components/webstatus-notification-webhook-channels.ts @@ -0,0 +1,47 @@ +/** + * 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 {LitElement, css, html} from 'lit'; +import {customElement} from 'lit/decorators.js'; +import './webstatus-notification-panel.js'; + +@customElement('webstatus-notification-webhook-channels') +export class WebstatusNotificationWebhookChannels extends LitElement { + static styles = css` + .card-body { + padding: 20px; + color: #71717a; + } + `; + + render() { + return html` + + + Webhook +
+ Create Webhook + channel +
+
+

Coming soon

+
+
+ `; + } +} diff --git a/frontend/src/static/js/components/webstatus-sidebar-menu.ts b/frontend/src/static/js/components/webstatus-sidebar-menu.ts index 3b285ebfe..9e47c318a 100644 --- a/frontend/src/static/js/components/webstatus-sidebar-menu.ts +++ b/frontend/src/static/js/components/webstatus-sidebar-menu.ts @@ -50,11 +50,14 @@ import { savedSearchHelpers, } from '../contexts/app-bookmark-info-context.js'; import {TaskStatus} from '@lit/task'; +import {User} from 'firebase/auth'; +import {firebaseUserContext} from '../contexts/firebase-user-context.js'; // Map from sl-tree-item ids to paths. enum NavigationItemKey { FEATURES = 'features-item', STATISTICS = 'statistics-item', + NOTIFICATION_CHANNELS = 'notification-channels-item', } interface NavigationItem { @@ -75,6 +78,10 @@ const navigationMap: NavigationMap = { id: NavigationItemKey.STATISTICS, path: '/stats', }, + [NavigationItemKey.NOTIFICATION_CHANNELS]: { + id: NavigationItemKey.NOTIFICATION_CHANNELS, + path: '/settings/notification-channels', + }, }; interface GetLocationFunction { @@ -152,6 +159,10 @@ export class WebstatusSidebarMenu extends LitElement { @state() appBookmarkInfo?: AppBookmarkInfo; + @consume({context: firebaseUserContext, subscribe: true}) + @state() + user: User | null | undefined; + // For now, unconditionally open the features dropdown. @state() private isFeaturesDropdownExpanded: boolean = true; @@ -371,6 +382,28 @@ export class WebstatusSidebarMenu extends LitElement { `; } + renderSettingsMenu(): TemplateResult { + if (this.user === undefined) { + return html`${nothing}`; + } + if (this.user === null) { + return html`${nothing}`; + } + + return html` + + + + + Notification Channels + + + `; + } + render(): TemplateResult { return html` @@ -396,7 +429,7 @@ export class WebstatusSidebarMenu extends LitElement { Statistics --> - ${this.renderUserSavedSearches()} + ${this.renderUserSavedSearches()} ${this.renderSettingsMenu()} diff --git a/frontend/src/static/js/utils/app-router.ts b/frontend/src/static/js/utils/app-router.ts index 96f316b87..1b882c85f 100644 --- a/frontend/src/static/js/utils/app-router.ts +++ b/frontend/src/static/js/utils/app-router.ts @@ -21,6 +21,7 @@ import '../components/webstatus-feature-page.js'; 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'; export const initRouter = async (element: HTMLElement): Promise => { const router = new Router(element); @@ -37,6 +38,10 @@ export const initRouter = async (element: HTMLElement): Promise => { component: 'webstatus-stats-page', path: '/stats', }, + { + component: 'webstatus-notification-channels-page', + path: '/settings/notification-channels', + }, { component: 'webstatus-feature-gone-split-page', path: '/errors-410/feature-gone-split', From f81459655266e3aa7f2ffdf5db5186e9b6af4c8b Mon Sep 17 00:00:00 2001 From: James Scott Date: Fri, 2 Jan 2026 21:37:00 +0000 Subject: [PATCH 24/27] Update GEMINI.md knowledge base about frontend changes I had the gemini CLI examine some of its shortcomings while working on https://github.com/GoogleChrome/webstatus.dev/pull/2152 It updated GEMINI.md with its learnings --- GEMINI.md | 26 +++++++++++++++----------- 1 file changed, 15 insertions(+), 11 deletions(-) diff --git a/GEMINI.md b/GEMINI.md index bd4054d2f..945f9702c 100644 --- a/GEMINI.md +++ b/GEMINI.md @@ -69,6 +69,8 @@ The frontend Single Page Application (SPA). - **DON'T** introduce other UI frameworks like React or Vue. - **DO** use the Lit Context and service container pattern to access shared services. - **DON'T** create new global state management solutions. + - **DON'T** render the full page layout (header, sidebar, main container) inside a page component. DO focus on rendering only the specific content for that route. The main `webstatus-app` component provides the overall page shell. + - **DON'T** add generic class names (e.g., `.card`, `.container`) to `shared-css.ts`. DO leverage Shadow DOM encapsulation by defining component-specific styles within the component's `static styles` block. Use composition with slots (e.g., a `` component) for reusable layout patterns instead of shared global CSS classes. - **DO** add Playwright tests for new user-facing features and unit tests for component logic. #### Workflows / Data Ingestion Jobs (`workflows/`) @@ -165,17 +167,17 @@ This section describes the components related to user accounts, authentication, - **Authentication**: User authentication is handled via Firebase Authentication on the frontend, which integrates with GitHub as an OAuth provider. When a user signs in, the frontend receives a Firebase token and a GitHub token. The GitHub token is sent to the backend during a login sync process. - **User Profile Sync**: On login, the backend synchronizes the user's verified GitHub emails with their notification channels in the database. This is an example of a transactional update using the mapper pattern. The flow is as follows: - 1. A user signs in on the frontend. The frontend sends the user's GitHub token to the backend's `/v1/users/me/ping` endpoint. - 2. The `httpserver.PingUser` handler receives the request. It uses a `UserGitHubClient` to fetch the user's profile and verified emails from the GitHub API. - 3. The handler then calls `spanneradapters.Backend.SyncUserProfileInfo` with the user's profile information. - 4. The `Backend` adapter translates the `backendtypes.UserProfile` to a `gcpspanner.UserProfile` and calls `gcpspanner.Client.SyncUserProfileInfo`. - 5. `SyncUserProfileInfo` starts a `ReadWriteTransaction`. - 6. Inside the transaction, it fetches the user's existing `NotificationChannels` and `NotificationChannelStates`. - 7. It then compares the existing channels with the verified emails from GitHub. - - New emails result in new `NotificationChannel` and `NotificationChannelState` records, created using `createWithTransaction`. - - Emails that were previously disabled are re-enabled using `updateWithTransaction`. - - Channels for emails that are no longer verified are disabled, also using `updateWithTransaction`. - 8. The entire set of operations is committed atomically. If any step fails, the entire transaction is rolled back. + 1. A user signs in on the frontend. The frontend sends the user's GitHub token to the backend's `/v1/users/me/ping` endpoint. + 2. The `httpserver.PingUser` handler receives the request. It uses a `UserGitHubClient` to fetch the user's profile and verified emails from the GitHub API. + 3. The handler then calls `spanneradapters.Backend.SyncUserProfileInfo` with the user's profile information. + 4. The `Backend` adapter translates the `backendtypes.UserProfile` to a `gcpspanner.UserProfile` and calls `gcpspanner.Client.SyncUserProfileInfo`. + 5. `SyncUserProfileInfo` starts a `ReadWriteTransaction`. + 6. Inside the transaction, it fetches the user's existing `NotificationChannels` and `NotificationChannelStates`. + 7. It then compares the existing channels with the verified emails from GitHub. + - New emails result in new `NotificationChannel` and `NotificationChannelState` records, created using `createWithTransaction`. + - Emails that were previously disabled are re-enabled using `updateWithTransaction`. + - Channels for emails that are no longer verified are disabled, also using `updateWithTransaction`. + 8. The entire set of operations is committed atomically. If any step fails, the entire transaction is rolled back. - **Notification Channels**: The `NotificationChannels` table stores the destinations for notifications (e.g., email addresses). Each channel has a corresponding entry in the `NotificationChannelStates` table, which tracks whether the channel is enabled or disabled. - **Saved Search Subscriptions**: Authenticated users can save feature search queries and subscribe to receive notifications when the results of that query change. This is managed through the `UserSavedSearches` and `UserSavedSearchBookmarks` tables. @@ -232,6 +234,7 @@ This practice decouples the core application logic from the exact structure of t - **DO** add E2E tests for critical user journeys. - **DON'T** write E2E tests for small component-level interactions. - **DO** use resilient selectors like `data-testid`. + - **DO** move the mouse to a neutral position (e.g., `page.mouse.move(0, 0)`) before taking a screenshot to avoid flaky tests caused by unintended hover effects. - **Go Unit & Integration Tests**: - **DO** use table-driven unit tests with mocks for dependencies at the adapter layer (`spanneradapters`). - **DO** write **integration tests using `testcontainers-go`** for any changes to the `lib/gcpspanner` layer. This is especially critical when implementing or modifying a mapper. These tests must spin up a Spanner emulator and verify the mapper's logic against a real database. @@ -239,6 +242,7 @@ This practice decouples the core application logic from the exact structure of t - **TypeScript Unit Tests**: - **TypeScript**: Use `npm run test -w frontend`. - **ES Module Testing**: When testing components that use ES module exports directly (e.g., `signInWithPopup` from `firebase/auth`), direct stubbing with Sinon (e.g., `sinon.stub(firebaseAuth, 'signInWithPopup')`) is problematic due to module immutability. Instead, introduce a helper property (e.g., `credentialGetter`) in the component that defaults to the original ES module function but can be overridden with a Sinon stub in tests. This allows for effective mocking of ES module interactions. + - **DO** prefer using generic arguments for `querySelector` in tests (e.g., `querySelector('slot[name="content"]')`) to improve type safety and avoid unnecessary casting. ### 5.3. CI/CD (`.github/`) From e0cbf1078af050ee4a8455aa7f4c6bb2ad8c8b55 Mon Sep 17 00:00:00 2001 From: James Scott Date: Sat, 3 Jan 2026 15:12:24 +0000 Subject: [PATCH 25/27] test --- frontend/nginx.conf | 4 + frontend/src/static/js/api/client.ts | 140 ++++++ ...status-manage-subscriptions-dialog.test.ts | 368 ++++++++++++++++ .../test/webstatus-subscribe-button.test.ts | 43 ++ .../test/webstatus-subscriptions-page.test.ts | 144 ++++++ .../webstatus-manage-subscriptions-dialog.ts | 409 ++++++++++++++++++ .../components/webstatus-overview-content.ts | 54 +++ .../components/webstatus-subscribe-button.ts | 53 +++ .../webstatus-subscriptions-page.ts | 183 ++++++++ .../static/js/contexts/api-client-context.ts | 2 +- frontend/src/static/js/utils/app-router.ts | 5 + 11 files changed, 1404 insertions(+), 1 deletion(-) create mode 100644 frontend/src/static/js/components/test/webstatus-manage-subscriptions-dialog.test.ts create mode 100644 frontend/src/static/js/components/test/webstatus-subscribe-button.test.ts create mode 100644 frontend/src/static/js/components/test/webstatus-subscriptions-page.test.ts create mode 100644 frontend/src/static/js/components/webstatus-manage-subscriptions-dialog.ts create mode 100644 frontend/src/static/js/components/webstatus-subscribe-button.ts create mode 100644 frontend/src/static/js/components/webstatus-subscriptions-page.ts 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', From f3d2efc57d8a330b4a42a3bc6f8fae805bb5d505 Mon Sep 17 00:00:00 2001 From: James Scott Date: Sun, 4 Jan 2026 17:06:28 +0000 Subject: [PATCH 26/27] feat(backend): implement search configuration publisher adapter Introduces the `BackendAdapter` in `gcppubsubadapters` to handle publishing `SearchConfigurationChangedEvent` messages from the API layer. This adapter encapsulates the logic for mapping the API's `SavedSearchResponse` model to the internal event struct. This simplifies the handler code by allowing it to pass the response object directly along with context like `userID` and `isCreation`. This infrastructure is required to trigger the "Cold Start" (initial state generation) or "Query Update" workflows in the Event Producer whenever a user creates or modifies a saved search. Changes: - **Adapter**: Added `BackendAdapter` with `PublishSearchConfigurationChanged`. - **Wiring**: Integrated the publisher into the Backend Server and its handlers (`CreateSavedSearch`, `UpdateSavedSearch`). --- backend/cmd/server/main.go | 21 +++ backend/manifests/pod.yaml | 6 + backend/pkg/httpserver/create_saved_search.go | 6 + .../httpserver/create_saved_search_test.go | 59 +++++++- .../httpserver/create_subscription_test.go | 1 + .../delete_notification_channel_test.go | 1 + .../httpserver/delete_subscription_test.go | 1 + .../httpserver/get_feature_metadata_test.go | 4 +- backend/pkg/httpserver/get_feature_test.go | 1 + backend/pkg/httpserver/get_features_test.go | 1 + .../get_notification_channel_test.go | 1 + .../pkg/httpserver/get_saved_search_test.go | 1 + .../pkg/httpserver/get_subscription_test.go | 1 + ..._aggregated_baseline_status_counts_test.go | 1 + .../list_aggregated_feature_support_test.go | 1 + .../list_aggregated_wpt_metrics_test.go | 1 + .../httpserver/list_chromium_usage_test.go | 1 + .../list_feature_wpt_metrics_test.go | 1 + ..._missing_one_implementation_counts_test.go | 1 + ...issing_one_implementation_features_test.go | 1 + .../list_notification_channels_test.go | 1 + .../pkg/httpserver/list_subscriptions_test.go | 1 + .../list_user_saved_searches_test.go | 3 +- backend/pkg/httpserver/ping_user_test.go | 1 + .../put_user_saved_search_bookmark_test.go | 1 + .../httpserver/remove_saved_search_test.go | 2 +- .../remove_user_saved_search_bookmark_test.go | 2 +- backend/pkg/httpserver/server.go | 8 ++ backend/pkg/httpserver/server_test.go | 32 +++++ backend/pkg/httpserver/update_saved_search.go | 6 + .../httpserver/update_saved_search_test.go | 74 +++++++++- .../httpserver/update_subscription_test.go | 1 + backend/skaffold.yaml | 1 + lib/gcppubsub/gcppubsubadapters/backend.go | 67 +++++++++ .../gcppubsubadapters/backend_test.go | 136 ++++++++++++++++++ 35 files changed, 439 insertions(+), 8 deletions(-) create mode 100644 lib/gcppubsub/gcppubsubadapters/backend.go create mode 100644 lib/gcppubsub/gcppubsubadapters/backend_test.go 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/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/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) + } + }) + } +} From 958f85c5bba341109d5ec0ee8ff570bda83286fc Mon Sep 17 00:00:00 2001 From: James Scott Date: Mon, 5 Jan 2026 01:20:13 +0000 Subject: [PATCH 27/27] wip --- .dev/mailpit/Dockerfile | 18 ++ .dev/mailpit/manifests/pod.yaml | 41 +++ .dev/mailpit/manifests/service.yaml | 30 +++ .dev/mailpit/skaffold.yaml | 31 +++ DEVELOPMENT.md | 2 +- Makefile | 8 + backend/go.mod | 1 + backend/go.sum | 4 + e2e/tests/notifications.spec.ts | 236 ++++++++++++++++++ e2e/tests/test-data-util.ts | 63 +++++ lib/email/smtpsender/client.go | 71 ++++++ lib/email/smtpsender/client_test.go | 106 ++++++++ .../smtpsenderadapters/email_worker.go | 64 +++++ .../smtpsenderadapters/email_worker_test.go | 75 ++++++ lib/gcpspanner/feature_group_lookups.go | 27 ++ lib/gcpspanner/saved_search_subscription.go | 24 ++ lib/gcpspanner/web_features.go | 16 ++ skaffold.yaml | 1 + util/cmd/load_fake_data/main.go | 109 ++++++++ workers/email/cmd/job/main.go | 59 ++++- workers/email/manifests/pod.yaml | 6 + workers/email/skaffold.yaml | 1 + workers/skaffold.yaml | 22 ++ 23 files changed, 1013 insertions(+), 2 deletions(-) create mode 100644 .dev/mailpit/Dockerfile create mode 100644 .dev/mailpit/manifests/pod.yaml create mode 100644 .dev/mailpit/manifests/service.yaml create mode 100644 .dev/mailpit/skaffold.yaml create mode 100644 e2e/tests/notifications.spec.ts create mode 100644 e2e/tests/test-data-util.ts create mode 100644 lib/email/smtpsender/client.go create mode 100644 lib/email/smtpsender/client_test.go create mode 100644 lib/email/smtpsender/smtpsenderadapters/email_worker.go create mode 100644 lib/email/smtpsender/smtpsenderadapters/email_worker_test.go create mode 100644 workers/skaffold.yaml 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/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/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/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/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