355 lines
13 KiB
JavaScript
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)
|
|
})
|
|
})
|