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,