/ tests / proposals / proposal-generator-v2-supplement.test.js
proposal-generator-v2-supplement.test.js
  1  /**
  2   * Supplement tests for src/proposal-generator-v2.js
  3   *
  4   * Covers DB-driven utility functions (getPendingOutreaches, approveOutreach,
  5   * reworkOutreach, processReworkQueue) without requiring LLM API calls.
  6   * Uses an in-memory SQLite database via pg-mock.
  7   */
  8  
  9  import { test, describe, before, after, beforeEach, mock } from 'node:test';
 10  import assert from 'node:assert';
 11  import Database from 'better-sqlite3';
 12  import { createPgMock } from '../helpers/pg-mock.js';
 13  
 14  // Create in-memory SQLite with required schema
 15  const testDb = new Database(':memory:');
 16  
 17  testDb.exec(`
 18    CREATE TABLE IF NOT EXISTS sites (
 19      id INTEGER PRIMARY KEY AUTOINCREMENT,
 20      domain TEXT NOT NULL,
 21      landing_page_url TEXT NOT NULL,
 22      keyword TEXT,
 23      status TEXT DEFAULT 'enriched',
 24      score REAL DEFAULT 65,
 25      grade TEXT DEFAULT 'C',
 26      score_json TEXT,
 27      country_code TEXT DEFAULT 'AU',
 28      created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
 29      rescored_at DATETIME
 30    );
 31  
 32    CREATE TABLE IF NOT EXISTS messages (
 33      id INTEGER PRIMARY KEY AUTOINCREMENT,
 34      site_id INTEGER NOT NULL REFERENCES sites(id),
 35      direction TEXT NOT NULL DEFAULT 'outbound' CHECK(direction IN ('inbound', 'outbound')),
 36      contact_method TEXT NOT NULL DEFAULT 'email',
 37      contact_uri TEXT,
 38      message_body TEXT,
 39      subject_line TEXT,
 40      approval_status TEXT DEFAULT 'pending', delivery_status TEXT,
 41      rework_instructions TEXT,
 42      sent_at DATETIME,
 43      created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
 44      message_type TEXT DEFAULT 'outreach',
 45      raw_payload TEXT,
 46      read_at TEXT
 47    );
 48  
 49    CREATE TABLE IF NOT EXISTS unsubscribed_emails (
 50      id INTEGER PRIMARY KEY AUTOINCREMENT,
 51      email TEXT NOT NULL UNIQUE COLLATE NOCASE,
 52      site_id INTEGER REFERENCES sites(id),
 53      unsubscribed_at DATETIME DEFAULT CURRENT_TIMESTAMP
 54    );
 55  `);
 56  
 57  // Mock db.js BEFORE importing the module under test
 58  mock.module('../../src/utils/db.js', { namedExports: createPgMock(testDb) });
 59  
 60  const { getPendingOutreaches, approveOutreach, reworkOutreach, processReworkQueue } =
 61    await import('../../src/proposal-generator-v2.js');
 62  
 63  describe('proposal-generator-v2 supplement tests', () => {
 64    beforeEach(() => {
 65      testDb.exec(
 66        'DELETE FROM messages; DELETE FROM unsubscribed_emails; DELETE FROM sites;'
 67      );
 68    });
 69  
 70    after(() => {
 71      testDb.close();
 72    });
 73  
 74    describe('getPendingOutreaches', () => {
 75      test('returns empty array when no pending outreaches', async () => {
 76        const result = await getPendingOutreaches();
 77        assert.deepStrictEqual(result, []);
 78      });
 79  
 80      test('returns pending outreaches with site data', async () => {
 81        const site = testDb
 82          .prepare(
 83            'INSERT INTO sites (domain, landing_page_url, keyword, score_json) VALUES (?, ?, ?, ?)'
 84          )
 85          .run(
 86            'test.com',
 87            'https://test.com',
 88            'web design',
 89            JSON.stringify({ overall_calculation: { letter_grade: 'C' } })
 90          );
 91  
 92        testDb.prepare(
 93          "INSERT INTO messages (site_id, contact_method, contact_uri, message_body, approval_status) VALUES (?, ?, ?, ?, 'pending')"
 94        ).run(site.lastInsertRowid, 'email', 'owner@test.com', 'Test proposal');
 95  
 96        const result = await getPendingOutreaches();
 97        assert.strictEqual(result.length, 1);
 98        assert.strictEqual(result[0].contact_uri, 'owner@test.com');
 99        assert.strictEqual(result[0].domain, 'test.com');
100      });
101  
102      test('does not return approved outreaches', async () => {
103        const site = testDb
104          .prepare('INSERT INTO sites (domain, landing_page_url, keyword) VALUES (?, ?, ?)')
105          .run('approved.com', 'https://approved.com', 'test');
106  
107        testDb.prepare(
108          "INSERT INTO messages (site_id, contact_method, contact_uri, message_body, approval_status) VALUES (?, ?, ?, ?, 'approved')"
109        ).run(site.lastInsertRowid, 'email', 'owner@approved.com', 'Proposal');
110  
111        const result = await getPendingOutreaches();
112        assert.strictEqual(result.length, 0);
113      });
114  
115      test('respects limit parameter', async () => {
116        const site = testDb
117          .prepare('INSERT INTO sites (domain, landing_page_url, keyword) VALUES (?, ?, ?)')
118          .run('limit-test.com', 'https://limit-test.com', 'test');
119  
120        for (let i = 0; i < 5; i++) {
121          testDb.prepare(
122            "INSERT INTO messages (site_id, contact_method, contact_uri, message_body, approval_status) VALUES (?, ?, ?, ?, 'pending')"
123          ).run(site.lastInsertRowid, 'email', `owner${i}@test.com`, `Proposal ${i}`);
124        }
125  
126        const result = await getPendingOutreaches(3);
127        assert.strictEqual(result.length, 3);
128      });
129    });
130  
131    describe('approveOutreach', () => {
132      test('sets outreach status to approved', async () => {
133        const site = testDb
134          .prepare('INSERT INTO sites (domain, landing_page_url, keyword) VALUES (?, ?, ?)')
135          .run('approve.com', 'https://approve.com', 'test');
136  
137        const outreach = testDb
138          .prepare(
139            "INSERT INTO messages (site_id, contact_method, contact_uri, approval_status) VALUES (?, ?, ?, 'pending')"
140          )
141          .run(site.lastInsertRowid, 'email', 'owner@approve.com');
142  
143        await approveOutreach(outreach.lastInsertRowid);
144  
145        const updated = testDb
146          .prepare('SELECT approval_status FROM messages WHERE id = ?')
147          .get(outreach.lastInsertRowid);
148        assert.strictEqual(updated.approval_status, 'approved');
149      });
150    });
151  
152    describe('reworkOutreach', () => {
153      test('sets outreach status to rework and stores instructions', async () => {
154        const site = testDb
155          .prepare('INSERT INTO sites (domain, landing_page_url, keyword) VALUES (?, ?, ?)')
156          .run('rework.com', 'https://rework.com', 'test');
157  
158        const outreach = testDb
159          .prepare(
160            "INSERT INTO messages (site_id, contact_method, contact_uri, approval_status) VALUES (?, ?, ?, 'pending')"
161          )
162          .run(site.lastInsertRowid, 'email', 'owner@rework.com');
163  
164        await reworkOutreach(outreach.lastInsertRowid, 'Make the tone more friendly');
165  
166        const updated = testDb
167          .prepare('SELECT approval_status, rework_instructions FROM messages WHERE id = ?')
168          .get(outreach.lastInsertRowid);
169  
170        assert.strictEqual(updated.approval_status, 'rework');
171        assert.strictEqual(updated.rework_instructions, 'Make the tone more friendly');
172      });
173    });
174  
175    describe('processReworkQueue', () => {
176      test('returns immediately when rework queue is empty', async () => {
177        await assert.doesNotReject(() => processReworkQueue());
178      });
179    });
180  });