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 945a56440..492ffea37 100644
--- a/backend/pkg/httpserver/get_saved_search_rss.go
+++ b/backend/pkg/httpserver/get_saved_search_rss.go
@@ -15,19 +15,181 @@
package httpserver
import (
+ "bytes"
"context"
+ "encoding/xml"
+ "errors"
+ "fmt"
+ "log/slog"
+ "net/http"
+ "net/url"
+ "strconv"
+ "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"`
+ 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"`
+ 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 GUID `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)
+ 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)
+
+ 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",
+ 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: GUID{
+ Value: e.ID,
+ IsPermaLink: "false",
+ },
+ 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
+ }
+
+ var buf bytes.Buffer
+ buf.Grow(len(xml.Header) + len(xmlBytes))
+ buf.WriteString(xml.Header)
+ buf.Write(xmlBytes)
+
+ return backend.GetSubscriptionRSS200ApplicationrssXmlResponse{
+ 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
new file mode 100644
index 000000000..2d70246a2
--- /dev/null
+++ b/backend/pkg/httpserver/get_saved_search_rss_test.go
@@ -0,0 +1,254 @@
+// 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),
+ 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: 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",
+ "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..46be014ec 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,21 @@ 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,
+ pageSize int,
+ pageToken *string,
+ ) ([]backendtypes.SavedSearchNotificationEvent, *string, 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..c2d7ea00e 100644
--- a/backend/pkg/httpserver/server_test.go
+++ b/backend/pkg/httpserver/server_test.go
@@ -190,6 +190,28 @@ 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
+ expectedPageSize int
+ expectedPageToken *string
+ output []backendtypes.SavedSearchNotificationEvent
+ outputNextPageToken *string
+ err error
+}
+
type MockListUserSavedSeachesConfig struct {
expectedUserID string
expectedPageSize int
@@ -316,6 +338,9 @@ type MockWPTMetricsStorer struct {
createUserSavedSearchCfg *MockCreateUserSavedSearchConfig
deleteUserSavedSearchCfg *MockDeleteUserSavedSearchConfig
getSavedSearchCfg *MockGetSavedSearchConfig
+ getSavedSearchPublicCfg *MockGetSavedSearchPublicConfig
+ getSavedSearchSubscriptionPublicCfg *MockGetSavedSearchSubscriptionPublicConfig
+ listSavedSearchNotificationEventsCfg *MockListSavedSearchNotificationEventsConfig
listUserSavedSearchesCfg *MockListUserSavedSeachesConfig
updateUserSavedSearchCfg *MockUpdateUserSavedSearchConfig
putUserSavedSearchBookmarkCfg *MockPutUserSavedSearchBookmarkConfig
@@ -359,6 +384,9 @@ type MockWPTMetricsStorer struct {
callCountGetSavedSearchSubscription int
callCountListSavedSearchSubscriptions int
callCountUpdateSavedSearchSubscription int
+ callCountGetSavedSearchPublic int
+ callCountGetSavedSearchSubscriptionPublic int
+ callCountListSavedSearchNotificationEvents int
}
func (m *MockWPTMetricsStorer) GetIDFromFeatureKey(
@@ -713,6 +741,97 @@ 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,
+ pageSize int,
+ pageToken *string,
+) ([]backendtypes.SavedSearchNotificationEvent, *string, 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.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.outputNextPageToken,
+ m.listSavedSearchNotificationEventsCfg.err
+}
+
func (m *MockWPTMetricsStorer) DeleteUserSavedSearch(
_ context.Context,
userID string,
@@ -1474,13 +1593,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..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"
@@ -172,3 +173,96 @@ func (c *Client) GetLatestSavedSearchNotificationEvent(
return r.readRowByKey(ctx, key)
}
+
+// savedSearchNotificationEventCursor is used for pagination.
+type savedSearchNotificationEventCursor struct {
+ LastTimestamp time.Time `json:"last_timestamp"`
+ LastID string `json:"last_id"`
+}
+
+// decodeSavedSearchNotificationEventCursor decodes a cursor string.
+func decodeSavedSearchNotificationEventCursor(cursor string) (*savedSearchNotificationEventCursor, error) {
+ return decodeCursor[savedSearchNotificationEventCursor](cursor)
+}
+
+// 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, nil, err
+ }
+
+ 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 8ea64afa4..01138aeb6 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,92 @@ 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, nextPageToken, err := spannerClient.ListSavedSearchNotificationEvents(
+ ctx,
+ savedSearchID,
+ snapshotType,
+ tc.limit,
+ nil,
+ )
+ 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))
+ }
+
+ 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/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..e1f03336f 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,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,
+ pageSize int,
+ pageToken *string,
+ ) ([]gcpspanner.SavedSearchNotificationEvent, *string, error)
}
// Backend converts queries to spanner to usable entities for the backend
@@ -172,6 +182,46 @@ func NewBackend(client BackendSpannerClient) *Backend {
return &Backend{client: client}
}
+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, 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, nextPageToken, 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 +896,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 +1738,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..33cd48f74 100644
--- a/lib/gcpspanner/spanneradapters/backend_test.go
+++ b/lib/gcpspanner/spanneradapters/backend_test.go
@@ -196,6 +196,28 @@ 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
+ expectedPageSize int
+ expectedPageToken *string
+ result []gcpspanner.SavedSearchNotificationEvent
+ outputNextPageToken *string
+ returnedError error
+}
+
type mockListSavedSearchSubscriptionsConfig struct {
expectedRequest gcpspanner.ListSavedSearchSubscriptionsRequest
result []gcpspanner.SavedSearchSubscriptionView
@@ -204,36 +226,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 +589,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 +661,42 @@ 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,
+ pageSize int,
+ pageToken *string) ([]gcpspanner.SavedSearchNotificationEvent, *string, error) {
+ if savedSearchID != c.mockListSavedSearchNotificationEventsCfg.expectedSavedSearchID ||
+ snapshotType != c.mockListSavedSearchNotificationEventsCfg.expectedSnapshotType ||
+ 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.outputNextPageToken,
+ c.mockListSavedSearchNotificationEventsCfg.returnedError
+}
+
// ListSavedSearchSubscriptions implements BackendSpannerClient.
func (c mockBackendSpannerClient) ListSavedSearchSubscriptions(
_ context.Context,