Skip to content

Commit 05ecb1f

Browse files
committed
fix(hocuspocus): unblock typecheck on link-metadata tests + html scrape
Four CI typecheck errors after the link-metadata module landed: - htmlScrape.ts: Bun's TextDecoder ctor types the encoding as a closed union, but the value is parsed from arbitrary HTTP / HTML headers and cannot be narrowed statically. Cast to never (matches the receiver's "trust nothing" stance) — invalid encodings throw and the existing catch falls back to utf-8. - http.test.ts: Response.json() returns unknown under bun:test. Added a local WireBody type + readBody helper so every assertion reads through a typed boundary instead of `as any`. Kept WireBody independent of the production discriminated union — intersecting MetadataResponse & ErrorResponse collapses to never on success. - htmlScrape.test.ts: dropped the as HeadersInit cast (the type is not in the bun-types lib set, and Headers ctor accepts RequestInit['headers'] directly). - oembed.test.ts: typed the mockImplementation params as (_url: unknown, init?: RequestInit) so the spy honors the fetch signature instead of falling back to implicit any. Made-with: Cursor
1 parent 1663ba9 commit 05ecb1f

4 files changed

Lines changed: 35 additions & 14 deletions

File tree

packages/hocuspocus.server/src/modules/link-metadata/__tests__/integration/http.test.ts

Lines changed: 26 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,23 @@ import pino from 'pino'
44

55
import { init } from '../../module'
66

7+
// Wire shape that tests assert against. Kept independent of the production
8+
// types because the discriminated union (success: true vs false) collapses
9+
// to `never` when intersected, and toBe() narrows expected values from the
10+
// receiver's type. `success: boolean` here is intentional.
11+
interface WireBody {
12+
success?: boolean
13+
message?: string
14+
code?: string
15+
title?: string
16+
publisher?: { name?: string }
17+
cached?: boolean
18+
fetched_at?: string
19+
oembed?: { html?: string }
20+
}
21+
const readBody = async (response: Response): Promise<WireBody> =>
22+
(await response.json()) as WireBody
23+
724
const silentLogger = pino({ level: 'silent' })
825

926
const makeMockRedis = () => {
@@ -40,7 +57,7 @@ describe('GET /api/metadata (integration)', () => {
4057
const app = buildApp()
4158
const response = await app.request('http://localhost/api/metadata')
4259
expect(response.status).toBe(400)
43-
const body = await response.json()
60+
const body = await readBody(response)
4461
expect(body.success).toBe(false)
4562
expect(body.code).toBe('INVALID_URL')
4663
expect(typeof body.message).toBe('string')
@@ -50,7 +67,7 @@ describe('GET /api/metadata (integration)', () => {
5067
const app = buildApp()
5168
const response = await app.request('http://localhost/api/metadata?url=not-a-url')
5269
expect(response.status).toBe(400)
53-
const body = await response.json()
70+
const body = await readBody(response)
5471
expect(body.success).toBe(false)
5572
expect(body.code).toBe('INVALID_URL')
5673
})
@@ -73,7 +90,7 @@ describe('GET /api/metadata (integration)', () => {
7390
'http://localhost/api/metadata?url=' +
7491
encodeURIComponent('https://www.youtube.com/watch?v=xss')
7592
)
76-
const body = await response.json()
93+
const body = (await response.json()) as { oembed?: { html?: string } }
7794
expect(body.oembed?.html).toBeUndefined()
7895
})
7996

@@ -83,7 +100,7 @@ describe('GET /api/metadata (integration)', () => {
83100
'http://localhost/api/metadata?url=' + encodeURIComponent('http://192.168.1.1')
84101
)
85102
expect(response.status).toBe(400)
86-
const body = await response.json()
103+
const body = await readBody(response)
87104
expect(body.code).toBe('BLOCKED_URL')
88105
})
89106

@@ -109,10 +126,10 @@ describe('GET /api/metadata (integration)', () => {
109126
expect(response.status).toBe(200)
110127
expect(response.headers.get('cache-control')).toContain('max-age=3600')
111128
expect(response.headers.get('vary')).toBe('Accept-Language')
112-
const body = await response.json()
129+
const body = await readBody(response)
113130
expect(body.success).toBe(true)
114131
expect(body.title).toBe('Test')
115-
expect(body.publisher.name).toBe('YouTube')
132+
expect(body.publisher?.name).toBe('YouTube')
116133
expect(body.cached).toBe(false)
117134
expect(body.fetched_at).toBeDefined()
118135
})
@@ -127,7 +144,7 @@ describe('GET /api/metadata (integration)', () => {
127144

128145
expect(response.status).toBe(200)
129146
expect(response.headers.get('cache-control')).toContain('max-age=600')
130-
const body = await response.json()
147+
const body = await readBody(response)
131148
expect(body.success).toBe(true)
132149
expect(body.title).toBe('random-blog.example.com')
133150
})
@@ -146,10 +163,10 @@ describe('GET /api/metadata (integration)', () => {
146163
encodeURIComponent('https://www.youtube.com/watch?v=cached')
147164

148165
const first = await app.request(url)
149-
expect((await first.json()).cached).toBe(false)
166+
expect((await readBody(first)).cached).toBe(false)
150167

151168
const second = await app.request(url)
152-
const body = await second.json()
169+
const body = await readBody(second)
153170
expect(body.cached).toBe(true)
154171
})
155172
})

