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(` ${title}

body

`) // 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(` Canonical Test x `)) 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('Original Titlex', { 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) }) })