Skip to content

Commit 9d7557a

Browse files
fix(rss): deliver unseen items published before the last poll (#7792)
1 parent 29c604d commit 9d7557a

2 files changed

Lines changed: 136 additions & 6 deletions

File tree

Lines changed: 128 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,128 @@
1+
/**
2+
* @vitest-environment node
3+
*/
4+
import { createLogger } from '@sim/logger'
5+
import { createWorkflowRecord } from '@sim/testing'
6+
import { beforeEach, describe, expect, it, vi } from 'vitest'
7+
8+
const { mockFetch, mockValidateUrl, mockProcessEvent, mockUpdateConfig } = vi.hoisted(() => ({
9+
mockFetch: vi.fn(),
10+
mockValidateUrl: vi.fn(),
11+
mockProcessEvent: vi.fn(),
12+
mockUpdateConfig: vi.fn(),
13+
}))
14+
15+
vi.mock('@/lib/core/security/input-validation.server', () => ({
16+
secureFetchWithPinnedIP: mockFetch,
17+
validateUrlWithDNS: mockValidateUrl,
18+
}))
19+
20+
vi.mock('@/lib/core/idempotency/service', () => ({
21+
pollingIdempotency: {
22+
executeWithIdempotency: vi.fn(
23+
async (_provider: string, _key: string, execute: () => Promise<unknown>) => execute()
24+
),
25+
},
26+
}))
27+
28+
vi.mock('@/lib/webhooks/processor', () => ({
29+
processPolledWebhookEvent: mockProcessEvent,
30+
}))
31+
32+
vi.mock('@/lib/webhooks/polling/utils', () => ({
33+
markWebhookSuccess: vi.fn(),
34+
markWebhookFailed: vi.fn(),
35+
updateWebhookProviderConfig: mockUpdateConfig,
36+
}))
37+
38+
import { rssPollingHandler } from '@/lib/webhooks/polling/rss'
39+
import type { PollWebhookContext, WebhookRecord } from '@/lib/webhooks/polling/types'
40+
41+
const SUBSCRIBED_AT = new Date('2026-08-27T18:36:16.000Z')
42+
const LAST_CHECKED_AT = '2026-09-11T23:26:27.000Z'
43+
const GUID = 'https://example.com/news/late-item'
44+
45+
function context(lastSeenGuids: string[] = []): PollWebhookContext {
46+
const webhookData: WebhookRecord = {
47+
id: 'rss-webhook',
48+
workflowId: 'rss-listener',
49+
deploymentVersionId: null,
50+
registrationStatus: null,
51+
registrationGeneration: null,
52+
configFingerprint: null,
53+
preparedAt: null,
54+
blockId: null,
55+
path: 'rss-listener',
56+
routingKey: null,
57+
provider: 'rss',
58+
providerConfig: {
59+
feedUrl: 'https://example.com/feed.xml',
60+
lastCheckedTimestamp: LAST_CHECKED_AT,
61+
lastSeenGuids,
62+
},
63+
isActive: true,
64+
failedCount: 0,
65+
lastFailedAt: null,
66+
archivedAt: null,
67+
createdAt: SUBSCRIBED_AT,
68+
updatedAt: new Date(LAST_CHECKED_AT),
69+
}
70+
return {
71+
webhookData,
72+
workflowData: createWorkflowRecord({
73+
id: 'rss-listener',
74+
}) as PollWebhookContext['workflowData'],
75+
requestId: 'rss-request',
76+
logger: createLogger('RssTest'),
77+
}
78+
}
79+
80+
function feed(pubDate: string) {
81+
return new Response(
82+
`<?xml version="1.0"?><rss version="2.0"><channel>
83+
<title>Canary fixture</title><link>https://example.com</link><description>RSS fixture</description>
84+
<item><title>Late item</title><guid>${GUID}</guid><pubDate>${pubDate}</pubDate></item>
85+
</channel></rss>`,
86+
{ headers: { 'Content-Type': 'application/rss+xml' } }
87+
)
88+
}
89+
90+
describe('RSS delivery across delayed feed updates', () => {
91+
beforeEach(() => {
92+
vi.clearAllMocks()
93+
mockValidateUrl.mockResolvedValue({ isValid: true, resolvedIP: '203.0.113.1' })
94+
mockProcessEvent.mockResolvedValue({ success: true })
95+
mockUpdateConfig.mockResolvedValue(undefined)
96+
})
97+
98+
it('delivers an unseen item published before the last poll but after subscription', async () => {
99+
mockFetch.mockResolvedValue(feed('Fri, 11 Sep 2026 21:25:32 GMT'))
100+
101+
expect(await rssPollingHandler.pollWebhook(context())).toBe('success')
102+
expect(mockProcessEvent).toHaveBeenCalledExactlyOnceWith(
103+
expect.anything(),
104+
expect.anything(),
105+
expect.objectContaining({ item: expect.objectContaining({ guid: GUID }) }),
106+
'rss-request'
107+
)
108+
expect(mockUpdateConfig).toHaveBeenCalledWith(
109+
'rss-webhook',
110+
expect.objectContaining({ lastSeenGuids: [GUID] }),
111+
expect.anything()
112+
)
113+
})
114+
115+
it('does not redeliver a known GUID when its publication date changes', async () => {
116+
mockFetch.mockResolvedValue(feed('Fri, 11 Sep 2026 23:28:00 GMT'))
117+
118+
expect(await rssPollingHandler.pollWebhook(context([GUID]))).toBe('success')
119+
expect(mockProcessEvent).not.toHaveBeenCalled()
120+
})
121+
122+
it('does not backfill items published before the subscription existed', async () => {
123+
mockFetch.mockResolvedValue(feed('Thu, 27 Aug 2026 18:30:00 GMT'))
124+
125+
expect(await rssPollingHandler.pollWebhook(context())).toBe('success')
126+
expect(mockProcessEvent).not.toHaveBeenCalled()
127+
})
128+
})

apps/sim/lib/webhooks/polling/rss.ts

Lines changed: 8 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -109,7 +109,7 @@ export const rssPollingHandler: PollingProviderHandler = {
109109
items: newItems,
110110
etag,
111111
lastModified,
112-
} = await fetchNewRssItems(config, requestId, logger)
112+
} = await fetchNewRssItems(config, webhookData.createdAt, requestId, logger)
113113

