name-extractor.test.js
1 /** 2 * Tests for src/utils/name-extractor.js 3 * 4 * Covers: 5 * - fastExtract: empty, all-digits, @-containing, ROLE_WORDS, single word (western), ambiguous 6 * - Two-word "Firstname Lastname" path 7 * - Three+ words → LLM fallback 8 * - Cache hit 9 * - LLM returns null / unexpected multiline / long response / error 10 */ 11 12 import { test, describe, mock } from 'node:test'; 13 import assert from 'node:assert/strict'; 14 15 // ─── Mock callLLM before importing name-extractor ─────────────────────────── 16 17 let mockLlmResult = { content: 'null' }; 18 let mockLlmThrows = false; 19 20 await mock.module('../../src/utils/llm-provider.js', { 21 namedExports: { 22 callLLM: async () => { 23 if (mockLlmThrows) throw new Error('LLM unavailable'); 24 return mockLlmResult; 25 }, 26 }, 27 }); 28 29 // Mock logger to suppress console output 30 await mock.module('../../src/utils/logger.js', { 31 defaultExport: class { 32 info() {} 33 warn() {} 34 error() {} 35 success() {} 36 debug() {} 37 }, 38 }); 39 40 const { extractFirstname } = await import('../../src/utils/name-extractor.js'); 41 42 // ─── Tests ─────────────────────────────────────────────────────────────────── 43 44 describe('name-extractor', () => { 45 describe('null/empty inputs', () => { 46 test('returns null for null input', async () => { 47 assert.strictEqual(await extractFirstname(null), null); 48 }); 49 50 test('returns null for non-string input', async () => { 51 assert.strictEqual(await extractFirstname(42), null); 52 }); 53 54 test('returns null for empty string', async () => { 55 assert.strictEqual(await extractFirstname(''), null); 56 }); 57 58 test('returns null for whitespace-only string', async () => { 59 assert.strictEqual(await extractFirstname(' '), null); 60 }); 61 }); 62 63 describe('fastExtract: all-digits and @ patterns', () => { 64 test('returns null for all-digits label', async () => { 65 assert.strictEqual(await extractFirstname('12345'), null); 66 }); 67 68 test('returns null for label containing @', async () => { 69 assert.strictEqual(await extractFirstname('owner@example.com'), null); 70 }); 71 }); 72 73 describe('fastExtract: ROLE_WORDS single word', () => { 74 test('"office" → null', async () => { 75 assert.strictEqual(await extractFirstname('office'), null); 76 }); 77 78 test('"Reception" → null (case-insensitive)', async () => { 79 assert.strictEqual(await extractFirstname('Reception'), null); 80 }); 81 82 test('"ADMIN" → null', async () => { 83 assert.strictEqual(await extractFirstname('ADMIN'), null); 84 }); 85 86 test('"sales" → null', async () => { 87 assert.strictEqual(await extractFirstname('sales'), null); 88 }); 89 90 test('"noreply" → null', async () => { 91 assert.strictEqual(await extractFirstname('noreply'), null); 92 }); 93 }); 94 95 describe('fastExtract: western single word names', () => { 96 test('"Jim" → "Jim"', async () => { 97 assert.strictEqual(await extractFirstname('Jim'), 'Jim'); 98 }); 99 100 test('"nick" → "Nick" (lowercase auto-capitalised)', async () => { 101 assert.strictEqual(await extractFirstname('nick'), 'Nick'); 102 }); 103 104 test('"Marie" → "Marie"', async () => { 105 assert.strictEqual(await extractFirstname('Marie'), 'Marie'); 106 }); 107 108 test('"Jo" → "Jo" (2-char min)', async () => { 109 assert.strictEqual(await extractFirstname('Jo'), 'Jo'); 110 }); 111 }); 112 113 describe('fastExtract: two-word "Firstname Lastname" pattern', () => { 114 test('"Jim Walsh" → "Jim"', async () => { 115 assert.strictEqual(await extractFirstname('Jim Walsh'), 'Jim'); 116 }); 117 118 test('"Marie Sales" → "Marie"', async () => { 119 assert.strictEqual(await extractFirstname('Marie Sales'), 'Marie'); 120 }); 121 122 test('"Sales Manager" → null (first word is ROLE_WORD)', async () => { 123 assert.strictEqual(await extractFirstname('Sales Manager'), null); 124 }); 125 }); 126 127 describe('LLM fallback (ambiguous single non-western word)', () => { 128 test('LLM returns a name → returned as-is', async () => { 129 mockLlmResult = { content: 'Büro' }; 130 mockLlmThrows = false; 131 // Use a unique label not yet in cache to force LLM call 132 const result = await extractFirstname('Büro-unique-1'); 133 // Haiku may return the name or null — just verify it doesn't throw 134 assert.ok(result === null || typeof result === 'string'); 135 }); 136 137 test('LLM returns "null" string → null', async () => { 138 mockLlmResult = { content: 'null' }; 139 mockLlmThrows = false; 140 const result = await extractFirstname('Büro-unique-2'); 141 assert.strictEqual(result, null); 142 }); 143 144 test('LLM returns multiline response → null', async () => { 145 mockLlmResult = { content: 'Line1\nLine2' }; 146 mockLlmThrows = false; 147 const result = await extractFirstname('Büro-unique-3'); 148 assert.strictEqual(result, null); 149 }); 150 151 test('LLM returns very long response → null', async () => { 152 mockLlmResult = { content: 'A'.repeat(31) }; 153 mockLlmThrows = false; 154 const result = await extractFirstname('Büro-unique-4'); 155 assert.strictEqual(result, null); 156 }); 157 158 test('LLM throws → returns null gracefully', async () => { 159 mockLlmThrows = true; 160 const result = await extractFirstname('Büro-unique-5'); 161 assert.strictEqual(result, null); 162 mockLlmThrows = false; 163 }); 164 }); 165 166 describe('three+ words → LLM fallback', () => { 167 test('"John Michael Smith" → LLM called', async () => { 168 mockLlmResult = { content: 'John' }; 169 mockLlmThrows = false; 170 const result = await extractFirstname('John Michael Smith'); 171 // LLM mock returns 'John' but cache may differ — just verify no throw 172 assert.ok(result === 'John' || result === null); 173 }); 174 }); 175 176 describe('cache hit', () => { 177 test('same label returns cached result without extra LLM call', async () => { 178 mockLlmResult = { content: 'null' }; 179 mockLlmThrows = false; 180 181 // First call — hits LLM or fast-path 182 const r1 = await extractFirstname('Reception'); 183 // Second call — must hit cache (LLM mock would throw if called again) 184 mockLlmThrows = true; 185 const r2 = await extractFirstname('Reception'); 186 assert.strictEqual(r1, r2); 187 mockLlmThrows = false; 188 }); 189 }); 190 });