/ tests / utils / prompt-learning.test.js
prompt-learning.test.js
  1  /**
  2   * Tests for src/utils/prompt-learning.js
  3   *
  4   * Tests: logPromptFeedback, analyzePromptFeedback, generatePromptRecommendations,
  5   * getPromptHistory.
  6   *
  7   * versionPromptFile() reads from disk (prompts/FILE) — not tested here.
  8   *
  9   * Uses pg-mock pattern since prompt-learning.js imports from db.js.
 10   */
 11  
 12  import { test, describe, mock } from 'node:test';
 13  import assert from 'node:assert/strict';
 14  import Database from 'better-sqlite3';
 15  import { createPgMock } from '../helpers/pg-mock.js';
 16  
 17  // Seed DB BEFORE importing (module uses db.js internally)
 18  const db = new Database(':memory:');
 19  db.exec(`
 20    CREATE TABLE IF NOT EXISTS sites (
 21      id INTEGER PRIMARY KEY AUTOINCREMENT,
 22      domain TEXT NOT NULL,
 23      rescored_at DATETIME
 24    );
 25  
 26    CREATE TABLE IF NOT EXISTS messages (
 27      id INTEGER PRIMARY KEY AUTOINCREMENT,
 28      site_id INTEGER REFERENCES sites(id),
 29      read_at TEXT,
 30      message_type TEXT DEFAULT 'outreach',
 31      raw_payload TEXT
 32    );
 33  
 34    CREATE TABLE IF NOT EXISTS prompt_feedback (
 35      id INTEGER PRIMARY KEY AUTOINCREMENT,
 36      message_id INTEGER REFERENCES messages(id) ON DELETE CASCADE,
 37      site_id INTEGER REFERENCES sites(id) ON DELETE CASCADE,
 38      prompt_file TEXT NOT NULL,
 39      prompt_version INTEGER DEFAULT 1,
 40      feedback_type TEXT NOT NULL,
 41      feedback_text TEXT,
 42      feedback_category TEXT,
 43      resulted_in_sale INTEGER DEFAULT 0,
 44      created_at DATETIME DEFAULT CURRENT_TIMESTAMP
 45    );
 46  
 47    CREATE TABLE IF NOT EXISTS prompt_versions (
 48      id INTEGER PRIMARY KEY AUTOINCREMENT,
 49      prompt_file TEXT NOT NULL,
 50      version INTEGER NOT NULL,
 51      content TEXT NOT NULL,
 52      change_summary TEXT,
 53      learning_applied TEXT,
 54      created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
 55      UNIQUE(prompt_file, version)
 56    );
 57  `);
 58  
 59  // Insert a site and a message
 60  db.exec(`INSERT INTO sites (domain) VALUES ('example.com')`);
 61  const siteId = db.prepare('SELECT id FROM sites LIMIT 1').get().id;
 62  db.exec(`INSERT INTO messages (site_id) VALUES (${siteId})`);
 63  const msgId = db.prepare('SELECT id FROM messages LIMIT 1').get().id;
 64  
 65  // Insert prompt feedback for 'PROPOSAL.md'
 66  const insert = db.prepare(`
 67    INSERT INTO prompt_feedback (message_id, site_id, prompt_file, feedback_type, feedback_text, feedback_category)
 68    VALUES (?, ?, ?, ?, ?, ?)
 69  `);
 70  
 71  insert.run(msgId, siteId, 'PROPOSAL.md', 'approved', 'Great proposal', 'tone');
 72  insert.run(msgId, siteId, 'PROPOSAL.md', 'approved', 'Good job', 'length');
 73  insert.run(msgId, siteId, 'PROPOSAL.md', 'rework', 'Too salesy', 'tone');
 74  insert.run(msgId, siteId, 'PROPOSAL.md', 'rework', 'Too long text', 'length');
 75  insert.run(msgId, siteId, 'PROPOSAL.md', 'rework', 'Very pushy tone', 'tone');
 76  insert.run(msgId, siteId, 'PROPOSAL.md', 'rejected', 'Not relevant', 'other');
 77  insert.run(msgId, siteId, 'PROPOSAL.md', 'conversion', null, null);
 78  // different prompt file
 79  insert.run(msgId, siteId, 'SCORING.md', 'approved', 'Looks good', null);
 80  
 81  mock.module('../../src/utils/db.js', { namedExports: createPgMock(db) });
 82  
 83  const {
 84    logPromptFeedback,
 85    analyzePromptFeedback,
 86    generatePromptRecommendations,
 87    getPromptHistory,
 88  } = await import('../../src/utils/prompt-learning.js');
 89  
 90  // ─── logPromptFeedback ────────────────────────────────────────────────────────
 91  
 92  describe('logPromptFeedback', () => {
 93    test('inserts feedback without throwing', async () => {
 94      await assert.doesNotReject(() =>
 95        logPromptFeedback({
 96          messageId: null,
 97          siteId: null,
 98          promptFile: 'TEST.md',
 99          feedbackType: 'approved',
100          feedbackText: 'Looks great',
101        })
102      );
103    });
104  
105    test('auto-categorizes feedback text', async () => {
106      // After logging, we can verify via analyzePromptFeedback
107      await logPromptFeedback({
108        promptFile: 'TEST.md',
109        feedbackType: 'rework',
110        feedbackText: 'This is too salesy and pushy',
111      });
112  
113      const analysis = await analyzePromptFeedback('TEST.md', 1);
114      assert.ok(analysis.stats.total > 0);
115    });
116  
117    test('accepts outreachId as alias for messageId', async () => {
118      await assert.doesNotReject(() =>
119        logPromptFeedback({
120          outreachId: null,
121          siteId: null,
122          promptFile: 'ALIAS.md',
123          feedbackType: 'rejected',
124          feedbackText: 'Not relevant',
125        })
126      );
127    });
128  });
129  
130  // ─── analyzePromptFeedback ────────────────────────────────────────────────────
131  
132  describe('analyzePromptFeedback', () => {
133    test('returns analysis object for known prompt file', async () => {
134      const analysis = await analyzePromptFeedback('PROPOSAL.md', 30);
135      assert.ok(typeof analysis === 'object');
136      assert.equal(analysis.promptFile, 'PROPOSAL.md');
137      assert.equal(analysis.period, '30 days');
138    });
139  
140    test('stats object has expected fields', async () => {
141      const { stats } = await analyzePromptFeedback('PROPOSAL.md', 30);
142      assert.ok('approved' in stats);
143      assert.ok('rework' in stats);
144      assert.ok('rejected' in stats);
145      assert.ok('conversions' in stats);
146      assert.ok('total' in stats);
147      assert.ok('approvalRate' in stats);
148    });
149  
150    test('counts feedback correctly', async () => {
151      const { stats } = await analyzePromptFeedback('PROPOSAL.md', 30);
152      assert.equal(stats.approved, 2);
153      assert.equal(stats.rework, 3);
154      assert.equal(stats.rejected, 1);
155      assert.equal(stats.conversions, 1);
156      assert.equal(stats.total, 7);
157    });
158  
159    test('calculates approvalRate as (approved+conversions)/total', async () => {
160      const { stats } = await analyzePromptFeedback('PROPOSAL.md', 30);
161      // (2 approved + 1 conversion) / 7 total = 42.9%
162      assert.ok(stats.approvalRate > 40 && stats.approvalRate < 45);
163    });
164  
165    test('returns empty stats for unknown prompt file', async () => {
166      const analysis = await analyzePromptFeedback('UNKNOWN.md', 30);
167      assert.equal(analysis.stats.total, 0);
168      assert.equal(analysis.stats.approvalRate, 0);
169    });
170  
171    test('includes distribution array', async () => {
172      const analysis = await analyzePromptFeedback('PROPOSAL.md', 30);
173      assert.ok(Array.isArray(analysis.distribution));
174      assert.ok(analysis.distribution.length > 0);
175    });
176  
177    test('includes categories array', async () => {
178      const analysis = await analyzePromptFeedback('PROPOSAL.md', 30);
179      assert.ok(Array.isArray(analysis.categories));
180    });
181  
182    test('includes topIssues (up to 5 categories)', async () => {
183      const analysis = await analyzePromptFeedback('PROPOSAL.md', 30);
184      assert.ok(Array.isArray(analysis.topIssues));
185      assert.ok(analysis.topIssues.length <= 5);
186    });
187  });
188  
189  // ─── generatePromptRecommendations ───────────────────────────────────────────
190  
191  describe('generatePromptRecommendations', () => {
192    test('returns object with recommendations array', async () => {
193      const result = await generatePromptRecommendations('PROPOSAL.md');
194      assert.ok(typeof result === 'object');
195      assert.ok(Array.isArray(result.recommendations));
196    });
197  
198    test('includes high-priority recommendation when approval rate < 70%', async () => {
199      // PROPOSAL.md has ~42.9% approval rate
200      const result = await generatePromptRecommendations('PROPOSAL.md');
201      const highPriority = result.recommendations.filter(r => r.priority === 'high');
202      assert.ok(
203        highPriority.length > 0,
204        'should have high priority recommendation for low approval rate'
205      );
206    });
207  
208    test('includes analysis stats in result', async () => {
209      const result = await generatePromptRecommendations('PROPOSAL.md');
210      assert.ok('stats' in result);
211      assert.ok('distribution' in result);
212    });
213  });
214  
215  // ─── getPromptHistory ─────────────────────────────────────────────────────────
216  
217  describe('getPromptHistory', () => {
218    test('returns empty array when no versions exist', async () => {
219      const history = await getPromptHistory('NO-VERSIONS.md');
220      assert.deepEqual(history, []);
221    });
222  
223    test('returns array (possibly empty) for any prompt file', async () => {
224      const history = await getPromptHistory('PROPOSAL.md');
225      assert.ok(Array.isArray(history));
226    });
227  
228    test('respects limit parameter', async () => {
229      // Insert a few versions for testing
230      db.prepare(
231        `INSERT INTO prompt_versions (prompt_file, version, content) VALUES ('LIMIT-TEST.md', 1, 'v1')`
232      ).run();
233      db.prepare(
234        `INSERT INTO prompt_versions (prompt_file, version, content) VALUES ('LIMIT-TEST.md', 2, 'v2')`
235      ).run();
236      db.prepare(
237        `INSERT INTO prompt_versions (prompt_file, version, content) VALUES ('LIMIT-TEST.md', 3, 'v3')`
238      ).run();
239  
240      const history = await getPromptHistory('LIMIT-TEST.md', 2);
241      assert.equal(history.length, 2);
242    });
243  });