114114
if (!newItems.length) {
115115
await updateRssState(webhookId, now.toISOString(), [], config, logger, etag, lastModified)
@@ -195,6 +195,7 @@ async function updateRssState(
195195

196196
async function fetchNewRssItems(
197197
config: RssWebhookConfig,
198+
subscriptionStartedAt: Date,
198199
requestId: string,
199200
logger: Logger
200201
): Promise<{ feed: RssFeed; items: RssItem[]; etag?: string; lastModified?: string }> {
@@ -248,9 +249,6 @@ async function fetchNewRssItems(
248249
return { feed: feed as RssFeed, items: [], etag: newEtag, lastModified: newLastModified }
249250
}
250251

251-
const lastCheckedTime = config.lastCheckedTimestamp
252-
? new Date(config.lastCheckedTimestamp)
253-
: null
254252
const lastSeenGuids = new Set(config.lastSeenGuids || [])
255253

256254
const newItems = feed.items.filter((item) => {
@@ -263,9 +261,13 @@ async function fetchNewRssItems(
263261
return false
264262
}
265263

266-
if (lastCheckedTime && item.isoDate) {
264+
/**
265+
* A cached feed can reveal an item after its publication time. Only the fixed
266+
* subscription boundary excludes history; the last poll time is not a delivery cursor.
267+
*/
268+
if (item.isoDate) {
267269
const itemDate = new Date(item.isoDate)
268-
if (itemDate <= lastCheckedTime) {
270+
if (itemDate <= subscriptionStartedAt) {
269271
return false
270272
}
271273
}

0 commit comments

Comments
 (0)