Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
39 changes: 38 additions & 1 deletion e2e/tests/feature-page.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@
*/

import {test, expect} from '@playwright/test';
import {setupFakeNow} from './utils';
import {setupFakeNow, resetUserData, loginAsUser} from './utils';

test.beforeEach(async ({page}) => {
await setupFakeNow(page);
Expand Down Expand Up @@ -217,3 +217,40 @@ test('shows gone page for a split feature', async ({page}) => {
const pageContainer = page.locator('.container'); // Assuming a generic container for the error page.
await expect(pageContainer).toHaveScreenshot();
});

test.describe('Subscriptions', () => {
test.beforeAll(async () => {
await resetUserData();
});
test.afterAll(async () => {
await resetUserData();
});
test('Logged-in user can subscribe to updates', async ({page}) => {
await loginAsUser(page, 'test user 1');

await page.goto(`http://localhost:5555/features/${featureID}`);
await page.getByRole('button', {name: 'Subscribe'}).click();
const dialog = page.locator('webstatus-manage-subscriptions-dialog');
await expect(
dialog.getByRole('heading', {name: 'Manage notifications'}),
).toBeVisible();

await dialog.getByText('test.user.1@example.com').click();

await dialog
.locator('sl-checkbox')
.filter({hasText: '...becomes widely available'})
.locator('label')
.click();

const createButton = dialog.getByRole('button', {
name: 'Create Subscription',
});
await expect(createButton).toBeVisible();
await createButton.click();

await expect(
page.locator('sl-alert', {hasText: 'Subscription saved!'}),
).toBeVisible();
});
});
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
6 changes: 6 additions & 0 deletions frontend/src/static/js/components/webstatus-feature-page.ts
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,7 @@ import {BaseChartsPage} from './webstatus-base-charts-page.js';

import './webstatus-feature-wpt-progress-chart-panel.js';
import './webstatus-feature-usage-chart-panel.js';
import './webstatus-subscribe-button.js';
import {DataFetchedEvent} from './webstatus-line-chart-panel.js';
import {
FeatureGoneSplitError,
Expand Down Expand Up @@ -764,6 +765,11 @@ export class FeaturePage extends BaseChartsPage {
)}
${this.renderOffsiteLink('MDN', null)}
${this.renderOffsiteLink('CanIUse', canIUseLink)}
<webstatus-subscribe-button
.savedSearchId=${this.feature?.system_managed_saved_search_id ??
''}
>
</webstatus-subscribe-button>
</div>
</div>

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -384,8 +384,9 @@ export class ManageSubscriptionsDialog extends LitElement {
label="Manage notifications"
class="dialog-main"
style="--width: min(90vw, 991px);"
?open=${this.open}
.open=${this.open}
@sl-request-close=${this._handleRequestClose}
@sl-hide=${() => this.dispatchEvent(new SubscriptionDialogCloseEvent())}
>
${this._loadingTask.render({
pending: () => html`<sl-spinner></sl-spinner>`,
Expand All @@ -397,7 +398,7 @@ export class ManageSubscriptionsDialog extends LitElement {
<sl-dialog
label="Unsaved Changes"
class="dialog-confirm"
?open=${this._isConfirmDialogOpen}
.open=${this._isConfirmDialogOpen}
@sl-hide=${this._onConfirmDialogHide}
>
<p>You have unsaved changes. Do you want to discard them?</p>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -60,7 +60,7 @@ export class SubscribeButton extends LitElement {
</sl-button>

<webstatus-manage-subscriptions-dialog
?open=${this._isSubscriptionDialogOpen}
.open=${this._isSubscriptionDialogOpen}
.savedSearchId=${this.savedSearchId}
@subscription-dialog-close=${() =>
(this._isSubscriptionDialogOpen = false)}
Expand Down
26 changes: 26 additions & 0 deletions lib/gcpspanner/delete_user_saved_search_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -95,4 +95,30 @@ func TestDeleteUserSavedSearch(t *testing.T) {
t.Errorf("expected ErrQueryReturnedNoResults. received %s", err)
}
})

t.Run("SYSTEM_MANAGED search cannot be deleted", func(t *testing.T) {
// 1. Create a system managed search directly in the DB.
systemSearchID := "system-search"
_, err := spannerClient.Apply(ctx, []*spanner.Mutation{
spanner.Insert("SavedSearches",
[]string{"ID", "Name", "Query", "Scope", "AuthorID", "CreatedAt", "UpdatedAt"},
[]interface{}{
systemSearchID, "System Search", "id:foo", "SYSTEM_MANAGED",
"system", spanner.CommitTimestamp, spanner.CommitTimestamp}),
})
if err != nil {
t.Fatalf("failed to create system search: %v", err)
}

// 2. Attempt to delete it via the user API.
err = spannerClient.DeleteUserSavedSearch(ctx, DeleteUserSavedSearchRequest{
RequestingUserID: "userID1",
SavedSearchID: systemSearchID,
})

// 3. Verify it fails with 'not found' because the mapper filters it out.
if !errors.Is(err, ErrQueryReturnedNoResults) {
t.Errorf("expected ErrQueryReturnedNoResults, got %v", err)
}
})
}
73 changes: 57 additions & 16 deletions lib/gcpspanner/get_user_saved_search.go
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ package gcpspanner

import (
"context"
"errors"
"fmt"

"cloud.google.com/go/spanner"
Expand Down Expand Up @@ -103,29 +104,69 @@ func (m authenticatedUserSavedSearchMapper) SelectOne(
return stmt
}

// readSavedSearchMapper provides a way to read any SavedSearch by its ID, regardless of scope.
type readSavedSearchMapper struct{}

func (m readSavedSearchMapper) SelectOne(id string) spanner.Statement {
stmt := spanner.NewStatement(fmt.Sprintf(`
SELECT
ID, Name, Description, Query, Scope, AuthorID, CreatedAt, UpdatedAt
FROM %s
WHERE ID = @id
LIMIT 1`,
savedSearchesTable))
stmt.Params["id"] = id

return stmt
}

// GetUserSavedSearch returns a single user saved search by its id.
// If the user is authenticated, it will also return their role and bookmark status.
func (c *Client) GetUserSavedSearch(
ctx context.Context,
savedSearchID string,
authenticatedUserID *string) (*UserSavedSearch, error) {

// Use a single read-only transaction for all operations.
txn := c.ReadOnlyTransaction()
defer txn.Close()

// 1. Fetch the base SavedSearch using the new generic mapper.
savedSearch, err := newEntityReader[readSavedSearchMapper, SavedSearch, string](c).
readRowByKeyWithTransaction(ctx, savedSearchID, txn)
if err != nil {
return nil, err
}

// 2. If the user is unauthenticated, they can only see public or system searches.
if authenticatedUserID == nil {
// For an unauthenticated user, we only read the SavedSearches row then fill in the rest of
// UserSavedSearch struct with nil values.
row, err := newEntityReader[unauthenticatedUserSavedSearchMapper, SavedSearch, string](c).
readRowByKey(ctx, savedSearchID)
if err != nil {
return nil, err
if savedSearch.Scope != UserPublicScope && savedSearch.Scope != SystemManagedScope {
return nil, ErrQueryReturnedNoResults
}

return &UserSavedSearch{SavedSearch: *savedSearch, Role: nil, IsBookmarked: nil}, nil
}

// 3. For an authenticated user, fetch their specific role and bookmark status
// using the original restricted mapper within the SAME transaction.
userSpecifics, err := newEntityReader[
authenticatedUserSavedSearchMapper,
UserSavedSearch,
authenticatedUserSavedSearchMapperKey,
](c).readRowByKeyWithTransaction(ctx, authenticatedUserSavedSearchMapperKey{
UserID: *authenticatedUserID,
ID: savedSearchID,
}, txn)

// If there are no user-specific details (e.g., for a SYSTEM_MANAGED search),
// that's okay. We just return the base search info.
if err != nil {
if errors.Is(err, ErrQueryReturnedNoResults) {
return &UserSavedSearch{SavedSearch: *savedSearch, Role: nil, IsBookmarked: nil}, nil
}

return &UserSavedSearch{
SavedSearch: *row,
IsBookmarked: nil,
Role: nil,
}, nil
return nil, err
}

return newEntityReader[authenticatedUserSavedSearchMapper, UserSavedSearch, authenticatedUserSavedSearchMapperKey](c).
readRowByKey(ctx, authenticatedUserSavedSearchMapperKey{
UserID: *authenticatedUserID,
ID: savedSearchID,
})
return userSpecifics, nil
}
26 changes: 26 additions & 0 deletions lib/gcpspanner/user_search_bookmarks_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -186,4 +186,30 @@ func TestUserSearchBookmark(t *testing.T) {
t.Errorf("expected ErrOwnerCannotDeleteBookmark error. received %s", err)
}
})

t.Run("the test user cannot bookmark a SYSTEM_MANAGED search", func(t *testing.T) {
// 1. Create a system managed search directly in the DB.
systemSearchID := "system-managed-search-id"
_, err := spannerClient.Apply(ctx, []*spanner.Mutation{
spanner.Insert("SavedSearches",
[]string{"ID", "Name", "Query", "Scope", "AuthorID", "CreatedAt", "UpdatedAt"},
[]interface{}{
systemSearchID, "System Search", "id:feat", "SYSTEM_MANAGED",
"system", spanner.CommitTimestamp, spanner.CommitTimestamp}),
})
if err != nil {
t.Fatalf("failed to create system search: %v", err)
}

// 2. Attempt to bookmark it.
err = spannerClient.AddUserSearchBookmark(ctx, UserSavedSearchBookmark{
UserID: testUser,
SavedSearchID: systemSearchID,
})

// 3. Verify it fails with 'not found' because the mapper filters it out.
if !errors.Is(err, ErrQueryReturnedNoResults) {
t.Errorf("expected ErrQueryReturnedNoResults, got %v", err)
}
})
}
6 changes: 6 additions & 0 deletions util/cmd/load_fake_data/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -1008,6 +1008,12 @@ func generateData(ctx context.Context, spannerClient *gcpspanner.Client, datasto
}
slog.InfoContext(ctx, "feature evolution generation complete")

err = spannerClient.SyncSystemManagedSavedQuery(ctx)
if err != nil {
return fmt.Errorf("system managed saved query sync failed %w", err)
}
slog.InfoContext(ctx, "system managed saved query sync complete")

return nil
}

Expand Down
Loading