Skip to content

Commit d599eaa

Browse files
authored
feat: Add UI support for RSS notification channels (#2441)
* feat(spanner): support intent-based RSS in storage layer and protect system channels This change implements the storage layer requirements for the Intent-Based RSS notification architecture. - Expose ChannelType in Subscriptions: Updated SavedSearchSubscriptionView to include ChannelType by joining with NotificationChannels in SelectOne and SelectList queries. This allows the API to return the channel type (e.g., 'rss') in subscription responses without extra lookups. - Protect System Channels: Updated Get, Update, and Delete NotificationChannel methods to enforce that system-managed channels (like RSS) cannot be accessed or modified directly by users. - Allow System Channels for Subscriptions: Updated createSavedSearchSubscription to allow linking to system channels during ownership checks. - Fixed tests and lints: Resolved UUID type mismatches in backend_test.go and added ChannelType to struct literals to satisfy the exhaustruct linter. * feat(api): implement XOR validation and enforce privacy for intent-based RSS This change implements the server layer requirements for the Intent-Based RSS notification architecture. - XOR Validation in CreateSubscription: Enforced that exactly one of 'channel_id' (Explicit Mode) or 'channel_type' (Implicit Mode) must be provided in CreateSubscription requests. - Hiding RSS Channels: Ensured that system-managed RSS channels are not exposed in list responses or directly accessible via CRUD operations (returning 404). - Hardening Create Notification Channel: Updated handler to explicitly reject manual creation of 'rss' channels, forcing users to use the intent-based subscription API. - Updated tests to reflect the new behavior and verify validation rules. * feat: Add UI support for RSS notification channels + tests Move from manual RSS channel management to implicit when subscribing.
1 parent 1e3f10c commit d599eaa

16 files changed

Lines changed: 405 additions & 57 deletions

e2e/tests/notification-channels.spec.ts

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -60,7 +60,6 @@ test.describe('Notification Channels Page', () => {
6060
// Verify RSS panel content.
6161
const rssPanel = page.locator('webstatus-notification-rss-channels');
6262
await expect(rssPanel).toBeVisible();
63-
await expect(rssPanel).toContainText('Coming soon');
6463

6564
// Verify Webhook panel content.
6665
const webhookPanel = page.locator(
1.42 KB
Loading
1.4 KB
Loading
1.96 KB
Loading
1.92 KB
Loading
1.79 KB
Loading
2.09 KB
Loading

frontend/src/static/js/api/client.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -194,6 +194,8 @@ export const ALL_CHANNELS: ChannelsParameter[] = [
194194
EXPERIMENTAL_CHANNEL,
195195
];
196196

197+
export const CHANNEL_TYPE_RSS = 'rss';
198+
197199
export const TEST_COUNT_METRIC_VIEW: components['schemas']['WPTMetricView'] =
198200
'test_counts';
199201
export const SUBTEST_COUNT_METRIC_VIEW: components['schemas']['WPTMetricView'] =
@@ -295,13 +297,19 @@ function base64urlEncode(str: string): string {
295297

296298
export class APIClient {
297299
private readonly client: ReturnType<typeof createClient<paths>>;
300+
private readonly baseUrl: string;
298301
constructor(baseUrl: string) {
302+
this.baseUrl = baseUrl;
299303
this.client = createClient<paths>({
300304
baseUrl,
301305
...fetchOptions,
302306
});
303307
}
304308

309+
public getBaseUrl(): string {
310+
return this.baseUrl;
311+
}
312+
305313
// Internal client detail for constructing a FeatureResultOffsetCursor pagination token.
306314
// Typically, users of the /v1/features endpoint should use the provided pagination token.
307315
// However, this token can be used to facilitate a UI where we have selectable page numbers.

frontend/src/static/js/components/test/webstatus-manage-notification-channel-dialog.test.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,10 @@ import '@shoelace-style/shoelace/dist/components/button/button.js';
2828
// Mock used to isolate nested form rendering and avoid needing to cast to access the component field.
2929
class MockConfigForm extends HTMLElement {
3030
validate = () => true;
31+
getUpdate = () => ({
32+
updates: {name: 'Updated name'},
33+
mask: ['name'],
34+
});
3135
}
3236

3337
if (!customElements.get('mock-config-form')) {

frontend/src/static/js/components/test/webstatus-manage-subscriptions-dialog.test.ts

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -316,6 +316,57 @@ describe('webstatus-manage-subscriptions-dialog', () => {
316316
}
317317
});
318318

319+
it('sets actionState correctly on successful create for RSS', async () => {
320+
const eventSpy = sandbox.spy();
321+
element.addEventListener('subscription-save-success', eventSpy);
322+
323+
// Make it dirty for creation.
324+
element.savedSearchId = 'new-saved-search-id';
325+
element['_activeChannelId'] = 'rss';
326+
element['_selectedFrequency'] = 'monthly';
327+
element['_selectedTriggers'] = ['feature_baseline_to_newly'];
328+
element['_initialSelectedFrequency'] = 'immediate';
329+
element['_initialSelectedTriggers'] = [];
330+
await element.updateComplete;
331+
expect(element.isDirty).to.be.true;
332+
333+
// Use a promise that we can resolve manually to check intermediate state.
334+
const savePromise = new Promise(resolve => {
335+
(apiClient.createSubscription as sinon.SinonStub).callsFake(() => {
336+
resolve(mockInitialSubscription);
337+
return Promise.resolve(mockInitialSubscription);
338+
});
339+
});
340+
341+
const saveOperation = element['_handleSave']();
342+
await element.updateComplete;
343+
344+
// Check intermediate 'saving' state and loading property on button.
345+
expect(element['_actionState'].phase).to.equal('saving');
346+
const saveButton = element.shadowRoot?.querySelector<SlButton>(
347+
'sl-button[variant="primary"]',
348+
);
349+
expect(saveButton!.loading).to.be.true;
350+
351+
await savePromise;
352+
await saveOperation;
353+
await element.updateComplete;
354+
355+
expect(apiClient.createSubscription).to.have.been.calledWith('test-token', {
356+
saved_search_id: 'new-saved-search-id',
357+
channel_type: 'rss',
358+
frequency: 'monthly',
359+
triggers: ['feature_baseline_to_newly'],
360+
});
361+
expect(eventSpy).to.have.been.calledOnce;
362+
363+
// Check final 'success' state.
364+
expect(element['_actionState'].phase).to.equal('success');
365+
if (element['_actionState'].phase === 'success') {
366+
expect(element['_actionState'].message).to.equal('Subscription saved.');
367+
}
368+
});
369+
319370
it('sets actionState correctly on successful update', async () => {
320371
const eventSpy = sandbox.spy();
321372
element.addEventListener('subscription-save-success', eventSpy);

0 commit comments

Comments
 (0)