diff --git a/lib/gcpspanner/notification_channel.go b/lib/gcpspanner/notification_channel.go index 3c2584b50..d279675c2 100644 --- a/lib/gcpspanner/notification_channel.go +++ b/lib/gcpspanner/notification_channel.go @@ -220,6 +220,79 @@ func (c *Client) GetNotificationChannel( return spannerChannel.toPublic() } +// ListNotificationChannelsRequest is a request to list notification channels. +type ListNotificationChannelsRequest struct { + UserID string + PageSize int + PageToken *string +} + +func (r ListNotificationChannelsRequest) GetPageSize() int { + return r.PageSize +} + +func (r ListNotificationChannelsRequest) GetPageToken() *string { + return r.PageToken +} + +type notificationChannelCursor struct { + LastID string `json:"last_id"` +} + +type listNotificationChannelsMapper struct{ notificationChannelMapper } + +func (m listNotificationChannelsMapper) EncodePageToken(item spannerNotificationChannel) string { + return encodeCursor(notificationChannelCursor{ + LastID: item.ID, + }) +} + +func (m listNotificationChannelsMapper) SelectList(req ListNotificationChannelsRequest) spanner.Statement { + var pageFilter string + params := map[string]interface{}{ + "userID": req.UserID, + "pageSize": req.PageSize, + } + if req.PageToken != nil { + cursor, err := decodeCursor[notificationChannelCursor](*req.PageToken) + if err == nil { + params["lastID"] = cursor.LastID + pageFilter = " AND ID > @lastID" + } + + } + query := fmt.Sprintf(`SELECT + ID, UserID, Name, Type, Config, CreatedAt, UpdatedAt + FROM NotificationChannels + WHERE UserID = @userID %s + ORDER BY UpdatedAt, ID + LIMIT @pageSize`, pageFilter) + stmt := spanner.NewStatement(query) + stmt.Params = params + + return stmt +} + +// ListNotificationChannels lists all notification channels for a user. +func (c *Client) ListNotificationChannels( + ctx context.Context, req ListNotificationChannelsRequest) ([]NotificationChannel, *string, error) { + items, token, err := newEntityLister[listNotificationChannelsMapper](c).list(ctx, req) + if err != nil { + return nil, nil, err + } + + channels := make([]NotificationChannel, 0, len(items)) + for _, item := range items { + channel, err := item.toPublic() + if err != nil { + return nil, nil, err + } + channels = append(channels, *channel) + } + + return channels, token, nil +} + // UpdateNotificationChannel updates a notification channel if it belongs to the specified user. func (c *Client) UpdateNotificationChannel( ctx context.Context, req UpdateNotificationChannelRequest) error { diff --git a/lib/gcpspanner/notification_channel_test.go b/lib/gcpspanner/notification_channel_test.go index 60824bc50..a0da51f7c 100644 --- a/lib/gcpspanner/notification_channel_test.go +++ b/lib/gcpspanner/notification_channel_test.go @@ -147,3 +147,60 @@ func TestNotificationChannelRefactoredOperations(t *testing.T) { } }) } + +func TestListNotificationChannels(t *testing.T) { + ctx := context.Background() + restartDatabaseContainer(t) + + userID := uuid.NewString() + + baseCreateReq := CreateNotificationChannelRequest{ + UserID: userID, + Name: "Test Email", + Type: "EMAIL", + EmailConfig: &EmailConfig{ + Address: "test@example.com", + IsVerified: true, + VerificationToken: nil, + }, + } + + // Create a few channels to list + for i := 0; i < 3; i++ { + _, err := spannerClient.CreateNotificationChannel(ctx, baseCreateReq) + if err != nil { + t.Fatalf("failed to create notification channel for list test: %v", err) + } + } + + // List first page + listReq1 := ListNotificationChannelsRequest{ + UserID: userID, + PageSize: 2, + PageToken: nil, + } + results1, nextPageToken1, err := spannerClient.ListNotificationChannels(ctx, listReq1) + if err != nil { + t.Fatalf("ListNotificationChannels page 1 failed: %v", err) + } + if len(results1) != 2 { + t.Errorf("expected 2 results on page 1, got %d", len(results1)) + } + if nextPageToken1 == nil { + t.Fatal("expected a next page token on page 1, got nil") + } + + // List second page + listReq2 := ListNotificationChannelsRequest{ + UserID: userID, + PageSize: 2, + PageToken: nextPageToken1, + } + results2, _, err := spannerClient.ListNotificationChannels(ctx, listReq2) + if err != nil { + t.Fatalf("ListNotificationChannels page 2 failed: %v", err) + } + if len(results2) < 1 { + t.Errorf("expected at least 1 result on page 2, got %d", len(results2)) + } +} diff --git a/lib/gcpspanner/spanneradapters/backend.go b/lib/gcpspanner/spanneradapters/backend.go index be09cf802..ed9d032a9 100644 --- a/lib/gcpspanner/spanneradapters/backend.go +++ b/lib/gcpspanner/spanneradapters/backend.go @@ -141,6 +141,21 @@ type BackendSpannerClient interface { AddUserSearchBookmark(ctx context.Context, req gcpspanner.UserSavedSearchBookmark) error DeleteUserSearchBookmark(ctx context.Context, req gcpspanner.UserSavedSearchBookmark) error SyncUserProfileInfo(ctx context.Context, userProfile gcpspanner.UserProfile) error + CreateNotificationChannel(ctx context.Context, req gcpspanner.CreateNotificationChannelRequest) (*string, error) + GetNotificationChannel( + ctx context.Context, channelID string, userID string) (*gcpspanner.NotificationChannel, error) + UpdateNotificationChannel(ctx context.Context, req gcpspanner.UpdateNotificationChannelRequest) error + DeleteNotificationChannel(ctx context.Context, channelID string, userID string) error + ListNotificationChannels(ctx context.Context, req gcpspanner.ListNotificationChannelsRequest) ( + []gcpspanner.NotificationChannel, *string, error) + CreateSavedSearchSubscription( + ctx context.Context, req gcpspanner.CreateSavedSearchSubscriptionRequest) (*string, error) + GetSavedSearchSubscription(ctx context.Context, subscriptionID string, userID string) ( + *gcpspanner.SavedSearchSubscription, 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) ( + []gcpspanner.SavedSearchSubscription, *string, error) } // Backend converts queries to spanner to usable entities for the backend @@ -1231,3 +1246,296 @@ func (s *Backend) GetIDFromFeatureKey( return id, nil } + +func (s *Backend) CreateNotificationChannel(ctx context.Context, + userID string, req backend.NotificationChannel) (*backend.NotificationChannelResponse, error) { + createReq := gcpspanner.CreateNotificationChannelRequest{ + UserID: userID, + Name: req.Name, + Type: string(req.Type), + EmailConfig: &gcpspanner.EmailConfig{Address: req.Value, IsVerified: false, VerificationToken: nil}, + } + + id, err := s.client.CreateNotificationChannel(ctx, createReq) + if err != nil { + // Handle specific errors from the client if necessary + return nil, err + } + + // Retrieve the newly created channel to return to the client. + channel, err := s.client.GetNotificationChannel(ctx, *id, userID) + if err != nil { + return nil, err + } + + return toBackendNotificationChannel(channel), nil +} + +func (s *Backend) GetNotificationChannel(ctx context.Context, + userID, channelID string) (*backend.NotificationChannelResponse, error) { + channel, err := s.client.GetNotificationChannel(ctx, channelID, userID) + if err != nil { + if errors.Is(err, gcpspanner.ErrMissingRequiredRole) { + return nil, errors.Join(err, backendtypes.ErrUserNotAuthorizedForAction) + } else if errors.Is(err, gcpspanner.ErrQueryReturnedNoResults) { + return nil, errors.Join(err, backendtypes.ErrEntityDoesNotExist) + } + + return nil, err + } + + return toBackendNotificationChannel(channel), nil +} + +func (s *Backend) UpdateNotificationChannel(ctx context.Context, userID, + channelID string, req backend.UpdateNotificationChannelRequest) (*backend.NotificationChannelResponse, error) { + updateReq := gcpspanner.UpdateNotificationChannelRequest{ + ID: channelID, + UserID: userID, + Name: gcpspanner.OptionallySet[string]{ + Value: "", + IsSet: false, + }, + EmailConfig: gcpspanner.OptionallySet[*gcpspanner.EmailConfig]{ + Value: nil, + IsSet: false, + }, + } + + for _, field := range req.UpdateMask { + switch field { + case backend.UpdateNotificationChannelRequestMaskName: + updateReq.Name = gcpspanner.OptionallySet[string]{Value: *req.Name, IsSet: true} + } + } + + err := s.client.UpdateNotificationChannel(ctx, updateReq) + if err != nil { + if errors.Is(err, gcpspanner.ErrMissingRequiredRole) { + return nil, errors.Join(err, backendtypes.ErrUserNotAuthorizedForAction) + } else if errors.Is(err, gcpspanner.ErrQueryReturnedNoResults) { + return nil, errors.Join(err, backendtypes.ErrEntityDoesNotExist) + } + + return nil, err + } + + // Fetch the updated channel to return. + channel, err := s.client.GetNotificationChannel(ctx, channelID, userID) + if err != nil { + return nil, err + } + + return toBackendNotificationChannel(channel), nil +} + +func (s *Backend) DeleteNotificationChannel(ctx context.Context, userID, channelID string) error { + err := s.client.DeleteNotificationChannel(ctx, channelID, userID) + if err != nil { + if errors.Is(err, gcpspanner.ErrMissingRequiredRole) { + return errors.Join(err, backendtypes.ErrUserNotAuthorizedForAction) + } else if errors.Is(err, gcpspanner.ErrQueryReturnedNoResults) { + return errors.Join(err, backendtypes.ErrEntityDoesNotExist) + } + + return err + } + + return nil +} + +func (s *Backend) ListNotificationChannels(ctx context.Context, + userID string, pageSize int, pageToken *string) (*backend.NotificationChannelPage, error) { + listReq := gcpspanner.ListNotificationChannelsRequest{ + UserID: userID, + PageSize: pageSize, + PageToken: pageToken, + } + channels, _, err := s.client.ListNotificationChannels(ctx, listReq) + if err != nil { + if errors.Is(err, gcpspanner.ErrInvalidCursorFormat) { + return nil, errors.Join(err, backendtypes.ErrInvalidPageToken) + } + + return nil, err + } + + backendChannels := make([]backend.NotificationChannelResponse, 0, len(channels)) + for i := range channels { + backendChannels = append(backendChannels, *toBackendNotificationChannel(&channels[i])) + } + + return &backend.NotificationChannelPage{ + Data: &backendChannels, + Metadata: &backend.PageMetadata{ + NextPageToken: nil, + }, + }, nil +} + +// toBackendNotificationChannel is a helper function to convert spanner +// notification channel to backend notification channel. +func toBackendNotificationChannel(channel *gcpspanner.NotificationChannel) *backend.NotificationChannelResponse { + if channel == nil { + return nil + } + // Convert spanner channel to backend channel + // This can be expanded to handle different channel types. + var value string + if channel.EmailConfig != nil { + value = channel.EmailConfig.Address + } + + return &backend.NotificationChannelResponse{ + Id: channel.ID, + Name: channel.Name, + Type: backend.NotificationChannelResponseType(channel.Type), + Value: value, + // For now, assume all channels are enabled. + // We currently do not disable channels. + // TODO: https://github.com/GoogleChrome/webstatus.dev/issues/2021 + Status: backend.NotificationChannelStatusEnabled, + CreatedAt: channel.CreatedAt, + UpdatedAt: channel.UpdatedAt, + } +} + +func (s *Backend) CreateSavedSearchSubscription(ctx context.Context, + userID string, req backend.Subscription) (*backend.SubscriptionResponse, error) { + createReq := gcpspanner.CreateSavedSearchSubscriptionRequest{ + UserID: userID, + ChannelID: req.ChannelId, + SavedSearchID: req.SavedSearchId, + Triggers: req.Triggers, + Frequency: string(req.Frequency), + } + + id, err := s.client.CreateSavedSearchSubscription(ctx, createReq) + if err != nil { + return nil, err + } + + // Retrieve the newly created subscription to return to the client. + sub, err := s.client.GetSavedSearchSubscription(ctx, *id, userID) + if err != nil { + return nil, err + } + + return toBackendSubscription(sub), nil + +} + +func (s *Backend) ListSavedSearchSubscriptions(ctx context.Context, + userID string, pageSize int, pageToken *string) (*backend.SubscriptionPage, error) { + listReq := gcpspanner.ListSavedSearchSubscriptionsRequest{ + UserID: userID, + PageSize: pageSize, + PageToken: pageToken, + } + subs, token, err := s.client.ListSavedSearchSubscriptions(ctx, listReq) + if err != nil { + return nil, err + } + backendSubs := make([]backend.SubscriptionResponse, 0, len(subs)) + for i := range subs { + backendSubs = append(backendSubs, *toBackendSubscription(&subs[i])) + } + + return &backend.SubscriptionPage{ + Data: &backendSubs, + Metadata: &backend.PageMetadata{ + NextPageToken: token, + }, + }, nil +} + +func (s *Backend) GetSavedSearchSubscription(ctx context.Context, + userID, subscriptionID string) (*backend.SubscriptionResponse, error) { + sub, err := s.client.GetSavedSearchSubscription(ctx, subscriptionID, userID) + if err != nil { + if errors.Is(err, gcpspanner.ErrMissingRequiredRole) { + return nil, errors.Join(err, backendtypes.ErrUserNotAuthorizedForAction) + } else 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{ + ID: subscriptionID, + UserID: userID, + Triggers: gcpspanner.OptionallySet[[]string]{ + IsSet: false, + Value: nil, + }, + Frequency: gcpspanner.OptionallySet[string]{ + IsSet: false, + Value: "", + }, + } + + for _, field := range req.UpdateMask { + switch field { + case backend.UpdateSubscriptionRequestMaskTriggers: + updateReq.Triggers = gcpspanner.OptionallySet[[]string]{Value: *req.Triggers, IsSet: true} + case backend.UpdateSubscriptionRequestMaskFrequency: + updateReq.Frequency = gcpspanner.OptionallySet[string]{Value: string(*req.Frequency), IsSet: true} + } + } + + err := s.client.UpdateSavedSearchSubscription(ctx, updateReq) + if err != nil { + if errors.Is(err, gcpspanner.ErrMissingRequiredRole) { + return nil, errors.Join(err, backendtypes.ErrUserNotAuthorizedForAction) + } else if errors.Is(err, gcpspanner.ErrQueryReturnedNoResults) { + return nil, errors.Join(err, backendtypes.ErrEntityDoesNotExist) + } + + return nil, err + } + + // Fetch the updated subscription to return. + sub, err := s.client.GetSavedSearchSubscription(ctx, subscriptionID, userID) + if err != nil { + return nil, err + } + + return toBackendSubscription(sub), nil +} + +func (s *Backend) DeleteSavedSearchSubscription(ctx context.Context, userID, subscriptionID string) error { + err := s.client.DeleteSavedSearchSubscription(ctx, subscriptionID, userID) + if err != nil { + if errors.Is(err, gcpspanner.ErrMissingRequiredRole) { + return errors.Join(err, backendtypes.ErrUserNotAuthorizedForAction) + } else if errors.Is(err, gcpspanner.ErrQueryReturnedNoResults) { + return errors.Join(err, backendtypes.ErrEntityDoesNotExist) + } + + return err + } + + return nil +} + +func toBackendSubscription(sub *gcpspanner.SavedSearchSubscription) *backend.SubscriptionResponse { + if sub == nil { + return nil + } + + return &backend.SubscriptionResponse{ + Id: sub.ID, + ChannelId: sub.ChannelID, + SavedSearchId: sub.SavedSearchID, + Triggers: sub.Triggers, + Frequency: backend.SubscriptionResponseFrequency(sub.Frequency), + CreatedAt: sub.CreatedAt, + UpdatedAt: sub.UpdatedAt, + } +} diff --git a/lib/gcpspanner/spanneradapters/backend_test.go b/lib/gcpspanner/spanneradapters/backend_test.go index 6d8c96e26..dd0b1121f 100644 --- a/lib/gcpspanner/spanneradapters/backend_test.go +++ b/lib/gcpspanner/spanneradapters/backend_test.go @@ -164,6 +164,73 @@ type mockBackendSpannerClient struct { mockGetMovedWebFeatureDetailsByOriginalFeatureKeyCfg *mockGetMovedWebFeatureDetailsByOriginalFeatureKeyConfig mockGetSplitWebFeatureByOriginalFeatureKeyCfg *mockGetSplitWebFeatureByOriginalFeatureKeyConfig mockSyncUserProfileInfoCfg *mockSyncUserProfileInfoConfig + mockCreateNotificationChannelCfg *mockCreateNotificationChannelConfig + mockGetNotificationChannelCfg *mockGetNotificationChannelConfig + mockUpdateNotificationChannelCfg *mockUpdateNotificationChannelConfig + mockDeleteNotificationChannelCfg *mockDeleteNotificationChannelConfig + mockCreateSavedSearchSubscriptionCfg *mockCreateSavedSearchSubscriptionConfig + mockGetSavedSearchSubscriptionCfg *mockGetSavedSearchSubscriptionConfig + mockUpdateSavedSearchSubscriptionCfg *mockUpdateSavedSearchSubscriptionConfig + mockDeleteSavedSearchSubscriptionCfg *mockDeleteSavedSearchSubscriptionConfig + mockListSavedSearchSubscriptionsCfg *mockListSavedSearchSubscriptionsConfig + mockListNotificationChannelsCfg *mockListNotificationChannelsConfig +} + +// CreateSavedSearchSubscription implements BackendSpannerClient. +func (c mockBackendSpannerClient) CreateSavedSearchSubscription( + _ context.Context, req gcpspanner.CreateSavedSearchSubscriptionRequest) (*string, error) { + if !reflect.DeepEqual(req, c.mockCreateSavedSearchSubscriptionCfg.expectedRequest) { + c.t.Error("unexpected input to mock") + } + + return c.mockCreateSavedSearchSubscriptionCfg.result, c.mockCreateSavedSearchSubscriptionCfg.returnedError +} + +// DeleteSavedSearchSubscription implements BackendSpannerClient. +func (c mockBackendSpannerClient) DeleteSavedSearchSubscription( + _ context.Context, subscriptionID string, userID string) error { + if subscriptionID != c.mockDeleteSavedSearchSubscriptionCfg.expectedSubscriptionID || + userID != c.mockDeleteSavedSearchSubscriptionCfg.expectedUserID { + c.t.Error("unexpected input to mock") + } + + return c.mockDeleteSavedSearchSubscriptionCfg.returnedError +} + +// GetSavedSearchSubscription implements BackendSpannerClient. +func (c mockBackendSpannerClient) GetSavedSearchSubscription( + _ context.Context, + subscriptionID string, + userID string) (*gcpspanner.SavedSearchSubscription, error) { + if subscriptionID != c.mockGetSavedSearchSubscriptionCfg.expectedSubscriptionID || + userID != c.mockGetSavedSearchSubscriptionCfg.expectedUserID { + c.t.Error("unexpected input to mock") + } + + return c.mockGetSavedSearchSubscriptionCfg.result, c.mockGetSavedSearchSubscriptionCfg.returnedError +} + +// ListSavedSearchSubscriptions implements BackendSpannerClient. +func (c mockBackendSpannerClient) ListSavedSearchSubscriptions( + _ context.Context, + req gcpspanner.ListSavedSearchSubscriptionsRequest) ([]gcpspanner.SavedSearchSubscription, *string, error) { + if !reflect.DeepEqual(req, c.mockListSavedSearchSubscriptionsCfg.expectedRequest) { + c.t.Error("unexpected input to mock") + } + + return c.mockListSavedSearchSubscriptionsCfg.result, + c.mockListSavedSearchSubscriptionsCfg.nextPageToken, + c.mockListSavedSearchSubscriptionsCfg.returnedError +} + +// UpdateSavedSearchSubscription implements BackendSpannerClient. +func (c mockBackendSpannerClient) UpdateSavedSearchSubscription( + _ context.Context, req gcpspanner.UpdateSavedSearchSubscriptionRequest) error { + if !reflect.DeepEqual(req, c.mockUpdateSavedSearchSubscriptionCfg.expectedRequest) { + c.t.Error("unexpected input to mock") + } + + return c.mockUpdateSavedSearchSubscriptionCfg.returnedError } type mockSyncUserProfileInfoConfig struct { @@ -171,6 +238,68 @@ type mockSyncUserProfileInfoConfig struct { returnedError error } +type mockCreateNotificationChannelConfig struct { + expectedRequest gcpspanner.CreateNotificationChannelRequest + result *string + returnedError error +} + +type mockGetNotificationChannelConfig struct { + expectedChannelID string + expectedUserID string + result *gcpspanner.NotificationChannel + returnedError error +} + +type mockUpdateNotificationChannelConfig struct { + expectedRequest gcpspanner.UpdateNotificationChannelRequest + returnedError error +} + +type mockDeleteNotificationChannelConfig struct { + expectedChannelID string + expectedUserID string + returnedError error +} + +type mockCreateSavedSearchSubscriptionConfig struct { + expectedRequest gcpspanner.CreateSavedSearchSubscriptionRequest + result *string + returnedError error +} + +type mockGetSavedSearchSubscriptionConfig struct { + expectedSubscriptionID string + expectedUserID string + result *gcpspanner.SavedSearchSubscription + returnedError error +} + +type mockUpdateSavedSearchSubscriptionConfig struct { + expectedRequest gcpspanner.UpdateSavedSearchSubscriptionRequest + returnedError error +} + +type mockDeleteSavedSearchSubscriptionConfig struct { + expectedSubscriptionID string + expectedUserID string + returnedError error +} + +type mockListSavedSearchSubscriptionsConfig struct { + expectedRequest gcpspanner.ListSavedSearchSubscriptionsRequest + result []gcpspanner.SavedSearchSubscription + nextPageToken *string + returnedError error +} + +type mockListNotificationChannelsConfig struct { + expectedRequest gcpspanner.ListNotificationChannelsRequest + result []gcpspanner.NotificationChannel + nextPageToken *string + returnedError error +} + func (c mockBackendSpannerClient) SyncUserProfileInfo( _ context.Context, userProfile gcpspanner.UserProfile) error { if !reflect.DeepEqual(userProfile, c.mockSyncUserProfileInfoCfg.expectedUserProfile) { @@ -180,7 +309,53 @@ func (c mockBackendSpannerClient) SyncUserProfileInfo( return c.mockSyncUserProfileInfoCfg.returnedError } -// GetMovedWebFeatureDetailsByOriginalFeatureKey implements BackendSpannerClient. +func (c mockBackendSpannerClient) CreateNotificationChannel( + _ context.Context, req gcpspanner.CreateNotificationChannelRequest) (*string, error) { + if !reflect.DeepEqual(req, c.mockCreateNotificationChannelCfg.expectedRequest) { + c.t.Errorf("unexpected input to CreateNotificationChannel: got %+v, want %+v", + req, c.mockCreateNotificationChannelCfg.expectedRequest) + } + + return c.mockCreateNotificationChannelCfg.result, c.mockCreateNotificationChannelCfg.returnedError +} + +func (c mockBackendSpannerClient) GetNotificationChannel( + _ context.Context, channelID string, userID string) (*gcpspanner.NotificationChannel, error) { + if channelID != c.mockGetNotificationChannelCfg.expectedChannelID || + userID != c.mockGetNotificationChannelCfg.expectedUserID { + c.t.Error("unexpected input to mock") + } + + return c.mockGetNotificationChannelCfg.result, c.mockGetNotificationChannelCfg.returnedError +} + +func (c mockBackendSpannerClient) UpdateNotificationChannel(_ context.Context, + req gcpspanner.UpdateNotificationChannelRequest) error { + if !reflect.DeepEqual(req, c.mockUpdateNotificationChannelCfg.expectedRequest) { + c.t.Error("unexpected input to mock") + } + + return c.mockUpdateNotificationChannelCfg.returnedError +} + +func (c mockBackendSpannerClient) DeleteNotificationChannel(_ context.Context, channelID string, userID string) error { + if channelID != c.mockDeleteNotificationChannelCfg.expectedChannelID || + userID != c.mockDeleteNotificationChannelCfg.expectedUserID { + c.t.Error("unexpected input to mock") + } + + return c.mockDeleteNotificationChannelCfg.returnedError +} +func (c mockBackendSpannerClient) ListNotificationChannels( + _ context.Context, + req gcpspanner.ListNotificationChannelsRequest) ([]gcpspanner.NotificationChannel, *string, error) { + if !reflect.DeepEqual(req, c.mockListNotificationChannelsCfg.expectedRequest) { + c.t.Error("unexpected input to mock") + } + + return c.mockListNotificationChannelsCfg.result, + c.mockListNotificationChannelsCfg.nextPageToken, c.mockListNotificationChannelsCfg.returnedError +} func (c mockBackendSpannerClient) GetMovedWebFeatureDetailsByOriginalFeatureKey( _ context.Context, featureKey string) (*gcpspanner.MovedWebFeature, error) { if featureKey != c.mockGetMovedWebFeatureDetailsByOriginalFeatureKeyCfg.expectedFeatureKey { @@ -3196,3 +3371,1035 @@ func TestConvertFeatureResult(t *testing.T) { }) } } + +func TestCreateNotificationChannel(t *testing.T) { + const ( + userID = "user123" + channelName = "My Email" + channelValue = "test@example.com" + channelID = "channel456" + expectedAPIType = backend.NotificationChannelTypeEmail + ) + + now := time.Now() + + testCases := []struct { + name string + input backend.NotificationChannel + createCfg *mockCreateNotificationChannelConfig + getCfg *mockGetNotificationChannelConfig + expected *backend.NotificationChannelResponse + expectedError error + }{ + { + name: "success", + input: backend.NotificationChannel{ + Name: channelName, + Type: expectedAPIType, + Value: channelValue, + }, + createCfg: &mockCreateNotificationChannelConfig{ + expectedRequest: gcpspanner.CreateNotificationChannelRequest{ + UserID: userID, + Name: channelName, + Type: string(expectedAPIType), + EmailConfig: &gcpspanner.EmailConfig{Address: channelValue, IsVerified: false, VerificationToken: nil}, + }, + result: valuePtr(channelID), + returnedError: nil, + }, + getCfg: &mockGetNotificationChannelConfig{ + expectedChannelID: channelID, + expectedUserID: userID, + result: &gcpspanner.NotificationChannel{ + ID: channelID, + UserID: userID, + Name: channelName, + Type: string(expectedAPIType), + EmailConfig: &gcpspanner.EmailConfig{Address: channelValue, IsVerified: false, VerificationToken: nil}, + CreatedAt: now, + UpdatedAt: now, + }, + returnedError: nil, + }, + expected: &backend.NotificationChannelResponse{ + Id: channelID, + Name: channelName, + Type: backend.NotificationChannelResponseType(expectedAPIType), + Value: channelValue, + Status: backend.NotificationChannelStatusEnabled, + CreatedAt: now, + UpdatedAt: now, + }, + expectedError: nil, + }, + { + name: "create error", + input: backend.NotificationChannel{ + Name: channelName, + Type: expectedAPIType, + Value: channelValue, + }, + createCfg: &mockCreateNotificationChannelConfig{ + expectedRequest: gcpspanner.CreateNotificationChannelRequest{ + UserID: userID, + Name: channelName, + Type: string(expectedAPIType), + EmailConfig: &gcpspanner.EmailConfig{Address: channelValue, IsVerified: false, VerificationToken: nil}, + }, + result: nil, + returnedError: errTest, + }, + getCfg: nil, + expected: nil, + expectedError: errTest, + }, + { + name: "get after create error", + input: backend.NotificationChannel{ + Name: channelName, + Type: expectedAPIType, + Value: channelValue, + }, + createCfg: &mockCreateNotificationChannelConfig{ + expectedRequest: gcpspanner.CreateNotificationChannelRequest{ + UserID: userID, + Name: channelName, + Type: string(expectedAPIType), + EmailConfig: &gcpspanner.EmailConfig{Address: channelValue, IsVerified: false, VerificationToken: nil}, + }, + result: valuePtr(channelID), + returnedError: nil, + }, + getCfg: &mockGetNotificationChannelConfig{ + expectedChannelID: channelID, + expectedUserID: userID, + result: nil, + returnedError: errTest, + }, + expected: nil, + expectedError: errTest, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + //nolint: exhaustruct + mock := mockBackendSpannerClient{ + t: t, + mockCreateNotificationChannelCfg: tc.createCfg, + mockGetNotificationChannelCfg: tc.getCfg, + } + b := NewBackend(mock) + resp, err := b.CreateNotificationChannel(context.Background(), userID, tc.input) + if !errors.Is(err, tc.expectedError) { + t.Errorf("unexpected error. got %v, want %v", err, tc.expectedError) + } + if diff := cmp.Diff(tc.expected, resp); diff != "" { + t.Errorf("response mismatch (-want +got):\n%s", diff) + } + }) + } +} + +func TestGetNotificationChannel(t *testing.T) { + const ( + userID = "user123" + channelID = "channel456" + ) + now := time.Now() + + testCases := []struct { + name string + cfg *mockGetNotificationChannelConfig + expected *backend.NotificationChannelResponse + expectedError error + }{ + { + name: "success", + cfg: &mockGetNotificationChannelConfig{ + expectedChannelID: channelID, + expectedUserID: userID, + result: &gcpspanner.NotificationChannel{ + ID: channelID, + UserID: userID, + Name: "My Email", + Type: "email", + EmailConfig: &gcpspanner.EmailConfig{Address: "test@example.com", IsVerified: false, VerificationToken: nil}, + CreatedAt: now, + UpdatedAt: now, + }, + returnedError: nil, + }, + expected: &backend.NotificationChannelResponse{ + Id: channelID, + Name: "My Email", + Type: backend.NotificationChannelResponseTypeEmail, + Value: "test@example.com", + Status: backend.NotificationChannelStatusEnabled, + CreatedAt: now, + UpdatedAt: now, + }, + expectedError: nil, + }, + { + name: "not found", + cfg: &mockGetNotificationChannelConfig{ + expectedChannelID: channelID, + expectedUserID: userID, + result: nil, + returnedError: gcpspanner.ErrQueryReturnedNoResults, + }, + expected: nil, + expectedError: backendtypes.ErrEntityDoesNotExist, + }, + { + name: "not authorized", + cfg: &mockGetNotificationChannelConfig{ + expectedChannelID: channelID, + expectedUserID: userID, + result: nil, + returnedError: gcpspanner.ErrMissingRequiredRole, + }, + expected: nil, + expectedError: backendtypes.ErrUserNotAuthorizedForAction, + }, + { + name: "other error", + cfg: &mockGetNotificationChannelConfig{ + expectedChannelID: channelID, + expectedUserID: userID, + result: nil, + returnedError: errTest, + }, + expected: nil, + expectedError: errTest, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + //nolint: exhaustruct + mock := mockBackendSpannerClient{ + t: t, + mockGetNotificationChannelCfg: tc.cfg, + } + b := NewBackend(mock) + resp, err := b.GetNotificationChannel(context.Background(), userID, channelID) + if !errors.Is(err, tc.expectedError) { + t.Errorf("unexpected error. got %v, want %v", err, tc.expectedError) + } + if diff := cmp.Diff(tc.expected, resp); diff != "" { + t.Errorf("response mismatch (-want +got):\n%s", diff) + } + }) + } +} + +func TestUpdateNotificationChannel(t *testing.T) { + const ( + userID = "user123" + channelID = "channel456" + ) + now := time.Now() + updatedName := "New Name" + + testCases := []struct { + name string + input backend.UpdateNotificationChannelRequest + updateCfg *mockUpdateNotificationChannelConfig + getCfg *mockGetNotificationChannelConfig + expected *backend.NotificationChannelResponse + expectedError error + }{ + { + name: "success update name", + input: backend.UpdateNotificationChannelRequest{ + UpdateMask: []backend.UpdateNotificationChannelRequestUpdateMask{backend.UpdateNotificationChannelRequestMaskName}, + Name: &updatedName, + }, + updateCfg: &mockUpdateNotificationChannelConfig{ + expectedRequest: gcpspanner.UpdateNotificationChannelRequest{ + ID: channelID, + UserID: userID, + Name: gcpspanner.OptionallySet[string]{Value: updatedName, IsSet: true}, + EmailConfig: gcpspanner.OptionallySet[*gcpspanner.EmailConfig]{ + Value: nil, + IsSet: false, + }, + }, + returnedError: nil, + }, + getCfg: &mockGetNotificationChannelConfig{ + expectedChannelID: channelID, + expectedUserID: userID, + result: &gcpspanner.NotificationChannel{ + ID: channelID, + Name: updatedName, + UserID: "user", + Type: "email", + EmailConfig: &gcpspanner.EmailConfig{Address: "old@example.com", IsVerified: false, VerificationToken: nil}, + CreatedAt: now, + UpdatedAt: now, + }, + returnedError: nil, + }, + expected: &backend.NotificationChannelResponse{ + Id: channelID, + Name: updatedName, + Type: backend.NotificationChannelResponseTypeEmail, + Value: "old@example.com", + Status: backend.NotificationChannelStatusEnabled, + CreatedAt: now, + UpdatedAt: now, + }, + expectedError: nil, + }, + { + name: "update error", + input: backend.UpdateNotificationChannelRequest{ + UpdateMask: []backend.UpdateNotificationChannelRequestUpdateMask{backend.UpdateNotificationChannelRequestMaskName}, + Name: &updatedName, + }, + updateCfg: &mockUpdateNotificationChannelConfig{ + expectedRequest: gcpspanner.UpdateNotificationChannelRequest{ + ID: channelID, + UserID: userID, + Name: gcpspanner.OptionallySet[string]{Value: updatedName, IsSet: true}, + EmailConfig: gcpspanner.OptionallySet[*gcpspanner.EmailConfig]{ + Value: nil, + IsSet: false, + }, + }, + returnedError: errTest, + }, + getCfg: nil, + expected: nil, + expectedError: errTest, + }, + { + name: "not found", + input: backend.UpdateNotificationChannelRequest{ + UpdateMask: []backend.UpdateNotificationChannelRequestUpdateMask{backend.UpdateNotificationChannelRequestMaskName}, + Name: &updatedName, + }, + updateCfg: &mockUpdateNotificationChannelConfig{ + expectedRequest: gcpspanner.UpdateNotificationChannelRequest{ + ID: channelID, + UserID: userID, + Name: gcpspanner.OptionallySet[string]{Value: updatedName, IsSet: true}, + EmailConfig: gcpspanner.OptionallySet[*gcpspanner.EmailConfig]{ + Value: nil, + IsSet: false, + }, + }, + returnedError: gcpspanner.ErrQueryReturnedNoResults, + }, + getCfg: nil, + expected: nil, + expectedError: backendtypes.ErrEntityDoesNotExist, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + //nolint: exhaustruct + mock := mockBackendSpannerClient{ + t: t, + mockUpdateNotificationChannelCfg: tc.updateCfg, + mockGetNotificationChannelCfg: tc.getCfg, + } + b := NewBackend(mock) + resp, err := b.UpdateNotificationChannel(context.Background(), userID, channelID, tc.input) + if !errors.Is(err, tc.expectedError) { + t.Errorf("unexpected error. got %v, want %v", err, tc.expectedError) + } + if diff := cmp.Diff(tc.expected, resp); diff != "" { + t.Errorf("response mismatch (-want +got):\n%s", diff) + } + }) + } +} + +func TestDeleteNotificationChannel(t *testing.T) { + const ( + userID = "user123" + channelID = "channel456" + ) + + testCases := []struct { + name string + cfg *mockDeleteNotificationChannelConfig + expectedError error + }{ + { + name: "success", + cfg: &mockDeleteNotificationChannelConfig{ + expectedChannelID: channelID, + expectedUserID: userID, + returnedError: nil, + }, + expectedError: nil, + }, + { + name: "not found", + cfg: &mockDeleteNotificationChannelConfig{ + expectedChannelID: channelID, + expectedUserID: userID, + returnedError: gcpspanner.ErrQueryReturnedNoResults, + }, + expectedError: backendtypes.ErrEntityDoesNotExist, + }, + { + name: "not authorized", + cfg: &mockDeleteNotificationChannelConfig{ + expectedChannelID: channelID, + expectedUserID: userID, + returnedError: gcpspanner.ErrMissingRequiredRole, + }, + expectedError: backendtypes.ErrUserNotAuthorizedForAction, + }, + { + name: "other error", + cfg: &mockDeleteNotificationChannelConfig{ + expectedChannelID: channelID, + expectedUserID: userID, + returnedError: errTest, + }, + expectedError: errTest, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + //nolint: exhaustruct + mock := mockBackendSpannerClient{ + t: t, + mockDeleteNotificationChannelCfg: tc.cfg, + } + b := NewBackend(mock) + err := b.DeleteNotificationChannel(context.Background(), userID, channelID) + if !errors.Is(err, tc.expectedError) { + t.Errorf("unexpected error. got %v, want %v", err, tc.expectedError) + } + }) + } +} + +func TestListNotificationChannels(t *testing.T) { + const ( + userID = "user123" + ) + now := time.Now() + + testCases := []struct { + name string + pageSize int + pageToken *string + cfg *mockListNotificationChannelsConfig + expected *backend.NotificationChannelPage + expectedError error + }{ + { + name: "success", + pageSize: 10, + pageToken: nil, + cfg: &mockListNotificationChannelsConfig{ + expectedRequest: gcpspanner.ListNotificationChannelsRequest{ + UserID: userID, + PageSize: 10, + PageToken: nil, + }, + result: []gcpspanner.NotificationChannel{ + { + ID: "id1", + Name: "channel1", + UserID: "user", + Type: "email", + EmailConfig: &gcpspanner.EmailConfig{Address: "1@test.com", + IsVerified: false, VerificationToken: nil}, + CreatedAt: now, + UpdatedAt: now, + }, + { + ID: "id2", + Name: "channel2", + UserID: "user", + Type: "email", + EmailConfig: &gcpspanner.EmailConfig{Address: "2@test.com", + IsVerified: false, VerificationToken: nil}, + CreatedAt: now, + UpdatedAt: now, + }, + }, + nextPageToken: nil, + returnedError: nil, + }, + expected: &backend.NotificationChannelPage{ + Data: &[]backend.NotificationChannelResponse{ + { + Id: "id1", + Name: "channel1", + Type: backend.NotificationChannelResponseTypeEmail, + Value: "1@test.com", + Status: backend.NotificationChannelStatusEnabled, + CreatedAt: now, + UpdatedAt: now, + }, + { + Id: "id2", + Name: "channel2", + Type: backend.NotificationChannelResponseTypeEmail, + Value: "2@test.com", + Status: backend.NotificationChannelStatusEnabled, + CreatedAt: now, + UpdatedAt: now, + }, + }, + Metadata: &backend.PageMetadata{NextPageToken: nil}, + }, + expectedError: nil, + }, + { + name: "db error", + pageSize: 10, + pageToken: nil, + cfg: &mockListNotificationChannelsConfig{ + expectedRequest: gcpspanner.ListNotificationChannelsRequest{ + UserID: userID, + PageSize: 10, + PageToken: nil, + }, + result: nil, + nextPageToken: nil, + returnedError: errTest, + }, + expected: nil, + expectedError: errTest, + }, + { + name: "invalid cursor", + pageSize: 10, + pageToken: nonNilInputPageToken, + cfg: &mockListNotificationChannelsConfig{ + expectedRequest: gcpspanner.ListNotificationChannelsRequest{ + UserID: userID, + PageSize: 10, + PageToken: nonNilInputPageToken, + }, + result: nil, + nextPageToken: nil, + returnedError: gcpspanner.ErrInvalidCursorFormat, + }, + expected: nil, + expectedError: backendtypes.ErrInvalidPageToken, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + //nolint: exhaustruct + mock := mockBackendSpannerClient{ + t: t, + mockListNotificationChannelsCfg: tc.cfg, + } + b := NewBackend(mock) + resp, err := b.ListNotificationChannels(context.Background(), userID, tc.pageSize, tc.pageToken) + if !errors.Is(err, tc.expectedError) { + t.Errorf("unexpected error. got %v, want %v", err, tc.expectedError) + } + if diff := cmp.Diff(tc.expected, resp); diff != "" { + t.Errorf("response mismatch (-want +got):\n%s", diff) + } + }) + } +} + +func TestCreateSavedSearchSubscription(t *testing.T) { + const ( + userID = "user123" + channelID = "channel-id" + savedSearchID = "saved-search-id" + subID = "sub-id" + ) + now := time.Now() + testCases := []struct { + name string + input backend.Subscription + createCfg *mockCreateSavedSearchSubscriptionConfig + getCfg *mockGetSavedSearchSubscriptionConfig + expected *backend.SubscriptionResponse + expectedError error + }{ + { + name: "success", + input: backend.Subscription{ + ChannelId: channelID, + SavedSearchId: savedSearchID, + Triggers: []string{"trigger1"}, + Frequency: backend.SubscriptionFrequencySubscriptionFrequencyDaily, + }, + createCfg: &mockCreateSavedSearchSubscriptionConfig{ + expectedRequest: gcpspanner.CreateSavedSearchSubscriptionRequest{ + UserID: userID, + ChannelID: channelID, + SavedSearchID: savedSearchID, + Triggers: []string{"trigger1"}, + Frequency: string(backend.SubscriptionFrequencySubscriptionFrequencyDaily), + }, + result: valuePtr(subID), + returnedError: nil, + }, + getCfg: &mockGetSavedSearchSubscriptionConfig{ + expectedSubscriptionID: subID, + expectedUserID: userID, + result: &gcpspanner.SavedSearchSubscription{ + ID: subID, + ChannelID: channelID, + SavedSearchID: savedSearchID, + Triggers: []string{"trigger1"}, + Frequency: string(backend.SubscriptionFrequencySubscriptionFrequencyDaily), + CreatedAt: now, + UpdatedAt: now, + }, + returnedError: nil, + }, + expected: &backend.SubscriptionResponse{ + Id: subID, + ChannelId: channelID, + SavedSearchId: savedSearchID, + Triggers: []string{"trigger1"}, + Frequency: backend.SubscriptionResponseFrequencySubscriptionFrequencyDaily, + CreatedAt: now, + UpdatedAt: now, + }, + expectedError: nil, + }, + { + name: "create error", + input: backend.Subscription{ + ChannelId: channelID, + SavedSearchId: savedSearchID, + Triggers: []string{"trigger1"}, + Frequency: backend.SubscriptionFrequencySubscriptionFrequencyDaily, + }, + createCfg: &mockCreateSavedSearchSubscriptionConfig{ + expectedRequest: gcpspanner.CreateSavedSearchSubscriptionRequest{ + UserID: userID, + ChannelID: channelID, + SavedSearchID: savedSearchID, + Triggers: []string{"trigger1"}, + Frequency: string(backend.SubscriptionFrequencySubscriptionFrequencyDaily), + }, + result: nil, + returnedError: errTest, + }, + getCfg: nil, + expected: nil, + expectedError: errTest, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + //nolint: exhaustruct + mock := mockBackendSpannerClient{ + t: t, + mockCreateSavedSearchSubscriptionCfg: tc.createCfg, + mockGetSavedSearchSubscriptionCfg: tc.getCfg, + } + b := NewBackend(mock) + resp, err := b.CreateSavedSearchSubscription(context.Background(), userID, tc.input) + if !errors.Is(err, tc.expectedError) { + t.Errorf("unexpected error. got %v, want %v", err, tc.expectedError) + } + if diff := cmp.Diff(tc.expected, resp); diff != "" { + t.Errorf("response mismatch (-want +got):\n%s", diff) + } + }) + } +} + +func TestListSavedSearchSubscriptions(t *testing.T) { + const ( + userID = "user123" + ) + now := time.Now() + + testCases := []struct { + name string + pageSize int + pageToken *string + cfg *mockListSavedSearchSubscriptionsConfig + expected *backend.SubscriptionPage + expectedError error + }{ + { + name: "success", + pageSize: 10, + pageToken: nil, + cfg: &mockListSavedSearchSubscriptionsConfig{ + expectedRequest: gcpspanner.ListSavedSearchSubscriptionsRequest{ + UserID: userID, + PageSize: 10, + PageToken: nil, + }, + result: []gcpspanner.SavedSearchSubscription{ + { + ID: "sub1", + ChannelID: "chan1", + SavedSearchID: "search1", + Triggers: []string{"t1"}, + Frequency: "daily", + CreatedAt: now, + UpdatedAt: now, + }, + }, + nextPageToken: nonNilNextPageToken, + returnedError: nil, + }, + expected: &backend.SubscriptionPage{ + Data: &[]backend.SubscriptionResponse{ + { + Id: "sub1", + ChannelId: "chan1", + SavedSearchId: "search1", + Triggers: []string{"t1"}, + Frequency: "daily", + CreatedAt: now, + UpdatedAt: now, + }, + }, + Metadata: &backend.PageMetadata{ + NextPageToken: nonNilNextPageToken, + }, + }, + expectedError: nil, + }, + { + name: "db error", + pageSize: 10, + pageToken: nil, + cfg: &mockListSavedSearchSubscriptionsConfig{ + expectedRequest: gcpspanner.ListSavedSearchSubscriptionsRequest{ + UserID: userID, + PageSize: 10, + PageToken: nil, + }, + nextPageToken: nil, + result: nil, + returnedError: errTest, + }, + expected: nil, + expectedError: errTest, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + //nolint: exhaustruct + mock := mockBackendSpannerClient{ + t: t, + mockListSavedSearchSubscriptionsCfg: tc.cfg, + } + b := NewBackend(mock) + resp, err := b.ListSavedSearchSubscriptions(context.Background(), userID, tc.pageSize, tc.pageToken) + if !errors.Is(err, tc.expectedError) { + t.Errorf("unexpected error. got %v, want %v", err, tc.expectedError) + } + if diff := cmp.Diff(tc.expected, resp); diff != "" { + t.Errorf("response mismatch (-want +got):\n%s", diff) + } + }) + } +} + +func TestGetSavedSearchSubscription(t *testing.T) { + const ( + userID = "user123" + subID = "sub456" + ) + now := time.Now() + + testCases := []struct { + name string + cfg *mockGetSavedSearchSubscriptionConfig + expected *backend.SubscriptionResponse + expectedError error + }{ + { + name: "success", + cfg: &mockGetSavedSearchSubscriptionConfig{ + expectedSubscriptionID: subID, + expectedUserID: userID, + result: &gcpspanner.SavedSearchSubscription{ + ID: subID, + ChannelID: "chan1", + SavedSearchID: "search1", + Triggers: []string{"t1"}, + Frequency: "daily", + CreatedAt: now, + UpdatedAt: now, + }, + returnedError: nil, + }, + expected: &backend.SubscriptionResponse{ + Id: subID, + ChannelId: "chan1", + SavedSearchId: "search1", + Triggers: []string{"t1"}, + Frequency: "daily", + CreatedAt: now, + UpdatedAt: now, + }, + expectedError: nil, + }, + { + name: "not found", + cfg: &mockGetSavedSearchSubscriptionConfig{ + expectedSubscriptionID: subID, + expectedUserID: userID, + result: nil, + returnedError: gcpspanner.ErrQueryReturnedNoResults, + }, + expected: nil, + expectedError: backendtypes.ErrEntityDoesNotExist, + }, + { + name: "not authorized", + cfg: &mockGetSavedSearchSubscriptionConfig{ + expectedSubscriptionID: subID, + expectedUserID: userID, + result: nil, + returnedError: gcpspanner.ErrMissingRequiredRole, + }, + expected: nil, + expectedError: backendtypes.ErrUserNotAuthorizedForAction, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + //nolint: exhaustruct + mock := mockBackendSpannerClient{ + t: t, + mockGetSavedSearchSubscriptionCfg: tc.cfg, + } + b := NewBackend(mock) + resp, err := b.GetSavedSearchSubscription(context.Background(), userID, subID) + if !errors.Is(err, tc.expectedError) { + t.Errorf("unexpected error. got %v, want %v", err, tc.expectedError) + } + if diff := cmp.Diff(tc.expected, resp); diff != "" { + t.Errorf("response mismatch (-want +got):\n%s", diff) + } + }) + } +} + +func TestUpdateSavedSearchSubscription(t *testing.T) { + const ( + userID = "user123" + subID = "sub456" + ) + now := time.Now() + updatedTriggers := []string{"new-trigger"} + updatedFrequency := backend.SubscriptionFrequencyDaily + + testCases := []struct { + name string + input backend.UpdateSubscriptionRequest + updateCfg *mockUpdateSavedSearchSubscriptionConfig + getCfg *mockGetSavedSearchSubscriptionConfig + expected *backend.SubscriptionResponse + expectedError error + }{ + { + name: "success update triggers", + input: backend.UpdateSubscriptionRequest{ + UpdateMask: []backend.UpdateSubscriptionRequestUpdateMask{ + backend.UpdateSubscriptionRequestMaskTriggers}, + Triggers: &updatedTriggers, + Frequency: nil, + }, + updateCfg: &mockUpdateSavedSearchSubscriptionConfig{ + expectedRequest: gcpspanner.UpdateSavedSearchSubscriptionRequest{ + ID: subID, + UserID: userID, + Triggers: gcpspanner.OptionallySet[[]string]{ + Value: updatedTriggers, IsSet: true, + }, + Frequency: gcpspanner.OptionallySet[string]{IsSet: false, Value: ""}, + }, + returnedError: nil, + }, + getCfg: &mockGetSavedSearchSubscriptionConfig{ + expectedSubscriptionID: subID, + expectedUserID: userID, + result: &gcpspanner.SavedSearchSubscription{ + ID: subID, + Triggers: updatedTriggers, + ChannelID: "channel", + SavedSearchID: "savedsearch", + Frequency: "daily", + CreatedAt: now, + UpdatedAt: now, + }, + returnedError: nil, + }, + expected: &backend.SubscriptionResponse{ + Id: subID, + Triggers: updatedTriggers, + ChannelId: "channel", + SavedSearchId: "savedsearch", + Frequency: "daily", + CreatedAt: now, + UpdatedAt: now, + }, + expectedError: nil, + }, + { + name: "success update frequency", + input: backend.UpdateSubscriptionRequest{ + UpdateMask: []backend.UpdateSubscriptionRequestUpdateMask{ + backend.UpdateSubscriptionRequestMaskFrequency}, + Frequency: &updatedFrequency, + Triggers: nil, + }, + updateCfg: &mockUpdateSavedSearchSubscriptionConfig{ + expectedRequest: gcpspanner.UpdateSavedSearchSubscriptionRequest{ + ID: subID, + UserID: userID, + Triggers: gcpspanner.OptionallySet[[]string]{IsSet: false, Value: nil}, + Frequency: gcpspanner.OptionallySet[string]{ + Value: string(updatedFrequency), IsSet: true, + }, + }, + returnedError: nil, + }, + getCfg: &mockGetSavedSearchSubscriptionConfig{ + expectedSubscriptionID: subID, + expectedUserID: userID, + result: &gcpspanner.SavedSearchSubscription{ + ID: subID, + ChannelID: "channel", + SavedSearchID: "savedsearchid", + Triggers: []string{"old"}, + Frequency: string(updatedFrequency), + CreatedAt: now, + UpdatedAt: now, + }, + returnedError: nil, + }, + expected: &backend.SubscriptionResponse{ + Id: subID, + ChannelId: "channel", + SavedSearchId: "savedsearchid", + Triggers: []string{"old"}, + Frequency: backend.SubscriptionResponseFrequency(updatedFrequency), + CreatedAt: now, + UpdatedAt: now, + }, + expectedError: nil, + }, + { + name: "not found", + input: backend.UpdateSubscriptionRequest{ + UpdateMask: []backend.UpdateSubscriptionRequestUpdateMask{ + backend.UpdateSubscriptionRequestMaskTriggers}, + Triggers: &updatedTriggers, + Frequency: nil, + }, + updateCfg: &mockUpdateSavedSearchSubscriptionConfig{ + expectedRequest: gcpspanner.UpdateSavedSearchSubscriptionRequest{ + ID: subID, + UserID: userID, + Triggers: gcpspanner.OptionallySet[[]string]{Value: updatedTriggers, IsSet: true}, + Frequency: gcpspanner.OptionallySet[string]{ + Value: "", + IsSet: false, + }, + }, + returnedError: gcpspanner.ErrQueryReturnedNoResults, + }, + getCfg: nil, + expected: nil, + expectedError: backendtypes.ErrEntityDoesNotExist, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + //nolint: exhaustruct + mock := mockBackendSpannerClient{ + t: t, + mockUpdateSavedSearchSubscriptionCfg: tc.updateCfg, + mockGetSavedSearchSubscriptionCfg: tc.getCfg, + } + b := NewBackend(mock) + resp, err := b.UpdateSavedSearchSubscription(context.Background(), userID, subID, tc.input) + if !errors.Is(err, tc.expectedError) { + t.Errorf("unexpected error. got %v, want %v", err, tc.expectedError) + } + if diff := cmp.Diff(tc.expected, resp); diff != "" { + t.Errorf("response mismatch (-want +got):\n%s", diff) + } + }) + } +} + +func TestDeleteSavedSearchSubscription(t *testing.T) { + const ( + userID = "user123" + subID = "sub456" + ) + + testCases := []struct { + name string + cfg *mockDeleteSavedSearchSubscriptionConfig + expectedError error + }{ + { + name: "success", + cfg: &mockDeleteSavedSearchSubscriptionConfig{ + expectedSubscriptionID: subID, + expectedUserID: userID, + returnedError: nil, + }, + expectedError: nil, + }, + { + name: "not found", + cfg: &mockDeleteSavedSearchSubscriptionConfig{ + expectedSubscriptionID: subID, + expectedUserID: userID, + returnedError: gcpspanner.ErrQueryReturnedNoResults, + }, + expectedError: backendtypes.ErrEntityDoesNotExist, + }, + { + name: "not authorized", + cfg: &mockDeleteSavedSearchSubscriptionConfig{ + expectedSubscriptionID: subID, + expectedUserID: userID, + returnedError: gcpspanner.ErrMissingRequiredRole, + }, + expectedError: backendtypes.ErrUserNotAuthorizedForAction, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + //nolint: exhaustruct + mock := mockBackendSpannerClient{ + t: t, + mockDeleteSavedSearchSubscriptionCfg: tc.cfg, + } + b := NewBackend(mock) + err := b.DeleteSavedSearchSubscription(context.Background(), userID, subID) + if !errors.Is(err, tc.expectedError) { + t.Errorf("unexpected error. got %v, want %v", err, tc.expectedError) + } + }) + } +} diff --git a/openapi/backend/openapi.yaml b/openapi/backend/openapi.yaml index e1a2913cb..1ef904d27 100644 --- a/openapi/backend/openapi.yaml +++ b/openapi/backend/openapi.yaml @@ -1402,6 +1402,131 @@ components: properties: role: $ref: '#/components/schemas/UserSavedSearchRole' + NotificationChannel: + type: object + properties: + type: + type: string + enum: + - email + name: + type: string + description: The name of the channel. + value: + type: string + description: The address for the channel, e.g., an email address. + required: + - type + - value + - name + NotificationChannelResponse: + allOf: + - $ref: '#/components/schemas/GenericUpdatableUniqueModel' + - $ref: '#/components/schemas/NotificationChannel' + - type: object + properties: + status: + type: string + enum: + - enabled + - disabled + x-enumNames: + - NotificationChannelStatusEnabled + - NotificationChannelStatusDisabled + required: + - status + NotificationChannelPage: + type: object + properties: + metadata: + $ref: '#/components/schemas/PageMetadata' + data: + type: array + items: + $ref: '#/components/schemas/NotificationChannelResponse' + UpdateNotificationChannelRequest: + type: object + properties: + name: + type: string + update_mask: + type: array + description: > + A list of fields to update. Required. Allowed values are: `name`, `value`. + items: + type: string + enum: + - name + x-enumNames: + - UpdateNotificationChannelRequestMaskName + minItems: 1 + uniqueItems: true + required: + - update_mask + Subscription: + type: object + properties: + saved_search_id: + type: string + channel_id: + type: string + triggers: + type: array + items: + type: string + frequency: + type: string + enum: + - daily + x-enumNames: + - SubscriptionFrequencyDaily + required: + - saved_search_id + - channel_id + - triggers + - frequency + SubscriptionResponse: + allOf: + - $ref: '#/components/schemas/GenericUpdatableUniqueModel' + - $ref: '#/components/schemas/Subscription' + SubscriptionPage: + type: object + properties: + metadata: + $ref: '#/components/schemas/PageMetadata' + data: + type: array + items: + $ref: '#/components/schemas/SubscriptionResponse' + UpdateSubscriptionRequest: + type: object + properties: + triggers: + type: array + items: + type: string + frequency: + type: string + enum: + - daily + x-enumNames: + - SubscriptionFrequencyDaily + update_mask: + type: array + description: > + A list of fields to update. Required. Allowed values are: `triggers`, `frequency`. + items: + type: string + enum: + - triggers + - frequency + x-enumNames: + - UpdateSubscriptionRequestMaskTriggers + - UpdateSubscriptionRequestMaskFrequency + minItems: 1 + uniqueItems: true + required: + - update_mask UserSavedSearchPage: type: object properties: