Skip to content

Commit f41d9ee

Browse files
committed
Fix error involving client-side webmention fetching, moved it to server-side
1 parent 5a4391d commit f41d9ee

4 files changed

Lines changed: 288 additions & 64 deletions

File tree

‎.cache/pages.json‎

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -22,10 +22,15 @@
2222
"contact",
2323
"offline",
2424
{
25-
"privacy": ["my-data"]
25+
"privacy": [
26+
"my-data"
27+
]
2628
},
2729
{
28-
"services": ["create-custom-font-sets", "overview"]
30+
"services": [
31+
"create-custom-font-sets",
32+
"overview"
33+
]
2934
},
3035
{
3136
"tags": [
@@ -40,4 +45,4 @@
4045
"typescript"
4146
]
4247
}
43-
]
48+
]

‎src/components/WebMentions/server/__tests__/index.spec.ts‎

Lines changed: 77 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -2,17 +2,17 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
22
import { TestError } from '@test/errors'
33
import type { Webmention, WebmentionResponse } from '@components/WebMentions/@types'
44
import fixture from '../__fixtures__/index.fixture.json'
5-
import {
6-
fetchWebmentions,
7-
isOwnWebmention,
8-
webmentionsByUrl,
9-
webmentionCountByType,
10-
} from '../index'
11-
12-
vi.mock('astro:env/client', () => ({
13-
WEBMENTION_IO_TOKEN: 'test-token',
5+
6+
const tokenState = vi.hoisted(() => ({ value: 'test-token' }))
7+
8+
vi.mock('astro:env/server', () => ({
9+
get WEBMENTION_IO_TOKEN() {
10+
return tokenState.value
11+
},
1412
}))
1513

14+
const importWebmentionsModule = async () => import('../index')
15+
1616
const fixtureResponse = fixture as WebmentionResponse
1717

1818
const createMockResponse = (
@@ -31,16 +31,20 @@ describe('fetchWebmentions', () => {
3131
let fetchMock: ReturnType<typeof vi.fn>
3232

3333
beforeEach(() => {
34+
vi.resetModules()
35+
tokenState.value = 'test-token'
3436
fetchMock = vi.fn()
3537
vi.stubGlobal('fetch', fetchMock)
3638
})
3739

3840
afterEach(() => {
3941
vi.unstubAllGlobals()
42+
vi.clearAllMocks()
4043
})
4144

4245
it('fetches, filters, and sanitizes webmentions from the API', async () => {
4346
fetchMock.mockResolvedValue(createMockResponse())
47+
const { fetchWebmentions } = await importWebmentionsModule()
4448

4549
const results = await fetchWebmentions(targetUrl)
4650

@@ -56,7 +60,8 @@ describe('fetchWebmentions', () => {
5660
expect(parsedUrl.searchParams.get('target')).toBe(targetUrl)
5761
expect(parsedUrl.searchParams.get('token')).toBe('test-token')
5862
expect(parsedUrl.searchParams.get('per-page')).toBe('1000')
59-
expect(init).toEqual({ headers: { 'Cache-Control': 'max-age=300' } })
63+
expect(init).toMatchObject({ headers: { 'Cache-Control': 'max-age=300' } })
64+
expect(init && 'signal' in (init as Record<string, unknown>)).toBe(true)
6065

6166
expect(results).toHaveLength(3)
6267
const [firstResult, secondResult, thirdResult] = results
@@ -76,6 +81,7 @@ describe('fetchWebmentions', () => {
7681
fetchMock.mockResolvedValue(
7782
createMockResponse({ ok: false, status: 500, statusText: 'Server Error', json: async () => ({}) }),
7883
)
84+
const { fetchWebmentions } = await importWebmentionsModule()
7985

8086
const results = await fetchWebmentions(targetUrl)
8187

@@ -87,16 +93,71 @@ describe('fetchWebmentions', () => {
8793
it('returns an empty array when the fetch call fails', async () => {
8894
const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
8995
fetchMock.mockRejectedValue(new TestError('network down'))
96+
const { fetchWebmentions } = await importWebmentionsModule()
9097

9198
const results = await fetchWebmentions(targetUrl)
9299

93100
expect(results).toEqual([])
94101
expect(errorSpy).toHaveBeenCalled()
95102
errorSpy.mockRestore()
96103
})
104+
105+
it('deduplicates concurrent requests for the same URL', async () => {
106+
let resolveFetch: ((response: Response) => void) | undefined
107+
fetchMock.mockImplementation(
108+
() => new Promise<Response>((resolve) => {
109+
resolveFetch = resolve
110+
}),
111+
)
112+
113+
const { fetchWebmentions } = await importWebmentionsModule()
114+
const firstCall = fetchWebmentions(targetUrl)
115+
const secondCall = fetchWebmentions(targetUrl)
116+
117+
expect(fetchMock).toHaveBeenCalledTimes(1)
118+
resolveFetch?.(createMockResponse())
119+
120+
const [firstResult, secondResult] = await Promise.all([firstCall, secondCall])
121+
expect(firstResult).toHaveLength(3)
122+
expect(secondResult).toHaveLength(3)
123+
})
124+
125+
it('skips repeated fetch attempts during the failure cooldown window', async () => {
126+
const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
127+
fetchMock.mockRejectedValue(new TestError('connect timeout'))
128+
const { fetchWebmentions } = await importWebmentionsModule()
129+
130+
const firstAttempt = await fetchWebmentions(targetUrl)
131+
expect(firstAttempt).toEqual([])
132+
expect(fetchMock).toHaveBeenCalledTimes(1)
133+
134+
fetchMock.mockClear()
135+
const secondAttempt = await fetchWebmentions(targetUrl)
136+
expect(secondAttempt).toEqual([])
137+
expect(fetchMock).not.toHaveBeenCalled()
138+
errorSpy.mockRestore()
139+
})
140+
141+
it('logs a warning once and skips fetches when the token is missing or placeholder', async () => {
142+
tokenState.value = 'updateme'
143+
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
144+
const { fetchWebmentions } = await importWebmentionsModule()
145+
146+
const results = await fetchWebmentions(targetUrl)
147+
148+
expect(results).toEqual([])
149+
expect(fetchMock).not.toHaveBeenCalled()
150+
expect(warnSpy).toHaveBeenCalledTimes(1)
151+
warnSpy.mockRestore()
152+
})
97153
})
98154

99155
describe('Webmention helpers', () => {
156+
beforeEach(() => {
157+
vi.resetModules()
158+
tokenState.value = 'test-token'
159+
})
160+
100161
const helperWebmentions: Webmention[] = [
101162
{
102163
'wm-id': 'helper-1',
@@ -121,7 +182,8 @@ describe('Webmention helpers', () => {
121182
},
122183
]
123184

124-
it('detects when a webmention originates from the configured domain', () => {
185+
it('detects when a webmention originates from the configured domain', async () => {
186+
const { isOwnWebmention } = await importWebmentionsModule()
125187
const [first, second, third] = helperWebmentions
126188
if (!first || !second || !third) {
127189
throw new TestError('helper webmentions fixture must include three entries')
@@ -132,14 +194,16 @@ describe('Webmention helpers', () => {
132194
expect(isOwnWebmention(third, ['https://elsewhere.example.com'])).toBe(true)
133195
})
134196

135-
it('filters webmentions by target URL', () => {
197+
it('filters webmentions by target URL', async () => {
198+
const { webmentionsByUrl } = await importWebmentionsModule()
136199
const filtered = webmentionsByUrl(helperWebmentions, 'https://example.com/a')
137200

138201
expect(filtered).toHaveLength(2)
139202
expect(filtered.every((entry) => entry['wm-target'] === 'https://example.com/a')).toBe(true)
140203
})
141204

142-
it('counts webmentions that match specific interaction types', () => {
205+
it('counts webmentions that match specific interaction types', async () => {
206+
const { webmentionCountByType } = await importWebmentionsModule()
143207
const count = webmentionCountByType(helperWebmentions, 'https://example.com/a', 'mention-of', 'like-of')
144208

145209
expect(count).toBe(2)

0 commit comments

Comments
 (0)