From 5aa2ff844b8a2b6ac939ae8eddaf6d8bba460c5b Mon Sep 17 00:00:00 2001 From: Neil Vohra Date: Mon, 6 Apr 2026 16:30:58 +0000 Subject: [PATCH 1/2] feat: Implement RSS feed API This is a first go at implementing this API and is *not* the final form of it. A few things that still need to be done: - With this change, JSON is just being inserted into the XML. This will need to be more properly formatted. - Caching of the responses from the RSS API. - Hardcoding the # of events/diffs returned to the API to 20 for now. Open to any number on this. --- .../pkg/httpserver/get_saved_search_rss.go | 116 ++++++++++- .../httpserver/get_saved_search_rss_test.go | 186 ++++++++++++++++++ backend/pkg/httpserver/server.go | 15 +- backend/pkg/httpserver/server_test.go | 106 +++++++++- lib/backendtypes/notification_event.go | 31 +++ .../saved_search_notification_events.go | 33 ++++ .../saved_search_notification_events_test.go | 77 ++++++++ lib/gcpspanner/saved_search_subscription.go | 9 + lib/gcpspanner/spanneradapters/backend.go | 66 +++++++ .../spanneradapters/backend_test.go | 117 ++++++++--- 10 files changed, 715 insertions(+), 41 deletions(-) create mode 100644 backend/pkg/httpserver/get_saved_search_rss_test.go create mode 100644 lib/backendtypes/notification_event.go diff --git a/backend/pkg/httpserver/get_saved_search_rss.go b/backend/pkg/httpserver/get_saved_search_rss.go index 945a56440..094368716 100644 --- a/backend/pkg/httpserver/get_saved_search_rss.go +++ b/backend/pkg/httpserver/get_saved_search_rss.go @@ -15,19 +15,123 @@ package httpserver import ( + "bytes" "context" + "encoding/xml" + "errors" + "fmt" + "log/slog" + "net/http" + "net/url" + "time" + "github.com/GoogleChrome/webstatus.dev/lib/backendtypes" "github.com/GoogleChrome/webstatus.dev/lib/gen/openapi/backend" ) -// GetSubscriptionRSS returns a "not supported" error for now. +// RSS struct for marshaling. +type RSS struct { + XMLName xml.Name `xml:"rss"` + Version string `xml:"version,attr"` + Channel Channel `xml:"channel"` +} + +type Channel struct { + Title string `xml:"title"` + Link string `xml:"link"` + Description string `xml:"description"` + Items []Item `xml:"item"` +} + +type Item struct { + Description string `xml:"description"` + GUID string `xml:"guid"` + PubDate string `xml:"pubDate"` +} + +// GetSubscriptionRSS handles the request to get an RSS feed for a subscription. // nolint: ireturn // Signature generated from OpenAPI. func (s *Server) GetSubscriptionRSS( - _ context.Context, - _ backend.GetSubscriptionRSSRequestObject, + ctx context.Context, + request backend.GetSubscriptionRSSRequestObject, ) (backend.GetSubscriptionRSSResponseObject, error) { - return backend.GetSubscriptionRSS500JSONResponse{ - Code: 500, - Message: "Not supported", + sub, err := s.wptMetricsStorer.GetSavedSearchSubscriptionPublic(ctx, request.SubscriptionId) + if err != nil { + if errors.Is(err, backendtypes.ErrEntityDoesNotExist) { + return backend.GetSubscriptionRSS404JSONResponse{ + Code: http.StatusNotFound, + Message: "Subscription not found", + }, nil + } + + return backend.GetSubscriptionRSS500JSONResponse{ + Code: http.StatusInternalServerError, + Message: "Internal server error", + }, nil + } + + search, err := s.wptMetricsStorer.GetSavedSearchPublic(ctx, sub.Subscribable.Id) + if err != nil { + if errors.Is(err, backendtypes.ErrEntityDoesNotExist) { + return backend.GetSubscriptionRSS404JSONResponse{ + Code: http.StatusNotFound, + Message: "Saved search not found", + }, nil + } + slog.ErrorContext(ctx, "failed to get saved search", "error", err) + + return backend.GetSubscriptionRSS500JSONResponse{ + Code: http.StatusInternalServerError, + Message: "Internal server error", + }, nil + } + + snapshotType := string(sub.Frequency) + events, err := s.wptMetricsStorer.ListSavedSearchNotificationEvents(ctx, search.Id, snapshotType, 20) + if err != nil { + slog.ErrorContext(ctx, "failed to list notification events", "error", err) + + return backend.GetSubscriptionRSS500JSONResponse{ + Code: http.StatusInternalServerError, + Message: "Internal server error", + }, nil + } + + channelLink := s.baseURL.String() + "/features?q=" + url.QueryEscape(search.Query) + + rss := RSS{ + XMLName: xml.Name{Local: "rss", Space: ""}, + Version: "2.0", + Channel: Channel{ + Title: fmt.Sprintf("WebStatus.dev - %s", search.Name), + Link: channelLink, + Description: fmt.Sprintf("RSS feed for saved search: %s", search.Name), + Items: make([]Item, 0, len(events)), + }, + } + + for _, e := range events { + rss.Channel.Items = append(rss.Channel.Items, Item{ + Description: string(e.Summary), + GUID: e.ID, + PubDate: e.Timestamp.Format(time.RFC1123Z), + }) + } + + xmlBytes, err := xml.MarshalIndent(rss, "", " ") + if err != nil { + slog.ErrorContext(ctx, "failed to marshal RSS XML", "error", err) + + return backend.GetSubscriptionRSS500JSONResponse{ + Code: http.StatusInternalServerError, + Message: "Internal server error", + }, nil + } + + fullXML := []byte(xml.Header + string(xmlBytes)) + + return backend.GetSubscriptionRSS200ApplicationrssXmlResponse{ + Body: bytes.NewReader(fullXML), + ContentLength: int64(len(fullXML)), }, nil } diff --git a/backend/pkg/httpserver/get_saved_search_rss_test.go b/backend/pkg/httpserver/get_saved_search_rss_test.go new file mode 100644 index 000000000..24cbedb6b --- /dev/null +++ b/backend/pkg/httpserver/get_saved_search_rss_test.go @@ -0,0 +1,186 @@ +// 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 httpserver + +import ( + "context" + "errors" + "io" + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" + + "github.com/GoogleChrome/webstatus.dev/lib/backendtypes" + "github.com/GoogleChrome/webstatus.dev/lib/gen/openapi/backend" +) + +func TestGetSubscriptionRSS(t *testing.T) { + testCases := []struct { + name string + subCfg *MockGetSavedSearchSubscriptionPublicConfig + searchCfg *MockGetSavedSearchPublicConfig + eventsCfg *MockListSavedSearchNotificationEventsConfig + expectedStatusCode int + expectedContentType string + expectedBodyContains []string + }{ + { + name: "success", + subCfg: &MockGetSavedSearchSubscriptionPublicConfig{ + expectedSubscriptionID: "sub-id", + output: &backend.SubscriptionResponse{ + Id: "sub-id", + Subscribable: backend.SavedSearchInfo{ + Id: "search-id", + Name: "", + }, + ChannelId: "", + CreatedAt: time.Time{}, + Frequency: backend.SubscriptionFrequencyImmediate, + Triggers: nil, + UpdatedAt: time.Time{}, + }, + err: nil, + }, + searchCfg: &MockGetSavedSearchPublicConfig{ + expectedSavedSearchID: "search-id", + output: &backend.SavedSearchResponse{ + Id: "search-id", + Name: "test search", + Query: "query", + BookmarkStatus: nil, + CreatedAt: time.Time{}, + Description: nil, + Permissions: nil, + UpdatedAt: time.Time{}, + }, + err: nil, + }, + eventsCfg: &MockListSavedSearchNotificationEventsConfig{ + expectedSavedSearchID: "search-id", + expectedSnapshotType: string(backend.SubscriptionFrequencyImmediate), + expectedLimit: 20, + output: []backendtypes.SavedSearchNotificationEvent{ + { + ID: "event-1", + SavedSearchID: "search-id", + SnapshotType: string(backend.SubscriptionFrequencyImmediate), + Timestamp: time.Date(2026, 1, 1, 12, 0, 0, 0, time.UTC), + EventType: "IMMEDIATE_DIFF", + Summary: []byte(`"summary"`), + Reasons: nil, + BlobPath: "", + DiffBlobPath: "", + }, + }, + err: nil, + }, + expectedStatusCode: 200, + expectedContentType: "application/rss+xml", + expectedBodyContains: []string{ + "WebStatus.dev - test search", + "RSS feed for saved search: test search", + "event-1", + "Thu, 01 Jan 2026 12:00:00 +0000", + "http://localhost:8080/features?q=query", + }, + }, + { + name: "subscription not found", + subCfg: &MockGetSavedSearchSubscriptionPublicConfig{ + expectedSubscriptionID: "missing-sub", + output: nil, + err: backendtypes.ErrEntityDoesNotExist, + }, + searchCfg: nil, + eventsCfg: nil, + expectedStatusCode: 404, + expectedContentType: "", + expectedBodyContains: nil, + }, + { + name: "500 error", + subCfg: &MockGetSavedSearchSubscriptionPublicConfig{ + expectedSubscriptionID: "sub-id", + output: nil, + err: errors.New("db error"), + }, + searchCfg: nil, + eventsCfg: nil, + expectedStatusCode: 500, + expectedContentType: "", + expectedBodyContains: nil, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + var mockStorer MockWPTMetricsStorer + mockStorer.getSavedSearchSubscriptionPublicCfg = tc.subCfg + mockStorer.getSavedSearchPublicCfg = tc.searchCfg + mockStorer.listSavedSearchNotificationEventsCfg = tc.eventsCfg + mockStorer.t = t + + myServer := Server{ + wptMetricsStorer: &mockStorer, + metadataStorer: nil, + userGitHubClientFactory: nil, + eventPublisher: nil, + operationResponseCaches: nil, + baseURL: getTestBaseURL(t), + } + + req := httptest.NewRequestWithContext( + context.Background(), + http.MethodGet, + "/v1/subscriptions/"+tc.subCfg.expectedSubscriptionID+"/rss", + nil, + ) + + // Fix createOpenAPIServerServer call + srv := createOpenAPIServerServer("", &myServer, nil, noopMiddleware) + + w := httptest.NewRecorder() + + // Fix router.ServeHTTP to srv.Handler.ServeHTTP + srv.Handler.ServeHTTP(w, req) + + resp := w.Result() + defer resp.Body.Close() + + if resp.StatusCode != tc.expectedStatusCode { + t.Errorf("expected status code %d, got %d", tc.expectedStatusCode, resp.StatusCode) + } + + if tc.expectedStatusCode == 200 { + contentType := resp.Header.Get("Content-Type") + if contentType != tc.expectedContentType { + t.Errorf("expected content type %s, got %s", tc.expectedContentType, contentType) + } + + bodyBytes, _ := io.ReadAll(resp.Body) + bodyStr := string(bodyBytes) + + for _, searchStr := range tc.expectedBodyContains { + if !strings.Contains(bodyStr, searchStr) { + t.Errorf("expected body to contain %q, but it did not.\nBody:\n%s", searchStr, bodyStr) + } + } + } + }) + } +} diff --git a/backend/pkg/httpserver/server.go b/backend/pkg/httpserver/server.go index fa72ed730..079bd642e 100644 --- a/backend/pkg/httpserver/server.go +++ b/backend/pkg/httpserver/server.go @@ -129,6 +129,7 @@ type WPTMetricsStorer interface { pageSize int, pageToken *string, ) (*backend.UserSavedSearchPage, error) + GetSavedSearchPublic(ctx context.Context, savedSearchID string) (*backend.SavedSearchResponse, error) UpdateUserSavedSearch( ctx context.Context, savedSearchID string, @@ -162,10 +163,20 @@ type WPTMetricsStorer interface { DeleteSavedSearchSubscription(ctx context.Context, userID, subscriptionID string) error GetSavedSearchSubscription(ctx context.Context, userID, subscriptionID string) (*backend.SubscriptionResponse, error) + GetSavedSearchSubscriptionPublic(ctx context.Context, subscriptionID string) (*backend.SubscriptionResponse, error) ListSavedSearchSubscriptions(ctx context.Context, userID string, pageSize int, pageToken *string) (*backend.SubscriptionPage, error) - UpdateSavedSearchSubscription(ctx context.Context, userID, subscriptionID string, - req backend.UpdateSubscriptionRequest) (*backend.SubscriptionResponse, error) + ListSavedSearchNotificationEvents( + ctx context.Context, + savedSearchID string, + snapshotType string, + limit int, + ) ([]backendtypes.SavedSearchNotificationEvent, error) + UpdateSavedSearchSubscription( + ctx context.Context, + userID, subscriptionID string, + req backend.UpdateSubscriptionRequest, + ) (*backend.SubscriptionResponse, error) } type Server struct { diff --git a/backend/pkg/httpserver/server_test.go b/backend/pkg/httpserver/server_test.go index fd87bef2a..6ddd74c00 100644 --- a/backend/pkg/httpserver/server_test.go +++ b/backend/pkg/httpserver/server_test.go @@ -190,6 +190,26 @@ type MockGetSavedSearchConfig struct { err error } +type MockGetSavedSearchPublicConfig struct { + expectedSavedSearchID string + output *backend.SavedSearchResponse + err error +} + +type MockGetSavedSearchSubscriptionPublicConfig struct { + expectedSubscriptionID string + output *backend.SubscriptionResponse + err error +} + +type MockListSavedSearchNotificationEventsConfig struct { + expectedSavedSearchID string + expectedSnapshotType string + expectedLimit int + output []backendtypes.SavedSearchNotificationEvent + err error +} + type MockListUserSavedSeachesConfig struct { expectedUserID string expectedPageSize int @@ -316,6 +336,9 @@ type MockWPTMetricsStorer struct { createUserSavedSearchCfg *MockCreateUserSavedSearchConfig deleteUserSavedSearchCfg *MockDeleteUserSavedSearchConfig getSavedSearchCfg *MockGetSavedSearchConfig + getSavedSearchPublicCfg *MockGetSavedSearchPublicConfig + getSavedSearchSubscriptionPublicCfg *MockGetSavedSearchSubscriptionPublicConfig + listSavedSearchNotificationEventsCfg *MockListSavedSearchNotificationEventsConfig listUserSavedSearchesCfg *MockListUserSavedSeachesConfig updateUserSavedSearchCfg *MockUpdateUserSavedSearchConfig putUserSavedSearchBookmarkCfg *MockPutUserSavedSearchBookmarkConfig @@ -359,6 +382,9 @@ type MockWPTMetricsStorer struct { callCountGetSavedSearchSubscription int callCountListSavedSearchSubscriptions int callCountUpdateSavedSearchSubscription int + callCountGetSavedSearchPublic int + callCountGetSavedSearchSubscriptionPublic int + callCountListSavedSearchNotificationEvents int } func (m *MockWPTMetricsStorer) GetIDFromFeatureKey( @@ -713,6 +739,75 @@ func (m *MockWPTMetricsStorer) GetSavedSearch( return m.getSavedSearchCfg.output, m.getSavedSearchCfg.err } +func (m *MockWPTMetricsStorer) GetSavedSearchPublic( + _ context.Context, + savedSearchID string, +) (*backend.SavedSearchResponse, error) { + m.callCountGetSavedSearchPublic++ + if m.getSavedSearchPublicCfg == nil { + m.t.Fatal("getSavedSearchPublicCfg is nil") + } + if m.getSavedSearchPublicCfg.expectedSavedSearchID != savedSearchID { + m.t.Fatalf( + "unexpected savedSearchID. want %s, got %s", + m.getSavedSearchPublicCfg.expectedSavedSearchID, + savedSearchID, + ) + } + + return m.getSavedSearchPublicCfg.output, m.getSavedSearchPublicCfg.err +} + +func (m *MockWPTMetricsStorer) GetSavedSearchSubscriptionPublic( + _ context.Context, + subscriptionID string, +) (*backend.SubscriptionResponse, error) { + m.callCountGetSavedSearchSubscriptionPublic++ + if m.getSavedSearchSubscriptionPublicCfg == nil { + m.t.Fatal("getSavedSearchSubscriptionPublicCfg is nil") + } + if m.getSavedSearchSubscriptionPublicCfg.expectedSubscriptionID != subscriptionID { + m.t.Fatalf( + "unexpected subscriptionID. want %s, got %s", + m.getSavedSearchSubscriptionPublicCfg.expectedSubscriptionID, + subscriptionID, + ) + } + + return m.getSavedSearchSubscriptionPublicCfg.output, m.getSavedSearchSubscriptionPublicCfg.err +} + +func (m *MockWPTMetricsStorer) ListSavedSearchNotificationEvents( + _ context.Context, + savedSearchID string, + snapshotType string, + limit int, +) ([]backendtypes.SavedSearchNotificationEvent, error) { + m.callCountListSavedSearchNotificationEvents++ + if m.listSavedSearchNotificationEventsCfg == nil { + m.t.Fatal("listSavedSearchNotificationEventsCfg is nil") + } + if m.listSavedSearchNotificationEventsCfg.expectedSavedSearchID != savedSearchID { + m.t.Fatalf( + "unexpected savedSearchID. want %s, got %s", + m.listSavedSearchNotificationEventsCfg.expectedSavedSearchID, + savedSearchID, + ) + } + if m.listSavedSearchNotificationEventsCfg.expectedSnapshotType != snapshotType { + m.t.Fatalf( + "unexpected snapshotType. want %s, got %s", + m.listSavedSearchNotificationEventsCfg.expectedSnapshotType, + snapshotType, + ) + } + if m.listSavedSearchNotificationEventsCfg.expectedLimit != limit { + m.t.Fatalf("unexpected limit. want %d, got %d", m.listSavedSearchNotificationEventsCfg.expectedLimit, limit) + } + + return m.listSavedSearchNotificationEventsCfg.output, m.listSavedSearchNotificationEventsCfg.err +} + func (m *MockWPTMetricsStorer) DeleteUserSavedSearch( _ context.Context, userID string, @@ -1474,13 +1569,18 @@ func (m *mockServerInterface) DeleteSubscription(ctx context.Context, // GetSubscriptionRSS implements backend.StrictServerInterface. // nolint: ireturn // WONTFIX - generated method signature -func (m *mockServerInterface) GetSubscriptionRSS(ctx context.Context, +func (m *mockServerInterface) GetSubscriptionRSS(_ context.Context, _ backend.GetSubscriptionRSSRequestObject) ( backend.GetSubscriptionRSSResponseObject, error) { - assertUserInCtx(ctx, m.t, m.expectedUserInCtx) m.callCount++ - panic("unimplemented") + + return backend.GetSubscriptionRSS200ApplicationrssXmlResponse{ + Body: strings.NewReader(""), + ContentLength: 0, + }, nil + } + func (m *mockServerInterface) assertCallCount(expectedCallCount int) { if m.callCount != expectedCallCount { m.t.Errorf("expected mock server to be used %d times. only used %d times", expectedCallCount, m.callCount) diff --git a/lib/backendtypes/notification_event.go b/lib/backendtypes/notification_event.go new file mode 100644 index 000000000..b16fffe5a --- /dev/null +++ b/lib/backendtypes/notification_event.go @@ -0,0 +1,31 @@ +// 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 backendtypes + +import "time" + +// SavedSearchNotificationEvent represents a change event for a saved search. +// This is the domain model used by the API layer. +type SavedSearchNotificationEvent struct { + ID string + SavedSearchID string + SnapshotType string + Timestamp time.Time + EventType string + Reasons []string + BlobPath string + DiffBlobPath string + Summary []byte // JSON bytes +} diff --git a/lib/gcpspanner/saved_search_notification_events.go b/lib/gcpspanner/saved_search_notification_events.go index c2543621e..ad6db83ee 100644 --- a/lib/gcpspanner/saved_search_notification_events.go +++ b/lib/gcpspanner/saved_search_notification_events.go @@ -172,3 +172,36 @@ func (c *Client) GetLatestSavedSearchNotificationEvent( return r.readRowByKey(ctx, key) } + +func (c *Client) ListSavedSearchNotificationEvents(ctx context.Context, + savedSearchID string, snapshotType string, limit int) ([]SavedSearchNotificationEvent, error) { + stmt := spanner.Statement{ + SQL: `SELECT * FROM SavedSearchNotificationEvents + WHERE SavedSearchId = @SavedSearchId AND SnapshotType = @SnapshotType + ORDER BY Timestamp DESC + LIMIT @Limit`, + Params: map[string]any{ + "SavedSearchId": savedSearchID, + "SnapshotType": SavedSearchSnapshotType(snapshotType), + "Limit": limit, + }, + } + iter := c.Single().Query(ctx, stmt) + defer iter.Stop() + + var events []SavedSearchNotificationEvent + err := iter.Do(func(row *spanner.Row) error { + var e SavedSearchNotificationEvent + if err := row.ToStruct(&e); err != nil { + return err + } + events = append(events, e) + + return nil + }) + if err != nil { + return nil, err + } + + return events, nil +} diff --git a/lib/gcpspanner/saved_search_notification_events_test.go b/lib/gcpspanner/saved_search_notification_events_test.go index 8ea64afa4..a32145453 100644 --- a/lib/gcpspanner/saved_search_notification_events_test.go +++ b/lib/gcpspanner/saved_search_notification_events_test.go @@ -17,6 +17,7 @@ package gcpspanner import ( "context" "errors" + "fmt" "testing" "time" @@ -401,3 +402,79 @@ func TestGetLatestSavedSearchNotificationEvent(t *testing.T) { t.Errorf("Latest event Timestamp mismatch: got %v, want %v", latestEvent.Timestamp, eventTimes[2]) } } + +func TestListSavedSearchNotificationEvents(t *testing.T) { + ctx := t.Context() + restartDatabaseContainer(t) + + savedSearchID := createSavedSearchForNotificationTests(ctx, t) + snapshotType := "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), + } + + setupLockAndInitialState(ctx, t, savedSearchID, snapshotType, "worker-1", "path/initial", 10*time.Minute, + time.Date(2025, 1, 1, 9, 0, 0, 0, time.UTC)) + + for i, eventTime := range eventTimes { + eventID := fmt.Sprintf("event-%d", i) + _, err := spannerClient.PublishSavedSearchNotificationEvent(ctx, SavedSearchNotificationCreateRequest{ + SavedSearchID: savedSearchID, + SnapshotType: SavedSearchSnapshotType(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) + } + } + + testCases := []struct { + name string + limit int + expectedCount int + expectedIDs []string + }{ + { + name: "list all", + limit: 10, + expectedCount: 3, + expectedIDs: []string{"event-2", "event-1", "event-0"}, + }, + { + name: "list with limit", + limit: 2, + expectedCount: 2, + expectedIDs: []string{"event-2", "event-1"}, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + events, err := spannerClient.ListSavedSearchNotificationEvents(ctx, savedSearchID, snapshotType, tc.limit) + if err != nil { + t.Fatalf("ListSavedSearchNotificationEvents() failed: %v", err) + } + + if len(events) != tc.expectedCount { + t.Errorf("expected %d events, got %d", tc.expectedCount, len(events)) + } + + for i, expectedID := range tc.expectedIDs { + if events[i].ID != expectedID { + t.Errorf("at index %d: expected ID %s, got %s", i, expectedID, events[i].ID) + } + } + }) + } +} diff --git a/lib/gcpspanner/saved_search_subscription.go b/lib/gcpspanner/saved_search_subscription.go index 7f5dc18c3..00dd517bd 100644 --- a/lib/gcpspanner/saved_search_subscription.go +++ b/lib/gcpspanner/saved_search_subscription.go @@ -373,6 +373,15 @@ func (c *Client) GetSavedSearchSubscription( return ret, err } +// GetSavedSearchSubscriptionPublic retrieves a subscription without checking ownership. +func (c *Client) GetSavedSearchSubscriptionPublic( + ctx context.Context, subscriptionID string) (*SavedSearchSubscriptionView, error) { + r := newEntityReader[savedSearchSubscriptionViewMapper, + SavedSearchSubscriptionView, string](c) + + return r.readRowByKey(ctx, subscriptionID) +} + // UpdateSavedSearchSubscription updates a subscription if it belongs to the specified user. func (c *Client) UpdateSavedSearchSubscription( ctx context.Context, req UpdateSavedSearchSubscriptionRequest) error { diff --git a/lib/gcpspanner/spanneradapters/backend.go b/lib/gcpspanner/spanneradapters/backend.go index 23274cc55..9e017abfc 100644 --- a/lib/gcpspanner/spanneradapters/backend.go +++ b/lib/gcpspanner/spanneradapters/backend.go @@ -131,6 +131,7 @@ type BackendSpannerClient interface { ctx context.Context, savedSearchID string, authenticatedUserID *string) (*gcpspanner.UserSavedSearch, error) + GetSavedSearch(ctx context.Context, id string) (*gcpspanner.SavedSearch, error) DeleteUserSavedSearch(ctx context.Context, req gcpspanner.DeleteUserSavedSearchRequest) error ListUserSavedSearches( ctx context.Context, @@ -145,6 +146,8 @@ type BackendSpannerClient interface { ctx context.Context, req gcpspanner.CreateSavedSearchSubscriptionRequest) (*string, error) GetSavedSearchSubscription(ctx context.Context, subscriptionID string, userID string) ( *gcpspanner.SavedSearchSubscriptionView, error) + GetSavedSearchSubscriptionPublic(ctx context.Context, subscriptionID string) ( + *gcpspanner.SavedSearchSubscriptionView, error) UpdateSavedSearchSubscription(ctx context.Context, req gcpspanner.UpdateSavedSearchSubscriptionRequest) error DeleteSavedSearchSubscription(ctx context.Context, subscriptionID string, userID string) error ListSavedSearchSubscriptions(ctx context.Context, req gcpspanner.ListSavedSearchSubscriptionsRequest) ( @@ -159,6 +162,8 @@ type BackendSpannerClient interface { ) (*string, error) UpdateNotificationChannel(ctx context.Context, req gcpspanner.UpdateNotificationChannelRequest) error DeleteNotificationChannel(ctx context.Context, channelID string, userID string) error + ListSavedSearchNotificationEvents(ctx context.Context, + savedSearchID string, snapshotType string, limit int) ([]gcpspanner.SavedSearchNotificationEvent, error) } // Backend converts queries to spanner to usable entities for the backend @@ -172,6 +177,35 @@ func NewBackend(client BackendSpannerClient) *Backend { return &Backend{client: client} } +func (s *Backend) ListSavedSearchNotificationEvents(ctx context.Context, + savedSearchID string, snapshotType string, limit int) ([]backendtypes.SavedSearchNotificationEvent, error) { + notifEvents, err := s.client.ListSavedSearchNotificationEvents(ctx, savedSearchID, snapshotType, limit) + if err != nil { + return nil, err + } + + events := make([]backendtypes.SavedSearchNotificationEvent, 0, len(notifEvents)) + for _, e := range notifEvents { + var summaryBytes []byte + if e.Summary.Valid { + summaryBytes, _ = json.Marshal(e.Summary.Value) + } + events = append(events, backendtypes.SavedSearchNotificationEvent{ + ID: e.ID, + SavedSearchID: e.SavedSearchID, + SnapshotType: string(e.SnapshotType), + Timestamp: e.Timestamp, + EventType: e.EventType, + Reasons: e.Reasons, + BlobPath: e.BlobPath, + DiffBlobPath: e.DiffBlobPath, + Summary: summaryBytes, + }) + } + + return events, nil +} + func (s *Backend) SyncUserProfileInfo(ctx context.Context, userProfile backendtypes.UserProfile) error { // In the future, we can add more complex adapter logic here. // For now, we just translate between the two types. @@ -846,6 +880,24 @@ func (s *Backend) GetSavedSearch(ctx context.Context, savedSearchID string, user return convertUserSavedSearchToSavedSearchResponse(savedSearch), nil } +func (s *Backend) GetSavedSearchPublic(ctx context.Context, savedSearchID string) ( + *backend.SavedSearchResponse, error) { + savedSearch, err := s.client.GetSavedSearch(ctx, savedSearchID) + if err != nil { + if errors.Is(err, gcpspanner.ErrQueryReturnedNoResults) { + return nil, errors.Join(err, backendtypes.ErrEntityDoesNotExist) + } + + return nil, err + } + + return convertUserSavedSearchToSavedSearchResponse(&gcpspanner.UserSavedSearch{ + SavedSearch: *savedSearch, + Role: nil, + IsBookmarked: nil, + }), nil +} + func buildUpdateSavedSearchRequestForGCP(savedSearchID string, userID string, updateRequest *backend.SavedSearchUpdateRequest) gcpspanner.UpdateSavedSearchRequest { @@ -1670,6 +1722,20 @@ func (s *Backend) GetSavedSearchSubscription(ctx context.Context, return toBackendSubscription(sub), nil } +func (s *Backend) GetSavedSearchSubscriptionPublic(ctx context.Context, + subscriptionID string) (*backend.SubscriptionResponse, error) { + sub, err := s.client.GetSavedSearchSubscriptionPublic(ctx, subscriptionID) + if err != nil { + if errors.Is(err, gcpspanner.ErrQueryReturnedNoResults) { + return nil, errors.Join(err, backendtypes.ErrEntityDoesNotExist) + } + + return nil, err + } + + return toBackendSubscription(sub), nil +} + func (s *Backend) UpdateSavedSearchSubscription(ctx context.Context, userID, subscriptionID string, req backend.UpdateSubscriptionRequest) (*backend.SubscriptionResponse, error) { updateReq := gcpspanner.UpdateSavedSearchSubscriptionRequest{ diff --git a/lib/gcpspanner/spanneradapters/backend_test.go b/lib/gcpspanner/spanneradapters/backend_test.go index fea15277a..1e6b4323d 100644 --- a/lib/gcpspanner/spanneradapters/backend_test.go +++ b/lib/gcpspanner/spanneradapters/backend_test.go @@ -196,6 +196,26 @@ type mockDeleteSavedSearchSubscriptionConfig struct { returnedError error } +type mockGetSavedSearchConfig struct { + expectedID string + result *gcpspanner.SavedSearch + returnedError error +} + +type mockGetSavedSearchSubscriptionPublicConfig struct { + expectedSubscriptionID string + result *gcpspanner.SavedSearchSubscriptionView + returnedError error +} + +type mockListSavedSearchNotificationEventsConfig struct { + expectedSavedSearchID string + expectedSnapshotType string + expectedLimit int + result []gcpspanner.SavedSearchNotificationEvent + returnedError error +} + type mockListSavedSearchSubscriptionsConfig struct { expectedRequest gcpspanner.ListSavedSearchSubscriptionsRequest result []gcpspanner.SavedSearchSubscriptionView @@ -204,36 +224,39 @@ type mockListSavedSearchSubscriptionsConfig struct { } type mockBackendSpannerClient struct { - t *testing.T - aggregationData []gcpspanner.WPTRunAggregationMetricWithTime - featureData []gcpspanner.WPTRunFeatureMetricWithTime - chromeDailyUsageData []gcpspanner.ChromeDailyUsageStatWithDate - mockFeaturesSearchCfg mockFeaturesSearchConfig - mockGetFeatureCfg mockGetFeatureConfig - mockGetIDByFeaturesIDCfg mockGetIDByFeaturesIDConfig - mockListBrowserFeatureCountMetricCfg mockListBrowserFeatureCountMetricConfig - mockListMissingOneImplCountsCfg mockListMissingOneImplCountsConfig - mockListMissingOneImplFeaturesCfg mockListMissingOneImplFeaturesConfig - mockListBaselineStatusCountsCfg mockListBaselineStatusCountsConfig - mockGetNotificationChannelCfg *mockGetNotificationChannelConfig - mockDeleteNotificationChannelCfg *mockDeleteNotificationChannelConfig - mockListNotificationChannelsCfg *mockListNotificationChannelsConfig - mockCreateNotificationChannelCfg *mockCreateNotificationChannelConfig - mockUpdateNotificationChannelCfg *mockUpdateNotificationChannelConfig - mockCreateNewUserSavedSearchCfg *mockCreateNewUserSavedSearchConfig - mockGetUserSavedSearchCfg *mockGetUserSavedSearchConfig - mockDeleteUserSavedSearchCfg *mockDeleteUserSavedSearchConfig - mockListUserSavedSearchesCfg *mockListUserSavedSearchesConfig - mockUpdateUserSavedSearchCfg *mockUpdateUserSavedSearchConfig - mockAddUserSearchBookmarkCfg *mockAddUserSearchBookmarkConfig - mockDeleteUserSearchBookmarkCfg *mockDeleteUserSearchBookmarkConfig - mockCreateSavedSearchSubscriptionCfg *mockCreateSavedSearchSubscriptionConfig - mockGetSavedSearchSubscriptionCfg *mockGetSavedSearchSubscriptionConfig - mockUpdateSavedSearchSubscriptionCfg *mockUpdateSavedSearchSubscriptionConfig - mockDeleteSavedSearchSubscriptionCfg *mockDeleteSavedSearchSubscriptionConfig - mockListSavedSearchSubscriptionsCfg *mockListSavedSearchSubscriptionsConfig - pageToken *string - err error + t *testing.T + aggregationData []gcpspanner.WPTRunAggregationMetricWithTime + featureData []gcpspanner.WPTRunFeatureMetricWithTime + chromeDailyUsageData []gcpspanner.ChromeDailyUsageStatWithDate + mockFeaturesSearchCfg mockFeaturesSearchConfig + mockGetFeatureCfg mockGetFeatureConfig + mockGetIDByFeaturesIDCfg mockGetIDByFeaturesIDConfig + mockListBrowserFeatureCountMetricCfg mockListBrowserFeatureCountMetricConfig + mockListMissingOneImplCountsCfg mockListMissingOneImplCountsConfig + mockListMissingOneImplFeaturesCfg mockListMissingOneImplFeaturesConfig + mockListBaselineStatusCountsCfg mockListBaselineStatusCountsConfig + mockGetSavedSearchCfg mockGetSavedSearchConfig + mockGetNotificationChannelCfg *mockGetNotificationChannelConfig + mockDeleteNotificationChannelCfg *mockDeleteNotificationChannelConfig + mockListNotificationChannelsCfg *mockListNotificationChannelsConfig + mockCreateNotificationChannelCfg *mockCreateNotificationChannelConfig + mockUpdateNotificationChannelCfg *mockUpdateNotificationChannelConfig + mockCreateNewUserSavedSearchCfg *mockCreateNewUserSavedSearchConfig + mockGetUserSavedSearchCfg *mockGetUserSavedSearchConfig + mockDeleteUserSavedSearchCfg *mockDeleteUserSavedSearchConfig + mockListUserSavedSearchesCfg *mockListUserSavedSearchesConfig + mockUpdateUserSavedSearchCfg *mockUpdateUserSavedSearchConfig + mockAddUserSearchBookmarkCfg *mockAddUserSearchBookmarkConfig + mockDeleteUserSearchBookmarkCfg *mockDeleteUserSearchBookmarkConfig + mockCreateSavedSearchSubscriptionCfg *mockCreateSavedSearchSubscriptionConfig + mockGetSavedSearchSubscriptionCfg *mockGetSavedSearchSubscriptionConfig + mockGetSavedSearchSubscriptionPublicCfg *mockGetSavedSearchSubscriptionPublicConfig + mockListSavedSearchNotificationEventsCfg *mockListSavedSearchNotificationEventsConfig + mockUpdateSavedSearchSubscriptionCfg *mockUpdateSavedSearchSubscriptionConfig + mockDeleteSavedSearchSubscriptionCfg *mockDeleteSavedSearchSubscriptionConfig + mockListSavedSearchSubscriptionsCfg *mockListSavedSearchSubscriptionsConfig + pageToken *string + err error mockGetMovedWebFeatureDetailsByOriginalFeatureKeyCfg *mockGetMovedWebFeatureDetailsByOriginalFeatureKeyConfig mockGetSplitWebFeatureByOriginalFeatureKeyCfg *mockGetSplitWebFeatureByOriginalFeatureKeyConfig @@ -564,6 +587,14 @@ func (c mockBackendSpannerClient) ListBaselineStatusCounts( return c.mockListBaselineStatusCountsCfg.result, c.mockListBaselineStatusCountsCfg.returnedError } +func (c mockBackendSpannerClient) GetSavedSearch(_ context.Context, id string) (*gcpspanner.SavedSearch, error) { + if id != c.mockGetSavedSearchCfg.expectedID { + c.t.Errorf("unexpected ID. want %s, got %s", c.mockGetSavedSearchCfg.expectedID, id) + } + + return c.mockGetSavedSearchCfg.result, c.mockGetSavedSearchCfg.returnedError +} + func (c mockBackendSpannerClient) GetUserSavedSearch( _ context.Context, id string, authenticatedUserID *string) ( *gcpspanner.UserSavedSearch, error) { @@ -628,6 +659,32 @@ func (c mockBackendSpannerClient) GetSavedSearchSubscription( return c.mockGetSavedSearchSubscriptionCfg.result, c.mockGetSavedSearchSubscriptionCfg.returnedError } +// GetSavedSearchSubscriptionPublic implements BackendSpannerClient. +func (c mockBackendSpannerClient) GetSavedSearchSubscriptionPublic( + _ context.Context, + subscriptionID string) (*gcpspanner.SavedSearchSubscriptionView, error) { + if subscriptionID != c.mockGetSavedSearchSubscriptionPublicCfg.expectedSubscriptionID { + c.t.Error("unexpected input to mock") + } + + return c.mockGetSavedSearchSubscriptionPublicCfg.result, c.mockGetSavedSearchSubscriptionPublicCfg.returnedError +} + +// ListSavedSearchNotificationEvents implements BackendSpannerClient. +func (c mockBackendSpannerClient) ListSavedSearchNotificationEvents( + _ context.Context, + savedSearchID string, + snapshotType string, + limit int) ([]gcpspanner.SavedSearchNotificationEvent, error) { + if savedSearchID != c.mockListSavedSearchNotificationEventsCfg.expectedSavedSearchID || + snapshotType != c.mockListSavedSearchNotificationEventsCfg.expectedSnapshotType || + limit != c.mockListSavedSearchNotificationEventsCfg.expectedLimit { + c.t.Error("unexpected input to mock") + } + + return c.mockListSavedSearchNotificationEventsCfg.result, c.mockListSavedSearchNotificationEventsCfg.returnedError +} + // ListSavedSearchSubscriptions implements BackendSpannerClient. func (c mockBackendSpannerClient) ListSavedSearchSubscriptions( _ context.Context, From 74df9ec51e50f323a2a171dd88282527c1ab88e1 Mon Sep 17 00:00:00 2001 From: Neil Vohra Date: Wed, 15 Apr 2026 23:17:29 +0000 Subject: [PATCH 2/2] fix(rss): align feed pagination indexing and enrich XML atom metadata - Adjusts SQL cursor sequencing to sort `EventId ASC`. This seamlessly harmonizes with Spanner's default ASC primary key appending so the query plans can completely pipeline native index traversals correctly on identical log timestamps. - Refines `` serialization to export `isPermaLink="false"`, guarding UUID structures from triggering automatic link parses across rigorous external RSS aggregators. - Inserts standard self-reference atomic path mappings (`rel="self"`) to fortify document compliance. --- Makefile | 3 +- .../pkg/httpserver/get_saved_search_rss.go | 80 +++++++++++-- .../httpserver/get_saved_search_rss_test.go | 72 +++++++++++- backend/pkg/httpserver/server.go | 5 +- backend/pkg/httpserver/server_test.go | 36 +++++- .../saved_search_notification_events.go | 111 ++++++++++++++---- .../saved_search_notification_events_test.go | 15 ++- lib/gcpspanner/spanneradapters/backend.go | 30 +++-- .../spanneradapters/backend_test.go | 20 +++- 9 files changed, 312 insertions(+), 60 deletions(-) diff --git a/Makefile b/Makefile index 17db0c479..71a4f5051 100644 --- a/Makefile +++ b/Makefile @@ -263,14 +263,13 @@ shell-lint: # Format all tracked and untracked Go files (skips ignored/generated files). go-format: go-install-tools git ls-files --cached --others --exclude-standard '*.go' | xargs -r -I {} go tool golines -w --max-len=120 {} - + go list -f '{{.Dir}}/...' -m | xargs -t golangci-lint run --fix lint-fix: node-install go-format npm run lint-fix -w frontend terraform fmt -recursive . npx prettier . --write npx stylelint "frontend/src/**/*.css" --fix - go list -f '{{.Dir}}/...' -m | xargs -t golangci-lint run --fix style-lint: npx stylelint "frontend/src/**/*.css" diff --git a/backend/pkg/httpserver/get_saved_search_rss.go b/backend/pkg/httpserver/get_saved_search_rss.go index 094368716..492ffea37 100644 --- a/backend/pkg/httpserver/get_saved_search_rss.go +++ b/backend/pkg/httpserver/get_saved_search_rss.go @@ -23,6 +23,7 @@ import ( "log/slog" "net/http" "net/url" + "strconv" "time" "github.com/GoogleChrome/webstatus.dev/lib/backendtypes" @@ -33,19 +34,31 @@ import ( type RSS struct { XMLName xml.Name `xml:"rss"` Version string `xml:"version,attr"` + AtomNS string `xml:"xmlns:atom,attr"` Channel Channel `xml:"channel"` } +type AtomLink struct { + Rel string `xml:"rel,attr"` + Href string `xml:"href,attr"` +} + type Channel struct { - Title string `xml:"title"` - Link string `xml:"link"` - Description string `xml:"description"` - Items []Item `xml:"item"` + Title string `xml:"title"` + Link string `xml:"link"` + Description string `xml:"description"` + AtomLinks []AtomLink `xml:"atom:link"` + Items []Item `xml:"item"` +} + +type GUID struct { + Value string `xml:",chardata"` + IsPermaLink string `xml:"isPermaLink,attr"` } type Item struct { Description string `xml:"description"` - GUID string `xml:"guid"` + GUID GUID `xml:"guid"` PubDate string `xml:"pubDate"` } @@ -87,7 +100,14 @@ func (s *Server) GetSubscriptionRSS( } snapshotType := string(sub.Frequency) - events, err := s.wptMetricsStorer.ListSavedSearchNotificationEvents(ctx, search.Id, snapshotType, 20) + pageSize := getPageSizeOrDefault(request.Params.PageSize) + events, nextPageToken, err := s.wptMetricsStorer.ListSavedSearchNotificationEvents( + ctx, + search.Id, + snapshotType, + pageSize, + request.Params.PageToken, + ) if err != nil { slog.ErrorContext(ctx, "failed to list notification events", "error", err) @@ -102,19 +122,54 @@ func (s *Server) GetSubscriptionRSS( rss := RSS{ XMLName: xml.Name{Local: "rss", Space: ""}, Version: "2.0", + AtomNS: "http://www.w3.org/2005/Atom", Channel: Channel{ Title: fmt.Sprintf("WebStatus.dev - %s", search.Name), Link: channelLink, Description: fmt.Sprintf("RSS feed for saved search: %s", search.Name), Items: make([]Item, 0, len(events)), + AtomLinks: nil, }, } + selfURL := s.baseURL.JoinPath("v1", "subscriptions", request.SubscriptionId, "rss") + selfQuery := selfURL.Query() + if request.Params.PageToken != nil { + selfQuery.Set("page_token", *request.Params.PageToken) + } + if request.Params.PageSize != nil { + selfQuery.Set("page_size", strconv.Itoa(*request.Params.PageSize)) + } + if len(selfQuery) > 0 { + selfURL.RawQuery = selfQuery.Encode() + } + + rss.Channel.AtomLinks = append(rss.Channel.AtomLinks, AtomLink{ + Rel: "self", + Href: selfURL.String(), + }) + + if nextPageToken != nil { + u := s.baseURL.JoinPath("v1", "subscriptions", request.SubscriptionId, "rss") + q := u.Query() + q.Set("page_token", *nextPageToken) + q.Set("page_size", strconv.Itoa(pageSize)) + u.RawQuery = q.Encode() + + rss.Channel.AtomLinks = append(rss.Channel.AtomLinks, AtomLink{ + Rel: "next", + Href: u.String(), + }) + } + for _, e := range events { rss.Channel.Items = append(rss.Channel.Items, Item{ Description: string(e.Summary), - GUID: e.ID, - PubDate: e.Timestamp.Format(time.RFC1123Z), + GUID: GUID{ + Value: e.ID, + IsPermaLink: "false", + }, + PubDate: e.Timestamp.Format(time.RFC1123Z), }) } @@ -128,10 +183,13 @@ func (s *Server) GetSubscriptionRSS( }, nil } - fullXML := []byte(xml.Header + string(xmlBytes)) + var buf bytes.Buffer + buf.Grow(len(xml.Header) + len(xmlBytes)) + buf.WriteString(xml.Header) + buf.Write(xmlBytes) return backend.GetSubscriptionRSS200ApplicationrssXmlResponse{ - Body: bytes.NewReader(fullXML), - ContentLength: int64(len(fullXML)), + Body: bytes.NewReader(buf.Bytes()), + ContentLength: int64(buf.Len()), }, nil } diff --git a/backend/pkg/httpserver/get_saved_search_rss_test.go b/backend/pkg/httpserver/get_saved_search_rss_test.go index 24cbedb6b..2d70246a2 100644 --- a/backend/pkg/httpserver/get_saved_search_rss_test.go +++ b/backend/pkg/httpserver/get_saved_search_rss_test.go @@ -73,7 +73,8 @@ func TestGetSubscriptionRSS(t *testing.T) { eventsCfg: &MockListSavedSearchNotificationEventsConfig{ expectedSavedSearchID: "search-id", expectedSnapshotType: string(backend.SubscriptionFrequencyImmediate), - expectedLimit: 20, + expectedPageSize: 100, + expectedPageToken: nil, output: []backendtypes.SavedSearchNotificationEvent{ { ID: "event-1", @@ -87,16 +88,83 @@ func TestGetSubscriptionRSS(t *testing.T) { DiffBlobPath: "", }, }, + outputNextPageToken: nil, + err: nil, + }, + expectedStatusCode: 200, + expectedContentType: "application/rss+xml", + expectedBodyContains: []string{ + "WebStatus.dev - test search", + "RSS feed for saved search: test search", + "event-1", + "Thu, 01 Jan 2026 12:00:00 +0000", + "http://localhost:8080/features?q=query", + }, + }, + { + name: "success with pagination", + subCfg: &MockGetSavedSearchSubscriptionPublicConfig{ + expectedSubscriptionID: "sub-id", + output: &backend.SubscriptionResponse{ + Id: "sub-id", + Subscribable: backend.SavedSearchInfo{ + Id: "search-id", + Name: "", + }, + ChannelId: "", + CreatedAt: time.Time{}, + Frequency: backend.SubscriptionFrequencyImmediate, + Triggers: nil, + UpdatedAt: time.Time{}, + }, + err: nil, + }, + searchCfg: &MockGetSavedSearchPublicConfig{ + expectedSavedSearchID: "search-id", + output: &backend.SavedSearchResponse{ + Id: "search-id", + Name: "test search", + Query: "query", + BookmarkStatus: nil, + CreatedAt: time.Time{}, + Description: nil, + Permissions: nil, + UpdatedAt: time.Time{}, + }, err: nil, }, + eventsCfg: &MockListSavedSearchNotificationEventsConfig{ + expectedSavedSearchID: "search-id", + expectedSnapshotType: string(backend.SubscriptionFrequencyImmediate), + expectedPageSize: 100, + expectedPageToken: nil, + output: []backendtypes.SavedSearchNotificationEvent{ + { + ID: "event-1", + SavedSearchID: "search-id", + SnapshotType: string(backend.SubscriptionFrequencyImmediate), + Timestamp: time.Date(2026, 1, 1, 12, 0, 0, 0, time.UTC), + EventType: "IMMEDIATE_DIFF", + Summary: []byte(`"summary"`), + Reasons: nil, + BlobPath: "", + DiffBlobPath: "", + }, + }, + outputNextPageToken: &[]string{"next-token"}[0], + err: nil, + }, expectedStatusCode: 200, expectedContentType: "application/rss+xml", expectedBodyContains: []string{ "WebStatus.dev - test search", "RSS feed for saved search: test search", - "event-1", + "event-1", "Thu, 01 Jan 2026 12:00:00 +0000", "http://localhost:8080/features?q=query", + ``, }, }, { diff --git a/backend/pkg/httpserver/server.go b/backend/pkg/httpserver/server.go index 079bd642e..46be014ec 100644 --- a/backend/pkg/httpserver/server.go +++ b/backend/pkg/httpserver/server.go @@ -170,8 +170,9 @@ type WPTMetricsStorer interface { ctx context.Context, savedSearchID string, snapshotType string, - limit int, - ) ([]backendtypes.SavedSearchNotificationEvent, error) + pageSize int, + pageToken *string, + ) ([]backendtypes.SavedSearchNotificationEvent, *string, error) UpdateSavedSearchSubscription( ctx context.Context, userID, subscriptionID string, diff --git a/backend/pkg/httpserver/server_test.go b/backend/pkg/httpserver/server_test.go index 6ddd74c00..c2d7ea00e 100644 --- a/backend/pkg/httpserver/server_test.go +++ b/backend/pkg/httpserver/server_test.go @@ -205,8 +205,10 @@ type MockGetSavedSearchSubscriptionPublicConfig struct { type MockListSavedSearchNotificationEventsConfig struct { expectedSavedSearchID string expectedSnapshotType string - expectedLimit int + expectedPageSize int + expectedPageToken *string output []backendtypes.SavedSearchNotificationEvent + outputNextPageToken *string err error } @@ -781,8 +783,9 @@ func (m *MockWPTMetricsStorer) ListSavedSearchNotificationEvents( _ context.Context, savedSearchID string, snapshotType string, - limit int, -) ([]backendtypes.SavedSearchNotificationEvent, error) { + pageSize int, + pageToken *string, +) ([]backendtypes.SavedSearchNotificationEvent, *string, error) { m.callCountListSavedSearchNotificationEvents++ if m.listSavedSearchNotificationEventsCfg == nil { m.t.Fatal("listSavedSearchNotificationEventsCfg is nil") @@ -801,11 +804,32 @@ func (m *MockWPTMetricsStorer) ListSavedSearchNotificationEvents( snapshotType, ) } - if m.listSavedSearchNotificationEventsCfg.expectedLimit != limit { - m.t.Fatalf("unexpected limit. want %d, got %d", m.listSavedSearchNotificationEventsCfg.expectedLimit, limit) + if m.listSavedSearchNotificationEventsCfg.expectedPageSize != pageSize { + m.t.Fatalf( + "unexpected pageSize. want %d, got %d", + m.listSavedSearchNotificationEventsCfg.expectedPageSize, + pageSize, + ) + } + if m.listSavedSearchNotificationEventsCfg.expectedPageToken != nil && pageToken != nil { + if *m.listSavedSearchNotificationEventsCfg.expectedPageToken != *pageToken { + m.t.Fatalf( + "unexpected pageToken. want %s, got %s", + *m.listSavedSearchNotificationEventsCfg.expectedPageToken, + *pageToken, + ) + } + } else if m.listSavedSearchNotificationEventsCfg.expectedPageToken != pageToken { + m.t.Fatalf( + "unexpected pageToken. want %v, got %v", + m.listSavedSearchNotificationEventsCfg.expectedPageToken, + pageToken, + ) } - return m.listSavedSearchNotificationEventsCfg.output, m.listSavedSearchNotificationEventsCfg.err + return m.listSavedSearchNotificationEventsCfg.output, + m.listSavedSearchNotificationEventsCfg.outputNextPageToken, + m.listSavedSearchNotificationEventsCfg.err } func (m *MockWPTMetricsStorer) DeleteUserSavedSearch( diff --git a/lib/gcpspanner/saved_search_notification_events.go b/lib/gcpspanner/saved_search_notification_events.go index ad6db83ee..8e104911f 100644 --- a/lib/gcpspanner/saved_search_notification_events.go +++ b/lib/gcpspanner/saved_search_notification_events.go @@ -16,6 +16,7 @@ package gcpspanner import ( "context" + "fmt" "time" "cloud.google.com/go/spanner" @@ -173,35 +174,95 @@ func (c *Client) GetLatestSavedSearchNotificationEvent( return r.readRowByKey(ctx, key) } -func (c *Client) ListSavedSearchNotificationEvents(ctx context.Context, - savedSearchID string, snapshotType string, limit int) ([]SavedSearchNotificationEvent, error) { - stmt := spanner.Statement{ - SQL: `SELECT * FROM SavedSearchNotificationEvents - WHERE SavedSearchId = @SavedSearchId AND SnapshotType = @SnapshotType - ORDER BY Timestamp DESC - LIMIT @Limit`, - Params: map[string]any{ - "SavedSearchId": savedSearchID, - "SnapshotType": SavedSearchSnapshotType(snapshotType), - "Limit": limit, - }, - } - iter := c.Single().Query(ctx, stmt) - defer iter.Stop() +// savedSearchNotificationEventCursor is used for pagination. +type savedSearchNotificationEventCursor struct { + LastTimestamp time.Time `json:"last_timestamp"` + LastID string `json:"last_id"` +} - var events []SavedSearchNotificationEvent - err := iter.Do(func(row *spanner.Row) error { - var e SavedSearchNotificationEvent - if err := row.ToStruct(&e); err != nil { - return err - } - events = append(events, e) +// decodeSavedSearchNotificationEventCursor decodes a cursor string. +func decodeSavedSearchNotificationEventCursor(cursor string) (*savedSearchNotificationEventCursor, error) { + return decodeCursor[savedSearchNotificationEventCursor](cursor) +} - return nil +// encodeSavedSearchNotificationEventCursor encodes a cursor struct. +func encodeSavedSearchNotificationEventCursor(lastTimestamp time.Time, lastID string) string { + return encodeCursor(savedSearchNotificationEventCursor{ + LastTimestamp: lastTimestamp, + LastID: lastID, }) +} + +type ListSavedSearchNotificationEventsRequest struct { + SavedSearchID string + SnapshotType string + PageSize int + PageToken *string +} + +func (r ListSavedSearchNotificationEventsRequest) GetPageSize() int { + return r.PageSize +} + +type listSavedSearchNotificationEventsMapper struct{} + +func (m listSavedSearchNotificationEventsMapper) Table() string { + return "SavedSearchNotificationEvents" +} + +func (m listSavedSearchNotificationEventsMapper) SelectList( + req ListSavedSearchNotificationEventsRequest, +) spanner.Statement { + var parsedToken *savedSearchNotificationEventCursor + if req.PageToken != nil { + parsedToken, _ = decodeSavedSearchNotificationEventCursor(*req.PageToken) + } + + params := map[string]any{ + "SavedSearchId": req.SavedSearchID, + "SnapshotType": SavedSearchSnapshotType(req.SnapshotType), + "Limit": req.PageSize, + } + + var pageFilter string + if parsedToken != nil { + pageFilter = `AND (Timestamp < @LastTimestamp OR (Timestamp = @LastTimestamp AND EventId > @LastID))` + params["LastTimestamp"] = parsedToken.LastTimestamp + params["LastID"] = parsedToken.LastID + } + + query := fmt.Sprintf(`SELECT * FROM SavedSearchNotificationEvents + WHERE SavedSearchId = @SavedSearchId AND SnapshotType = @SnapshotType %s + ORDER BY Timestamp DESC, EventId ASC + LIMIT @Limit`, pageFilter) + stmt := spanner.NewStatement(query) + stmt.Params = params + + return stmt +} + +func (m listSavedSearchNotificationEventsMapper) EncodePageToken(item SavedSearchNotificationEvent) string { + return encodeSavedSearchNotificationEventCursor(item.Timestamp, item.ID) +} + +func (c *Client) ListSavedSearchNotificationEvents( + ctx context.Context, + savedSearchID string, + snapshotType string, + pageSize int, + pageToken *string, +) ([]SavedSearchNotificationEvent, *string, error) { + req := ListSavedSearchNotificationEventsRequest{ + SavedSearchID: savedSearchID, + SnapshotType: snapshotType, + PageSize: pageSize, + PageToken: pageToken, + } + + items, token, err := newEntityLister[listSavedSearchNotificationEventsMapper](c).list(ctx, req) if err != nil { - return nil, err + return nil, nil, err } - return events, nil + return items, token, nil } diff --git a/lib/gcpspanner/saved_search_notification_events_test.go b/lib/gcpspanner/saved_search_notification_events_test.go index a32145453..01138aeb6 100644 --- a/lib/gcpspanner/saved_search_notification_events_test.go +++ b/lib/gcpspanner/saved_search_notification_events_test.go @@ -461,7 +461,13 @@ func TestListSavedSearchNotificationEvents(t *testing.T) { for _, tc := range testCases { t.Run(tc.name, func(t *testing.T) { - events, err := spannerClient.ListSavedSearchNotificationEvents(ctx, savedSearchID, snapshotType, tc.limit) + events, nextPageToken, err := spannerClient.ListSavedSearchNotificationEvents( + ctx, + savedSearchID, + snapshotType, + tc.limit, + nil, + ) if err != nil { t.Fatalf("ListSavedSearchNotificationEvents() failed: %v", err) } @@ -470,6 +476,13 @@ func TestListSavedSearchNotificationEvents(t *testing.T) { t.Errorf("expected %d events, got %d", tc.expectedCount, len(events)) } + if tc.limit == 2 && nextPageToken == nil { + t.Errorf("expected nextPageToken, got nil") + } + if tc.limit == 10 && nextPageToken != nil { + t.Errorf("expected no nextPageToken, got %s", *nextPageToken) + } + for i, expectedID := range tc.expectedIDs { if events[i].ID != expectedID { t.Errorf("at index %d: expected ID %s, got %s", i, expectedID, events[i].ID) diff --git a/lib/gcpspanner/spanneradapters/backend.go b/lib/gcpspanner/spanneradapters/backend.go index 9e017abfc..e1f03336f 100644 --- a/lib/gcpspanner/spanneradapters/backend.go +++ b/lib/gcpspanner/spanneradapters/backend.go @@ -162,8 +162,13 @@ type BackendSpannerClient interface { ) (*string, error) UpdateNotificationChannel(ctx context.Context, req gcpspanner.UpdateNotificationChannelRequest) error DeleteNotificationChannel(ctx context.Context, channelID string, userID string) error - ListSavedSearchNotificationEvents(ctx context.Context, - savedSearchID string, snapshotType string, limit int) ([]gcpspanner.SavedSearchNotificationEvent, error) + ListSavedSearchNotificationEvents( + ctx context.Context, + savedSearchID string, + snapshotType string, + pageSize int, + pageToken *string, + ) ([]gcpspanner.SavedSearchNotificationEvent, *string, error) } // Backend converts queries to spanner to usable entities for the backend @@ -177,11 +182,22 @@ func NewBackend(client BackendSpannerClient) *Backend { return &Backend{client: client} } -func (s *Backend) ListSavedSearchNotificationEvents(ctx context.Context, - savedSearchID string, snapshotType string, limit int) ([]backendtypes.SavedSearchNotificationEvent, error) { - notifEvents, err := s.client.ListSavedSearchNotificationEvents(ctx, savedSearchID, snapshotType, limit) +func (s *Backend) ListSavedSearchNotificationEvents( + ctx context.Context, + savedSearchID string, + snapshotType string, + pageSize int, + pageToken *string, +) ([]backendtypes.SavedSearchNotificationEvent, *string, error) { + notifEvents, nextPageToken, err := s.client.ListSavedSearchNotificationEvents( + ctx, + savedSearchID, + snapshotType, + pageSize, + pageToken, + ) if err != nil { - return nil, err + return nil, nil, err } events := make([]backendtypes.SavedSearchNotificationEvent, 0, len(notifEvents)) @@ -203,7 +219,7 @@ func (s *Backend) ListSavedSearchNotificationEvents(ctx context.Context, }) } - return events, nil + return events, nextPageToken, nil } func (s *Backend) SyncUserProfileInfo(ctx context.Context, userProfile backendtypes.UserProfile) error { diff --git a/lib/gcpspanner/spanneradapters/backend_test.go b/lib/gcpspanner/spanneradapters/backend_test.go index 1e6b4323d..33cd48f74 100644 --- a/lib/gcpspanner/spanneradapters/backend_test.go +++ b/lib/gcpspanner/spanneradapters/backend_test.go @@ -211,8 +211,10 @@ type mockGetSavedSearchSubscriptionPublicConfig struct { type mockListSavedSearchNotificationEventsConfig struct { expectedSavedSearchID string expectedSnapshotType string - expectedLimit int + expectedPageSize int + expectedPageToken *string result []gcpspanner.SavedSearchNotificationEvent + outputNextPageToken *string returnedError error } @@ -675,14 +677,24 @@ func (c mockBackendSpannerClient) ListSavedSearchNotificationEvents( _ context.Context, savedSearchID string, snapshotType string, - limit int) ([]gcpspanner.SavedSearchNotificationEvent, error) { + pageSize int, + pageToken *string) ([]gcpspanner.SavedSearchNotificationEvent, *string, error) { if savedSearchID != c.mockListSavedSearchNotificationEventsCfg.expectedSavedSearchID || snapshotType != c.mockListSavedSearchNotificationEventsCfg.expectedSnapshotType || - limit != c.mockListSavedSearchNotificationEventsCfg.expectedLimit { + pageSize != c.mockListSavedSearchNotificationEventsCfg.expectedPageSize { c.t.Error("unexpected input to mock") } + if c.mockListSavedSearchNotificationEventsCfg.expectedPageToken != nil && pageToken != nil { + if *c.mockListSavedSearchNotificationEventsCfg.expectedPageToken != *pageToken { + c.t.Error("unexpected page token in mock") + } + } else if c.mockListSavedSearchNotificationEventsCfg.expectedPageToken != pageToken { + c.t.Error("unexpected page token in mock") + } - return c.mockListSavedSearchNotificationEventsCfg.result, c.mockListSavedSearchNotificationEventsCfg.returnedError + return c.mockListSavedSearchNotificationEventsCfg.result, + c.mockListSavedSearchNotificationEventsCfg.outputNextPageToken, + c.mockListSavedSearchNotificationEventsCfg.returnedError } // ListSavedSearchSubscriptions implements BackendSpannerClient.