/ tests / utils / name-extractor.test.js
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  });