Files
SolidScribe/server/tests/models/ScrapeQueue.test.js
T
2026-11-03 11:20:03 -07:00

355 lines
13 KiB
JavaScript

require('module-alias/register')
const db = require('@config/database')
const User = require('@models/User')
const Attachment = require('@models/Attachment')
const ScrapeCache = require('@models/ScrapeCache')
const ScrapeQueue = require('@workers/ScrapeQueue')
const Schema = require('@helpers/Schema')
const Robots = require('@helpers/Robots')
const HttpFetch = require('@helpers/HttpFetch')
const UrlSafety = require('@helpers/UrlSafety')
const FetchMock = require('../support/fetchMock')
const dns = require('dns').promises
const testUserName = 'jestTestUserScrapeQueue'
const password = 'Beans19934!!!'
let userId = null
let dnsSpy = null
const page = (title, description) => FetchMock.html(`
<html><head>
<title>${title}</title>
<meta property="og:description" content="${description || 'a description'}">
</head><body><p>body</p></body></html>
`)
// Clear cache rows for a URL so each test starts from a real miss.
const clearCache = (url) => db.promise()
.query('DELETE FROM scrape_cache WHERE url_hash = ?', [UrlSafety.urlKey(url)])
const attachmentRow = (id) => db.promise()
.query('SELECT * FROM attachment WHERE id = ?', [id])
.then(([rows]) => rows[0] || null)
beforeAll(async () => {
await Schema.ensureScrapeSchema()
try {
const existing = await User.getByUserName(testUserName)
if (existing && existing.id) { await User.deleteUser(existing.id, password) }
} catch (error) { /* first run */ }
const registered = await User.register(testUserName, password)
userId = registered.userId
})
beforeEach(() => {
FetchMock.install()
Robots.__resetCache()
dnsSpy = jest.spyOn(dns, 'lookup').mockResolvedValue([{ address: '93.184.216.34', family: 4 }])
HttpFetch.__setSleep(() => Promise.resolve())
// Exercise the real code path with politeness spacing and robots disabled.
ScrapeQueue.configure({ perHostMinSpacingMs: 0, perHostMaxSpacingMs: 0, checkRobots: false })
})
afterEach(() => {
FetchMock.restore()
dnsSpy.mockRestore()
HttpFetch.__resetSleep()
ScrapeQueue.__reset()
})
afterAll(async () => {
await db.promise().query('DELETE FROM attachment WHERE user_id = ?', [userId])
db.end()
})
describe('fetch and persist', () => {
test('writes parsed metadata onto the attachment row as plaintext', async () => {
const url = 'https://example.com/post-one'
await clearCache(url)
FetchMock.route(url, () => page('Post One', 'the first post'))
const attachmentId = await Attachment.createPendingUrlAttachment(userId, null, url)
await ScrapeQueue.enqueueUrl({ userId, noteId: null, attachmentId, url })
const row = await attachmentRow(attachmentId)
expect(row.text).toContain('Post One')
expect(row.text).toContain('the first post')
expect(row.scrape_state).toBe(ScrapeQueue.STATE.DONE)
// Must stay unencrypted - the FULLTEXT index depends on it.
expect(row.text).not.toMatch(/^[0-9a-f]{64}/)
})
test('keeps the url the user wrote rather than the canonical url', async () => {
const url = 'https://example.com/original'
await clearCache(url)
FetchMock.route(url, () => FetchMock.html(`
<html><head><title>Canonical Test</title>
<link rel="canonical" href="https://example.com/different-canonical">
</head><body>x</body></html>
`))
const attachmentId = await Attachment.createPendingUrlAttachment(userId, null, url)
await ScrapeQueue.enqueueUrl({ userId, noteId: null, attachmentId, url })
const row = await attachmentRow(attachmentId)
expect(row.url).toBe(url)
// The canonical URL is recorded in the cache, not on the attachment.
const cached = await ScrapeCache.get(url)
expect(cached.canonical_url).toBe('https://example.com/different-canonical')
})
})
describe('caching', () => {
test('a second request for the same url makes zero network calls', async () => {
const url = 'https://example.com/cached'
await clearCache(url)
FetchMock.route(url, () => page('Cached Page'))
const firstId = await Attachment.createPendingUrlAttachment(userId, null, url)
await ScrapeQueue.enqueueUrl({ userId, noteId: null, attachmentId: firstId, url })
const callsAfterFirst = FetchMock.callCount()
expect(callsAfterFirst).toBeGreaterThan(0)
const secondId = await Attachment.createPendingUrlAttachment(userId, null, url)
await ScrapeQueue.enqueueUrl({ userId, noteId: null, attachmentId: secondId, url })
expect(FetchMock.callCount()).toBe(callsAfterFirst)
// The cached row still populates the second attachment.
const row = await attachmentRow(secondId)
expect(row.text).toContain('Cached Page')
expect(row.scrape_state).toBe(ScrapeQueue.STATE.DONE)
})
test('two notes sharing a url coalesce into one fetch', async () => {
const url = 'https://example.com/shared'
await clearCache(url)
FetchMock.route(url, () => page('Shared Page'))
const firstId = await Attachment.createPendingUrlAttachment(userId, null, url)
const secondId = await Attachment.createPendingUrlAttachment(userId, null, url)
// Enqueued together, so they overlap in flight.
await ScrapeQueue.enqueueUrls([
{ userId, noteId: null, attachmentId: firstId, url },
{ userId, noteId: null, attachmentId: secondId, url },
])
expect(FetchMock.callsTo(url)).toHaveLength(1)
const rows = await Promise.all([attachmentRow(firstId), attachmentRow(secondId)])
rows.forEach(row => {
expect(row.text).toContain('Shared Page')
expect(row.scrape_state).toBe(ScrapeQueue.STATE.DONE)
})
})
test('a stale entry revalidates and a 304 reuses the stored text', async () => {
const url = 'https://example.com/revalidate'
await clearCache(url)
FetchMock.routeSequence(url, [
FetchMock.html('<html><head><title>Original Title</title></head><body>x</body></html>', {
headers: { etag: 'W/"v1"' },
}),
FetchMock.status(304),
])
const firstId = await Attachment.createPendingUrlAttachment(userId, null, url)
await ScrapeQueue.enqueueUrl({ userId, noteId: null, attachmentId: firstId, url })
// Force staleness so the next pass revalidates instead of hitting cache.
await db.promise().query('UPDATE scrape_cache SET expires_at = ? WHERE url_hash = ?',
[Math.round(Date.now() / 1000) - 10, UrlSafety.urlKey(url)])
const secondId = await Attachment.createPendingUrlAttachment(userId, null, url)
await ScrapeQueue.enqueueUrl({ userId, noteId: null, attachmentId: secondId, url })
// Conditional request was sent.
const conditional = FetchMock.callsTo(url).find(call => call.headers['If-None-Match'])
expect(conditional).toBeTruthy()
const row = await attachmentRow(secondId)
expect(row.text).toContain('Original Title')
})
})
describe('failure handling', () => {
test('a 500 leaves the row failed and never writes a sentinel string', async () => {
const url = 'https://example.com/broken'
await clearCache(url)
FetchMock.route(url, () => FetchMock.status(500))
const attachmentId = await Attachment.createPendingUrlAttachment(userId, null, url)
await ScrapeQueue.enqueueUrl({ userId, noteId: null, attachmentId, url })
const row = await attachmentRow(attachmentId)
expect(row.scrape_state).toBe(ScrapeQueue.STATE.FAILED)
// The old code stored these in attachment.text and fed them to FULLTEXT.
expect(row.text).not.toContain('No site text')
expect(row.text).not.toContain('Request Timeout')
})
test('a 404 removes the shell row rather than orphaning it', async () => {
const url = 'https://example.com/gone'
await clearCache(url)
FetchMock.route(url, () => FetchMock.status(404))
const attachmentId = await Attachment.createPendingUrlAttachment(userId, null, url)
await ScrapeQueue.enqueueUrl({ userId, noteId: null, attachmentId, url })
expect(await attachmentRow(attachmentId)).toBeNull()
})
test('a blocked url removes the shell row and makes no request', async () => {
const url = 'http://127.0.0.1:9999/internal'
FetchMock.route(/.*/, () => page('should never load'))
const attachmentId = await Attachment.createPendingUrlAttachment(userId, null, url)
await ScrapeQueue.enqueueUrl({ userId, noteId: null, attachmentId, url })
expect(await attachmentRow(attachmentId)).toBeNull()
expect(FetchMock.callCount()).toBe(0)
})
test('one failing url does not strand the rest of a batch', async () => {
const good = 'https://example.com/good-one'
const bad = 'https://example.com/bad-one'
await clearCache(good)
await clearCache(bad)
FetchMock.route(good, () => page('Good One'))
FetchMock.route(bad, () => FetchMock.status(500))
const goodId = await Attachment.createPendingUrlAttachment(userId, null, good)
const badId = await Attachment.createPendingUrlAttachment(userId, null, bad)
// The old counter-based implementation never settled in this case.
const texts = await ScrapeQueue.enqueueUrls([
{ userId, noteId: null, attachmentId: badId, url: bad },
{ userId, noteId: null, attachmentId: goodId, url: good },
])
expect(texts).toHaveLength(2)
expect((await attachmentRow(goodId)).text).toContain('Good One')
})
test('a failed url is not refetched while inside its backoff window', async () => {
const url = 'https://example.com/flaky'
await clearCache(url)
FetchMock.route(url, () => FetchMock.status(500))
const firstId = await Attachment.createPendingUrlAttachment(userId, null, url)
await ScrapeQueue.enqueueUrl({ userId, noteId: null, attachmentId: firstId, url })
const callsAfterFirst = FetchMock.callCount()
const secondId = await Attachment.createPendingUrlAttachment(userId, null, url)
await ScrapeQueue.enqueueUrl({ userId, noteId: null, attachmentId: secondId, url })
expect(FetchMock.callCount()).toBe(callsAfterFirst)
})
})
describe('robots.txt', () => {
test('refuses a disallowed url and records it', async () => {
const url = 'https://example.com/no-bots/page'
await clearCache(url)
ScrapeQueue.configure({ perHostMinSpacingMs: 0, perHostMaxSpacingMs: 0 }) // robots ON
FetchMock.route('https://example.com/robots.txt', () => new Response(
'User-agent: *\nDisallow: /no-bots', { status: 200, headers: { 'content-type': 'text/plain' } }
))
FetchMock.route(url, () => page('Should Not Be Fetched'))
const attachmentId = await Attachment.createPendingUrlAttachment(userId, null, url)
await ScrapeQueue.enqueueUrl({ userId, noteId: null, attachmentId, url })
expect(FetchMock.callsTo(url)).toHaveLength(0)
const cached = await ScrapeCache.get(url)
expect(cached.fetch_state).toBe('blocked')
})
test('proceeds when robots.txt allows the path', async () => {
const url = 'https://example.com/allowed/page'
await clearCache(url)
ScrapeQueue.configure({ perHostMinSpacingMs: 0, perHostMaxSpacingMs: 0 })
FetchMock.route('https://example.com/robots.txt', () => new Response(
'User-agent: *\nDisallow: /private', { status: 200, headers: { 'content-type': 'text/plain' } }
))
FetchMock.route(url, () => page('Allowed Page'))
const attachmentId = await Attachment.createPendingUrlAttachment(userId, null, url)
await ScrapeQueue.enqueueUrl({ userId, noteId: null, attachmentId, url })
expect((await attachmentRow(attachmentId)).text).toContain('Allowed Page')
})
})
describe('crash recovery', () => {
test('re-enqueues rows left pending by a restart', async () => {
const url = 'https://example.com/recovered'
await clearCache(url)
FetchMock.route(url, () => page('Recovered Page'))
// Simulate a row that was enqueued but never processed.
const attachmentId = await Attachment.createPendingUrlAttachment(userId, null, url)
const recovery = await ScrapeQueue.recoverPending()
expect(recovery.requeued).toBeGreaterThan(0)
await ScrapeQueue.drain()
const row = await attachmentRow(attachmentId)
expect(row.text).toContain('Recovered Page')
expect(row.scrape_state).toBe(ScrapeQueue.STATE.DONE)
})
test('resets rows stuck mid-fetch so they are retried', async () => {
const url = 'https://example.com/stuck'
await clearCache(url)
FetchMock.route(url, () => page('Unstuck Page'))
const attachmentId = await Attachment.createPendingUrlAttachment(userId, null, url)
// Pretend the process died while this one was in flight.
await db.promise().query('UPDATE attachment SET scrape_state = ? WHERE id = ?',
[ScrapeQueue.STATE.FETCHING, attachmentId])
await ScrapeQueue.recoverPending()
await ScrapeQueue.drain()
expect((await attachmentRow(attachmentId)).scrape_state).toBe(ScrapeQueue.STATE.DONE)
})
test('does not sweep up long-settled rows', async () => {
// Rows default to DONE, which is what keeps the pre-existing ~1400
// attachment rows out of the recovery queue on first boot.
const [rows] = await db.promise().query(
'SELECT COUNT(*) AS n FROM attachment WHERE scrape_state = ? AND user_id = ?',
[ScrapeQueue.STATE.PENDING, userId]
)
const recovery = await ScrapeQueue.recoverPending()
expect(recovery.requeued).toBeLessThanOrEqual(rows[0].n + 1)
})
})