backfill-screenshots-mocked.test.js
1 /** 2 * Mocked Tests for backfillScreenshots function 3 * Tests the main orchestration function with capture.js mocked out 4 */ 5 6 import { describe, it, mock, beforeEach } from 'node:test'; 7 import assert from 'node:assert/strict'; 8 9 // Mock capture.js before importing the module under test 10 const mockCaptureScreenshots = mock.fn(); 11 const mockLaunchBrowser = mock.fn(); 12 const mockCreateStealthContext = mock.fn(); 13 14 mock.module('../../src/capture.js', { 15 namedExports: { 16 captureScreenshots: mockCaptureScreenshots, 17 launchBrowser: mockLaunchBrowser, 18 createStealthContext: mockCreateStealthContext, 19 }, 20 }); 21 22 // Import after mocking 23 const { backfillScreenshots } = await import('../../src/utils/backfill-screenshots.js'); 24 25 describe('backfillScreenshots (mocked)', () => { 26 let mockDb; 27 let mockBrowser; 28 let mockContext; 29 30 beforeEach(() => { 31 // Reset all mocks 32 mockCaptureScreenshots.mock.resetCalls(); 33 mockLaunchBrowser.mock.resetCalls(); 34 mockCreateStealthContext.mock.resetCalls(); 35 36 // Set up mock browser and context 37 mockContext = { 38 close: mock.fn(async () => {}), 39 }; 40 mockBrowser = { 41 close: mock.fn(async () => {}), 42 }; 43 44 mockLaunchBrowser.mock.mockImplementation(async () => mockBrowser); 45 mockCreateStealthContext.mock.mockImplementation(async () => mockContext); 46 47 // Set up mock database 48 mockDb = { 49 prepare: mock.fn(), 50 }; 51 }); 52 53 it('returns early with zero results when no sites have missing screenshots', async () => { 54 const mockStmt = { 55 all: mock.fn(() => []), 56 }; 57 mockDb.prepare.mock.mockImplementation(() => mockStmt); 58 59 const result = await backfillScreenshots(mockDb, 50); 60 61 assert.deepStrictEqual(result, { 62 total: 0, 63 success: 0, 64 failed: 0, 65 sites: [], 66 }); 67 68 // Should NOT launch a browser when there are no sites 69 assert.strictEqual(mockLaunchBrowser.mock.callCount(), 0); 70 assert.strictEqual(mockCreateStealthContext.mock.callCount(), 0); 71 }); 72 73 it('successfully backfills screenshots for sites', async () => { 74 const missingSites = [ 75 { 76 id: 1, 77 domain: 'example.com', 78 landing_page_url: 'https://example.com', 79 keyword: 'plumber sydney', 80 }, 81 { 82 id: 2, 83 domain: 'test.com', 84 landing_page_url: 'https://test.com', 85 keyword: 'electrician melbourne', 86 }, 87 ]; 88 89 const mockStmtAll = { all: mock.fn(() => missingSites) }; 90 const mockStmtRun = { run: mock.fn() }; 91 92 mockDb.prepare.mock.mockImplementation(sql => { 93 if (sql.includes('SELECT')) return mockStmtAll; 94 if (sql.includes('UPDATE')) return mockStmtRun; 95 return mockStmtAll; 96 }); 97 98 // Mock successful capture for both sites 99 mockCaptureScreenshots.mock.mockImplementation(async () => ({ 100 screenshots: { 101 desktop_above: Buffer.from('desktop_above'), 102 desktop_below: Buffer.from('desktop_below'), 103 mobile_above: Buffer.from('mobile_above'), 104 }, 105 screenshotsUncropped: { 106 desktop_above: Buffer.from('uncropped_desktop_above'), 107 desktop_below: Buffer.from('uncropped_desktop_below'), 108 mobile_above: Buffer.from('uncropped_mobile_above'), 109 }, 110 })); 111 112 const result = await backfillScreenshots(mockDb, 100); 113 114 assert.strictEqual(result.total, 2); 115 assert.strictEqual(result.success, 2); 116 assert.strictEqual(result.failed, 0); 117 assert.strictEqual(result.sites.length, 2); 118 assert.strictEqual(result.sites[0].status, 'success'); 119 assert.strictEqual(result.sites[1].status, 'success'); 120 121 // Verify browser was launched and closed 122 assert.strictEqual(mockLaunchBrowser.mock.callCount(), 1); 123 assert.strictEqual(mockCreateStealthContext.mock.callCount(), 1); 124 assert.strictEqual(mockContext.close.mock.callCount(), 1); 125 assert.strictEqual(mockBrowser.close.mock.callCount(), 1); 126 127 // Verify captureScreenshots was called for each site 128 assert.strictEqual(mockCaptureScreenshots.mock.callCount(), 2); 129 assert.strictEqual(mockCaptureScreenshots.mock.calls[0].arguments[1], 'https://example.com'); 130 assert.strictEqual(mockCaptureScreenshots.mock.calls[1].arguments[1], 'https://test.com'); 131 }); 132 133 it('handles capture failure gracefully for individual sites', async () => { 134 const missingSites = [ 135 { 136 id: 1, 137 domain: 'good.com', 138 landing_page_url: 'https://good.com', 139 keyword: 'roofer perth', 140 }, 141 { 142 id: 2, 143 domain: 'failing.com', 144 landing_page_url: 'https://failing.com', 145 keyword: 'painter brisbane', 146 }, 147 { 148 id: 3, 149 domain: 'alsogood.com', 150 landing_page_url: 'https://alsogood.com', 151 keyword: 'tiler adelaide', 152 }, 153 ]; 154 155 const mockStmtAll = { all: mock.fn(() => missingSites) }; 156 const mockStmtRun = { run: mock.fn() }; 157 158 mockDb.prepare.mock.mockImplementation(sql => { 159 if (sql.includes('SELECT')) return mockStmtAll; 160 if (sql.includes('UPDATE')) return mockStmtRun; 161 return mockStmtAll; 162 }); 163 164 mockCaptureScreenshots.mock.mockImplementation(async (_ctx, url) => { 165 if (url === 'https://failing.com') { 166 throw new Error('Timeout waiting for page load'); 167 } 168 return { 169 screenshots: { 170 desktop_above: Buffer.from('da'), 171 desktop_below: Buffer.from('db'), 172 mobile_above: Buffer.from('ma'), 173 }, 174 screenshotsUncropped: { 175 desktop_above: Buffer.from('uda'), 176 desktop_below: Buffer.from('udb'), 177 mobile_above: Buffer.from('uma'), 178 }, 179 }; 180 }); 181 182 const result = await backfillScreenshots(mockDb, 100); 183 184 assert.strictEqual(result.total, 3); 185 assert.strictEqual(result.success, 2); 186 assert.strictEqual(result.failed, 1); 187 188 // Check individual site results 189 const goodSite = result.sites.find(s => s.domain === 'good.com'); 190 assert.strictEqual(goodSite.status, 'success'); 191 192 const failingSite = result.sites.find(s => s.domain === 'failing.com'); 193 assert.strictEqual(failingSite.status, 'failed'); 194 assert.strictEqual(failingSite.error, 'Timeout waiting for page load'); 195 196 const alsoGoodSite = result.sites.find(s => s.domain === 'alsogood.com'); 197 assert.strictEqual(alsoGoodSite.status, 'success'); 198 199 // Browser should still be closed despite failure 200 assert.strictEqual(mockContext.close.mock.callCount(), 1); 201 assert.strictEqual(mockBrowser.close.mock.callCount(), 1); 202 }); 203 204 it('handles captureScreenshots returning an error property', async () => { 205 const missingSites = [ 206 { 207 id: 1, 208 domain: 'error-result.com', 209 landing_page_url: 'https://error-result.com', 210 keyword: 'hvac darwin', 211 }, 212 ]; 213 214 const mockStmtAll = { all: mock.fn(() => missingSites) }; 215 const mockStmtRun = { run: mock.fn() }; 216 217 mockDb.prepare.mock.mockImplementation(sql => { 218 if (sql.includes('SELECT')) return mockStmtAll; 219 if (sql.includes('UPDATE')) return mockStmtRun; 220 return mockStmtAll; 221 }); 222 223 // captureScreenshots returns result with error field (not throws) 224 mockCaptureScreenshots.mock.mockImplementation(async () => ({ 225 error: 'Navigation timeout exceeded', 226 screenshots: {}, 227 screenshotsUncropped: {}, 228 })); 229 230 const result = await backfillScreenshots(mockDb, 100); 231 232 assert.strictEqual(result.total, 1); 233 assert.strictEqual(result.success, 0); 234 assert.strictEqual(result.failed, 1); 235 assert.strictEqual(result.sites[0].status, 'failed'); 236 assert.strictEqual(result.sites[0].error, 'Navigation timeout exceeded'); 237 }); 238 239 it('closes browser even when all captures fail', async () => { 240 const missingSites = [ 241 { 242 id: 1, 243 domain: 'fail1.com', 244 landing_page_url: 'https://fail1.com', 245 keyword: 'test1', 246 }, 247 ]; 248 249 const mockStmtAll = { all: mock.fn(() => missingSites) }; 250 251 mockDb.prepare.mock.mockImplementation(() => mockStmtAll); 252 253 mockCaptureScreenshots.mock.mockImplementation(async () => { 254 throw new Error('Browser crashed'); 255 }); 256 257 const result = await backfillScreenshots(mockDb, 10); 258 259 assert.strictEqual(result.failed, 1); 260 // Browser and context must still be cleaned up 261 assert.strictEqual(mockContext.close.mock.callCount(), 1); 262 assert.strictEqual(mockBrowser.close.mock.callCount(), 1); 263 }); 264 265 it('passes limit to findSitesWithMissingScreenshots', async () => { 266 const mockStmtAll = { all: mock.fn(() => []) }; 267 mockDb.prepare.mock.mockImplementation(() => mockStmtAll); 268 269 await backfillScreenshots(mockDb, 25); 270 271 assert.strictEqual(mockStmtAll.all.mock.calls[0].arguments[0], 25); 272 }); 273 274 it('uses default limit of 100', async () => { 275 const mockStmtAll = { all: mock.fn(() => []) }; 276 mockDb.prepare.mock.mockImplementation(() => mockStmtAll); 277 278 await backfillScreenshots(mockDb); 279 280 assert.strictEqual(mockStmtAll.all.mock.calls[0].arguments[0], 100); 281 }); 282 283 it('launches browser with headless: true', async () => { 284 const missingSites = [ 285 { 286 id: 1, 287 domain: 'headless.com', 288 landing_page_url: 'https://headless.com', 289 keyword: 'test', 290 }, 291 ]; 292 293 const mockStmtAll = { all: mock.fn(() => missingSites) }; 294 const mockStmtRun = { run: mock.fn() }; 295 296 mockDb.prepare.mock.mockImplementation(sql => { 297 if (sql.includes('SELECT')) return mockStmtAll; 298 return mockStmtRun; 299 }); 300 301 mockCaptureScreenshots.mock.mockImplementation(async () => ({ 302 screenshots: { desktop_above: Buffer.from('x'), desktop_below: null, mobile_above: null }, 303 screenshotsUncropped: { 304 desktop_above: null, 305 desktop_below: null, 306 mobile_above: null, 307 }, 308 })); 309 310 await backfillScreenshots(mockDb, 1); 311 312 // Verify headless option 313 const launchArgs = mockLaunchBrowser.mock.calls[0].arguments[0]; 314 assert.strictEqual(launchArgs.headless, true); 315 }); 316 317 it('calls updateSiteScreenshots for successful captures', async () => { 318 const missingSites = [ 319 { 320 id: 42, 321 domain: 'update-test.com', 322 landing_page_url: 'https://update-test.com', 323 keyword: 'test', 324 }, 325 ]; 326 327 const mockStmtAll = { all: mock.fn(() => missingSites) }; 328 const mockStmtRun = { run: mock.fn() }; 329 330 let updateSqlCalled = false; 331 mockDb.prepare.mock.mockImplementation(sql => { 332 if (sql.includes('UPDATE sites')) { 333 updateSqlCalled = true; 334 return mockStmtRun; 335 } 336 return mockStmtAll; 337 }); 338 339 mockCaptureScreenshots.mock.mockImplementation(async () => ({ 340 screenshots: { 341 desktop_above: Buffer.from('da'), 342 desktop_below: Buffer.from('db'), 343 mobile_above: Buffer.from('ma'), 344 }, 345 screenshotsUncropped: { 346 desktop_above: Buffer.from('uda'), 347 desktop_below: Buffer.from('udb'), 348 mobile_above: Buffer.from('uma'), 349 }, 350 })); 351 352 await backfillScreenshots(mockDb, 10); 353 354 assert.ok(updateSqlCalled, 'UPDATE sites SQL should have been called'); 355 // The last arg to run should be the site ID 356 const runArgs = mockStmtRun.run.mock.calls[0].arguments; 357 assert.strictEqual(runArgs[6], 42); 358 }); 359 });