packages/hocuspocus.server/src/modules/link-metadata/__tests__/unit/htmlScrape.test.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,7 @@ describe('runHtmlScrape', () => {
3030
await runHtmlScrape('https://example.com', noopScraper, 'fr-FR')
3131

3232
const init = fetchSpy.mock.calls[0]![1] as RequestInit
33-
const headers = new Headers(init.headers as HeadersInit)
33+
const headers = new Headers(init.headers)
3434
expect(headers.get('accept-language')).toBe('fr-FR')
3535
expect(headers.get('user-agent')).toContain('DocsPlusBot')
3636
expect(headers.get('user-agent')).toContain('facebookexternalhit')

packages/hocuspocus.server/src/modules/link-metadata/__tests__/unit/oembed.test.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -71,10 +71,10 @@ describe('runOembed', () => {
7171

7272
test('respects per-call timeout via AbortSignal', async () => {
7373
let receivedSignal: AbortSignal | undefined
74-
fetchSpy.mockImplementation(async (_url, init) => {
75-
receivedSignal = (init as RequestInit).signal as AbortSignal
74+
fetchSpy.mockImplementation((async (_url: unknown, init?: RequestInit) => {
75+
receivedSignal = init?.signal as AbortSignal
7676
return new Response('{}', { status: 200, headers: { 'content-type': 'application/json' } })
77-
})
77+
}) as typeof fetch)
7878
await runOembed('https://www.youtube.com/watch?v=abc')
7979
expect(receivedSignal).toBeDefined()
8080
})

packages/hocuspocus.server/src/modules/link-metadata/domain/stages/htmlScrape.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -41,7 +41,11 @@ const decodeBody = (bytes: Uint8Array, contentType: string | null): string => {
4141
charset = parseCharsetFromMeta(firstKb)
4242
}
4343
try {
44-
return new TextDecoder(charset || 'utf-8', { fatal: false }).decode(bytes)
44+
// Bun's TextDecoder ctor types the encoding as a closed union, but the
45+
// value here is parsed from arbitrary HTTP / HTML and cannot be narrowed
46+
// statically. Invalid encodings throw RangeError, which the catch below
47+
// turns into a utf-8 fallback.
48+
return new TextDecoder((charset || 'utf-8') as never, { fatal: false }).decode(bytes)
4549
} catch {
4650
return new TextDecoder('utf-8', { fatal: false }).decode(bytes)
4751
}

0 commit comments

Comments
 (0)