/ tests / capture / backfill-screenshots-mocked.test.js
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  });