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 });