myavana-bot-test-core 2.2.0 → 2.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,253 @@
1
+ const { pgClient, redisClient } = require('../database');
2
+
3
+ jest.mock('../database', () => ({
4
+ pgClient: { query: jest.fn() },
5
+ redisClient: { get: jest.fn(), set: jest.fn() },
6
+ }));
7
+
8
+ const {
9
+ classifyFromSignals,
10
+ buildContextBlock,
11
+ saveUserPersona,
12
+ getUserPersona,
13
+ classifyAndSavePersona,
14
+ PERSONA_TYPES,
15
+ COMMUNICATION_STYLES,
16
+ } = require('../personaClassifier');
17
+
18
+ beforeEach(() => jest.clearAllMocks());
19
+
20
+ describe('classifyFromSignals', () => {
21
+ test('overwhelmed: high desperation', () => {
22
+ const result = classifyFromSignals({
23
+ profileUpdates: { desperation_level_inferred: 'high' },
24
+ chatHistory: [],
25
+ });
26
+ expect(result.personaType).toBe(PERSONA_TYPES.OVERWHELMED);
27
+ expect(result.communicationStyle).toBe(COMMUNICATION_STYLES.NURTURING);
28
+ expect(result.toneDirectives).toContain('lead with empathy before solutions');
29
+ });
30
+
31
+ test('overwhelmed: low confidence', () => {
32
+ const result = classifyFromSignals({
33
+ profileUpdates: { confidence_level_inferred: 'low' },
34
+ chatHistory: [],
35
+ });
36
+ expect(result.personaType).toBe(PERSONA_TYPES.OVERWHELMED);
37
+ });
38
+
39
+ test('researcher: long messages', () => {
40
+ const longMessage = 'a'.repeat(210);
41
+ const result = classifyFromSignals({
42
+ profileUpdates: {},
43
+ chatHistory: [{ role: 'user', content: longMessage }],
44
+ });
45
+ expect(result.personaType).toBe(PERSONA_TYPES.RESEARCHER);
46
+ expect(result.communicationStyle).toBe(COMMUNICATION_STYLES.PROFESSIONAL);
47
+ });
48
+
49
+ test('researcher: high confidence', () => {
50
+ const result = classifyFromSignals({
51
+ profileUpdates: { confidence_level_inferred: 'high' },
52
+ chatHistory: [],
53
+ });
54
+ expect(result.personaType).toBe(PERSONA_TYPES.RESEARCHER);
55
+ });
56
+
57
+ test('explorer: experimenting interest', () => {
58
+ const result = classifyFromSignals({
59
+ profileUpdates: { experimenting_interest: true },
60
+ chatHistory: [],
61
+ });
62
+ expect(result.personaType).toBe(PERSONA_TYPES.EXPLORER);
63
+ expect(result.communicationStyle).toBe(COMMUNICATION_STYLES.CASUAL);
64
+ expect(result.toneDirectives).toContain('encourage curiosity and experimentation');
65
+ });
66
+
67
+ test('action-oriented: short messages after 3+ turns', () => {
68
+ const history = [
69
+ { role: 'user', content: 'hi' },
70
+ { role: 'user', content: 'ok' },
71
+ { role: 'user', content: 'yes' },
72
+ ];
73
+ const result = classifyFromSignals({ profileUpdates: {}, chatHistory: history });
74
+ expect(result.personaType).toBe(PERSONA_TYPES.ACTION_ORIENTED);
75
+ expect(result.communicationStyle).toBe(COMMUNICATION_STYLES.CONCISE);
76
+ });
77
+
78
+ test('engaged: default when no signals match', () => {
79
+ const result = classifyFromSignals({ profileUpdates: {}, chatHistory: [] });
80
+ expect(result.personaType).toBe(PERSONA_TYPES.ENGAGED);
81
+ });
82
+
83
+ test('boredom adds tone directive regardless of persona', () => {
84
+ const result = classifyFromSignals({
85
+ profileUpdates: { boredom_expression: true },
86
+ chatHistory: [],
87
+ });
88
+ expect(result.toneDirectives).toContain(
89
+ 'vary your approach — try an unexpected angle or a direct question'
90
+ );
91
+ });
92
+
93
+ test('overwhelmed takes priority over researcher when both desperation and long messages', () => {
94
+ const result = classifyFromSignals({
95
+ profileUpdates: { desperation_level_inferred: 'high' },
96
+ chatHistory: [{ role: 'user', content: 'a'.repeat(210) }],
97
+ });
98
+ expect(result.personaType).toBe(PERSONA_TYPES.OVERWHELMED);
99
+ });
100
+ });
101
+
102
+ describe('buildContextBlock', () => {
103
+ test('returns formatted context string', () => {
104
+ const block = buildContextBlock({
105
+ personaType: 'overwhelmed',
106
+ communicationStyle: 'nurturing',
107
+ toneDirectives: ['lead with empathy'],
108
+ });
109
+ expect(block).toBe('[User persona: overwhelmed, style: nurturing — lead with empathy]\n');
110
+ });
111
+
112
+ test('returns empty string when personaType is missing', () => {
113
+ expect(buildContextBlock({})).toBe('');
114
+ expect(buildContextBlock()).toBe('');
115
+ });
116
+
117
+ test('omits directive separator when no directives and no trends', () => {
118
+ const block = buildContextBlock({
119
+ personaType: 'engaged',
120
+ communicationStyle: 'casual',
121
+ toneDirectives: [],
122
+ });
123
+ expect(block).toBe('[User persona: engaged, style: casual]\n');
124
+ });
125
+
126
+ test('joins multiple directives with semicolons', () => {
127
+ const block = buildContextBlock({
128
+ personaType: 'overwhelmed',
129
+ communicationStyle: 'nurturing',
130
+ toneDirectives: ['empathy first', 'small steps'],
131
+ });
132
+ expect(block).toContain('empathy first; small steps');
133
+ });
134
+
135
+ test('appends trend framing instruction when hasTrends=true', () => {
136
+ const block = buildContextBlock({
137
+ personaType: 'researcher',
138
+ communicationStyle: 'professional',
139
+ toneDirectives: [],
140
+ }, true);
141
+ expect(block).toContain('evidence or mechanism');
142
+ });
143
+
144
+ test('no trend framing when hasTrends=false', () => {
145
+ const block = buildContextBlock({
146
+ personaType: 'researcher',
147
+ communicationStyle: 'professional',
148
+ toneDirectives: [],
149
+ }, false);
150
+ expect(block).toBe('[User persona: researcher, style: professional]\n');
151
+ });
152
+
153
+ test('explorer gets casual trend framing when hasTrends=true', () => {
154
+ const block = buildContextBlock({
155
+ personaType: 'explorer',
156
+ communicationStyle: 'casual',
157
+ toneDirectives: [],
158
+ }, true);
159
+ expect(block).toContain('exciting things worth trying');
160
+ });
161
+ });
162
+
163
+ describe('saveUserPersona', () => {
164
+ test('upserts to PostgreSQL and sets Redis', async () => {
165
+ pgClient.query.mockResolvedValue({ rows: [] });
166
+ redisClient.set.mockResolvedValue('OK');
167
+
168
+ await saveUserPersona('u1', {
169
+ personaType: 'researcher',
170
+ communicationStyle: 'professional',
171
+ toneDirectives: ['skip basics'],
172
+ });
173
+
174
+ expect(pgClient.query).toHaveBeenCalledWith(
175
+ expect.stringContaining('INSERT INTO user_preferences'),
176
+ ['u1', 'researcher', 'professional', JSON.stringify({ toneDirectives: ['skip basics'] })]
177
+ );
178
+ expect(redisClient.set).toHaveBeenCalledWith(
179
+ 'persona:u1',
180
+ JSON.stringify({ personaType: 'researcher', communicationStyle: 'professional', toneDirectives: ['skip basics'] }),
181
+ { EX: 86400 }
182
+ );
183
+ });
184
+
185
+ test('does not throw when PostgreSQL fails', async () => {
186
+ pgClient.query.mockRejectedValue(new Error('DB down'));
187
+ redisClient.set.mockResolvedValue('OK');
188
+ await expect(saveUserPersona('u1', { personaType: 'engaged', communicationStyle: 'casual', toneDirectives: [] })).resolves.not.toThrow();
189
+ });
190
+
191
+ test('does not throw when Redis fails', async () => {
192
+ pgClient.query.mockResolvedValue({ rows: [] });
193
+ redisClient.set.mockRejectedValue(new Error('Redis down'));
194
+ await expect(saveUserPersona('u1', { personaType: 'engaged', communicationStyle: 'casual', toneDirectives: [] })).resolves.not.toThrow();
195
+ });
196
+ });
197
+
198
+ describe('getUserPersona', () => {
199
+ test('returns from Redis cache when present', async () => {
200
+ const cached = { personaType: 'explorer', communicationStyle: 'casual', toneDirectives: [] };
201
+ redisClient.get.mockResolvedValue(JSON.stringify(cached));
202
+
203
+ const result = await getUserPersona('u1');
204
+ expect(result).toEqual(cached);
205
+ expect(pgClient.query).not.toHaveBeenCalled();
206
+ });
207
+
208
+ test('falls back to PostgreSQL when Redis miss', async () => {
209
+ redisClient.get.mockResolvedValue(null);
210
+ pgClient.query.mockResolvedValue({
211
+ rows: [{
212
+ persona_type: 'researcher',
213
+ communication_style: 'professional',
214
+ engagement_preferences: { toneDirectives: ['skip basics'] },
215
+ }],
216
+ });
217
+
218
+ const result = await getUserPersona('u1');
219
+ expect(result).toEqual({
220
+ personaType: 'researcher',
221
+ communicationStyle: 'professional',
222
+ toneDirectives: ['skip basics'],
223
+ });
224
+ });
225
+
226
+ test('returns null when not found anywhere', async () => {
227
+ redisClient.get.mockResolvedValue(null);
228
+ pgClient.query.mockResolvedValue({ rows: [] });
229
+ expect(await getUserPersona('u1')).toBeNull();
230
+ });
231
+
232
+ test('returns null and does not throw when Redis errors', async () => {
233
+ redisClient.get.mockRejectedValue(new Error('Redis error'));
234
+ pgClient.query.mockResolvedValue({ rows: [] });
235
+ expect(await getUserPersona('u1')).toBeNull();
236
+ });
237
+ });
238
+
239
+ describe('classifyAndSavePersona', () => {
240
+ test('classifies and saves, returns persona', async () => {
241
+ pgClient.query.mockResolvedValue({ rows: [] });
242
+ redisClient.set.mockResolvedValue('OK');
243
+
244
+ const result = await classifyAndSavePersona('u1', {
245
+ profileUpdates: { desperation_level_inferred: 'high' },
246
+ chatHistory: [],
247
+ });
248
+
249
+ expect(result.personaType).toBe(PERSONA_TYPES.OVERWHELMED);
250
+ expect(pgClient.query).toHaveBeenCalled();
251
+ expect(redisClient.set).toHaveBeenCalled();
252
+ });
253
+ });
@@ -0,0 +1,199 @@
1
+ const mockQuery = jest.fn();
2
+ const mockRedisGet = jest.fn();
3
+ const mockRedisSet = jest.fn();
4
+
5
+ jest.mock('../database', () => ({
6
+ pgClient: { query: mockQuery },
7
+ redisClient: { get: mockRedisGet, set: mockRedisSet },
8
+ }));
9
+
10
+ const mockChatSend = jest.fn();
11
+ const mockChat = jest.fn(() => ({ send: mockChatSend }));
12
+ const mockUpdateState = jest.fn().mockResolvedValue({});
13
+ const mockLoadSession = jest.fn().mockResolvedValue({ updateState: mockUpdateState });
14
+ jest.mock('../ai', () => ({ chat: mockChat, loadSession: mockLoadSession }));
15
+ jest.mock('@genkit-ai/google-genai', () => ({
16
+ googleAI: { model: jest.fn(() => 'mocked-model') },
17
+ }));
18
+
19
+ const mockSaveHairIssue = jest.fn();
20
+ jest.mock('../hairIssues', () => ({ saveHairIssue: mockSaveHairIssue }));
21
+
22
+ const mockGetProductByName = jest.fn();
23
+ const mockAddProductRecommendation = jest.fn();
24
+ jest.mock('../utils', () => ({
25
+ getProductByName: mockGetProductByName,
26
+ addProductRecommendation: mockAddProductRecommendation,
27
+ }));
28
+
29
+ const mockSaveConversation = jest.fn();
30
+ const mockSaveConversationSummary = jest.fn();
31
+ const mockGenerateAndSaveSummary = jest.fn();
32
+ jest.mock('../conversation', () => ({
33
+ saveConversation: mockSaveConversation,
34
+ saveConversationSummary: mockSaveConversationSummary,
35
+ generateAndSaveSummary: mockGenerateAndSaveSummary,
36
+ }));
37
+
38
+ jest.mock('../session', () => jest.fn().mockImplementation(() => ({})));
39
+
40
+ const {
41
+ updateUserProfile,
42
+ postResponseProductCheck,
43
+ postResponseProcessing,
44
+ postProcessConversation,
45
+ } = require('../postResponseProcessing');
46
+
47
+ beforeEach(() => {
48
+ jest.clearAllMocks();
49
+ });
50
+
51
+ describe('updateUserProfile', () => {
52
+ test('does nothing when updates is empty', async () => {
53
+ const result = await updateUserProfile('u1', {});
54
+ expect(result).toBeNull();
55
+ expect(mockQuery).not.toHaveBeenCalled();
56
+ });
57
+
58
+ test('filters null values and updates DB + Redis', async () => {
59
+ mockQuery.mockResolvedValueOnce({ rows: [{ user_id: 'u1', hair_type: '4c' }] });
60
+ mockRedisSet.mockResolvedValue('OK');
61
+
62
+ const updates = { hair_type: '4c', porosity: null, name: 'Alex' };
63
+ const result = await updateUserProfile('u1', updates);
64
+
65
+ const dbCall = mockQuery.mock.calls.find(c => c[0].text && c[0].text.includes('UPDATE users'));
66
+ expect(dbCall).toBeDefined();
67
+ // null values should be filtered — only hair_type and name
68
+ expect(dbCall[0].values).not.toContain(null);
69
+ expect(mockRedisSet).toHaveBeenCalledWith('user:u1', expect.any(String));
70
+ });
71
+
72
+ test('returns null when user not found', async () => {
73
+ mockQuery.mockResolvedValueOnce({ rows: [] });
74
+
75
+ const result = await updateUserProfile('u1', { hair_type: '4c' });
76
+ expect(result).toBeNull();
77
+ });
78
+ });
79
+
80
+ describe('postResponseProductCheck', () => {
81
+ test('returns parsed product array on success', async () => {
82
+ const products = [{ productName: 'HairSI Kit', reason: 'For damage repair' }];
83
+ mockChatSend.mockResolvedValue({ output: JSON.stringify(products) });
84
+
85
+ const result = await postResponseProductCheck([], 'bot response');
86
+
87
+ expect(result).toEqual(products);
88
+ });
89
+
90
+ test('strips markdown code blocks before parsing', async () => {
91
+ const products = [{ productName: 'HairAI', reason: 'Quick analysis' }];
92
+ mockChatSend.mockResolvedValue({ output: '```json\n' + JSON.stringify(products) + '\n```' });
93
+
94
+ const result = await postResponseProductCheck([], 'bot response');
95
+
96
+ expect(result).toEqual(products);
97
+ });
98
+
99
+ test('returns empty array when output is empty', async () => {
100
+ mockChatSend.mockResolvedValue({ output: '' });
101
+
102
+ const result = await postResponseProductCheck([], 'bot response');
103
+
104
+ expect(result).toEqual([]);
105
+ });
106
+
107
+ test('returns empty array when output is []', async () => {
108
+ mockChatSend.mockResolvedValue({ output: '[]' });
109
+
110
+ const result = await postResponseProductCheck([], 'bot response');
111
+
112
+ expect(result).toEqual([]);
113
+ });
114
+
115
+ test('returns empty array when output is not a JSON array', async () => {
116
+ mockChatSend.mockResolvedValue({ output: '{"product": "x"}' });
117
+
118
+ const result = await postResponseProductCheck([], 'bot response');
119
+
120
+ expect(result).toEqual([]);
121
+ });
122
+
123
+ test('returns empty array on AI failure', async () => {
124
+ mockChat.mockImplementationOnce(() => { throw new Error('AI error'); });
125
+
126
+ const result = await postResponseProductCheck([], 'bot response');
127
+
128
+ expect(result).toEqual([]);
129
+ });
130
+ });
131
+
132
+ describe('postResponseProcessing', () => {
133
+ const history = [{ role: 'user', content: 'hi' }];
134
+
135
+ test('updates user profile when profile updates present', async () => {
136
+ mockQuery.mockResolvedValue({ rows: [{ user_id: 'u1' }] });
137
+ mockRedisSet.mockResolvedValue('OK');
138
+ mockChatSend.mockResolvedValue({ output: '[]' }); // product check
139
+ mockSaveConversation.mockResolvedValue({});
140
+ mockSaveConversationSummary.mockResolvedValue({});
141
+
142
+ const postProcessData = {
143
+ user_profile_updates: { hair_type: '4c' },
144
+ hair_issue_extractions: null,
145
+ };
146
+
147
+ await postResponseProcessing(postProcessData, 'u1', 'output', 'summary', 'c1', history, '{}');
148
+
149
+ const dbCall = mockQuery.mock.calls.find(c => c[0]?.text?.includes('UPDATE users'));
150
+ expect(dbCall).toBeDefined();
151
+ });
152
+
153
+ test('saves hair issues when present', async () => {
154
+ mockQuery.mockResolvedValue({ rows: [] });
155
+ mockChatSend.mockResolvedValue({ output: '[]' });
156
+ mockSaveConversation.mockResolvedValue({});
157
+ mockSaveConversationSummary.mockResolvedValue({});
158
+
159
+ const postProcessData = {
160
+ user_profile_updates: null,
161
+ hair_issue_extractions: { hair_issues_reported: ['dryness', 'breakage'] },
162
+ };
163
+
164
+ await postResponseProcessing(postProcessData, 'u1', 'output', 'summary', 'c1', history, '{}');
165
+
166
+ expect(mockSaveHairIssue).toHaveBeenCalledTimes(2);
167
+ expect(mockSaveHairIssue).toHaveBeenCalledWith('u1', 'dryness', expect.any(String));
168
+ expect(mockSaveHairIssue).toHaveBeenCalledWith('u1', 'breakage', expect.any(String));
169
+ });
170
+
171
+ test('saves product recommendations when products found', async () => {
172
+ mockQuery.mockResolvedValue({ rows: [] });
173
+ const products = [{ productName: 'HairSI Kit', reason: 'Damage repair' }];
174
+ mockChatSend.mockResolvedValue({ output: JSON.stringify(products) });
175
+ mockGetProductByName.mockResolvedValue({ product_id: 'p1' });
176
+ mockAddProductRecommendation.mockResolvedValue({});
177
+ mockSaveConversation.mockResolvedValue({});
178
+ mockSaveConversationSummary.mockResolvedValue({});
179
+
180
+ const postProcessData = { user_profile_updates: null, hair_issue_extractions: null };
181
+
182
+ await postResponseProcessing(postProcessData, 'u1', 'output', 'summary', 'c1', history, '{}');
183
+
184
+ expect(mockAddProductRecommendation).toHaveBeenCalledWith('u1', 'c1', 'p1', 'Damage repair');
185
+ });
186
+
187
+ test('saves conversation at end', async () => {
188
+ mockQuery.mockResolvedValue({ rows: [] });
189
+ mockChatSend.mockResolvedValue({ output: '[]' });
190
+ mockSaveConversation.mockResolvedValue({ conversation_id: 'c1' });
191
+ mockSaveConversationSummary.mockResolvedValue({});
192
+
193
+ const postProcessData = { user_profile_updates: null, hair_issue_extractions: null };
194
+
195
+ await postResponseProcessing(postProcessData, 'u1', 'output', 'summary', 'c1', history, '{}');
196
+
197
+ expect(mockSaveConversation).toHaveBeenCalledWith('c1', 'u1', history, 'summary');
198
+ });
199
+ });
@@ -0,0 +1,166 @@
1
+ const mockQuery = jest.fn();
2
+ const mockRedisGet = jest.fn();
3
+ const mockRedisSet = jest.fn();
4
+ const mockRedisDel = jest.fn();
5
+
6
+ jest.mock('../database', () => ({
7
+ pgClient: { query: mockQuery },
8
+ redisClient: { get: mockRedisGet, set: mockRedisSet, del: mockRedisDel },
9
+ }));
10
+
11
+ // Prevent real AI calls
12
+ jest.mock('../ai', () => ({ generate: jest.fn() }));
13
+ jest.mock('@genkit-ai/google-genai', () => ({
14
+ googleAI: { model: jest.fn(name => `mock:${name}`) },
15
+ }));
16
+
17
+ const { getTrendContext, getRelevantTrends, isTrendsStale, selectTrends } = require('../trendManager');
18
+
19
+ const SAMPLE_TRENDS = [
20
+ { trend_name: 'Scalp Detox Routine', description: 'Remove buildup for healthier growth', category: 'technique', relevance_stages: [1, 3, 7], trend_score: 0.9 },
21
+ { trend_name: 'Rice Water Protein', description: 'Fermented rice water strengthens strands', category: 'ingredient', relevance_stages: [6, 7], trend_score: 0.85 },
22
+ { trend_name: 'Silk Bonnet Sleeping', description: 'Reduce friction and retain moisture overnight', category: 'wellness', relevance_stages: [2, 8], trend_score: 0.75 },
23
+ { trend_name: 'Low Manipulation Braid', description: 'Protective styles for length retention', category: 'style', relevance_stages: [4, 5, 6], trend_score: 0.7 },
24
+ ];
25
+
26
+ beforeEach(() => jest.clearAllMocks());
27
+
28
+ // ---------------------------------------------------------------------------
29
+ // selectTrends
30
+ // ---------------------------------------------------------------------------
31
+
32
+ describe('selectTrends', () => {
33
+ test('returns empty array for empty input', () => {
34
+ expect(selectTrends([], 5, 3)).toEqual([]);
35
+ });
36
+
37
+ test('filters by stage and puts matches first', () => {
38
+ const result = selectTrends(SAMPLE_TRENDS, 6, 3);
39
+ expect(result[0].trend_name).toBe('Rice Water Protein');
40
+ expect(result).toHaveLength(3);
41
+ });
42
+
43
+ test('fills remaining slots with non-matching trends', () => {
44
+ const result = selectTrends(SAMPLE_TRENDS, 6, 4);
45
+ const names = result.map(t => t.trend_name);
46
+ expect(names).toContain('Rice Water Protein');
47
+ expect(names).toContain('Scalp Detox Routine'); // not stage 6 but fills slot
48
+ });
49
+
50
+ test('respects limit', () => {
51
+ expect(selectTrends(SAMPLE_TRENDS, null, 2)).toHaveLength(2);
52
+ });
53
+
54
+ test('returns top by score when stage is null', () => {
55
+ const result = selectTrends(SAMPLE_TRENDS, null, 2);
56
+ expect(result[0].trend_score).toBeGreaterThanOrEqual(result[1].trend_score);
57
+ });
58
+ });
59
+
60
+ // ---------------------------------------------------------------------------
61
+ // isTrendsStale
62
+ // ---------------------------------------------------------------------------
63
+
64
+ describe('isTrendsStale', () => {
65
+ test('returns true when no trends in DB', async () => {
66
+ mockQuery.mockResolvedValueOnce({ rows: [{ latest: null }] });
67
+ expect(await isTrendsStale()).toBe(true);
68
+ });
69
+
70
+ test('returns true when latest trend is older than 48h', async () => {
71
+ const old = new Date(Date.now() - 49 * 60 * 60 * 1000);
72
+ mockQuery.mockResolvedValueOnce({ rows: [{ latest: old }] });
73
+ expect(await isTrendsStale()).toBe(true);
74
+ });
75
+
76
+ test('returns false when trends are fresh', async () => {
77
+ const fresh = new Date(Date.now() - 1 * 60 * 60 * 1000);
78
+ mockQuery.mockResolvedValueOnce({ rows: [{ latest: fresh }] });
79
+ expect(await isTrendsStale()).toBe(false);
80
+ });
81
+
82
+ test('returns false without throwing when DB fails', async () => {
83
+ mockQuery.mockRejectedValueOnce(new Error('DB down'));
84
+ expect(await isTrendsStale()).toBe(false);
85
+ });
86
+ });
87
+
88
+ // ---------------------------------------------------------------------------
89
+ // getRelevantTrends
90
+ // ---------------------------------------------------------------------------
91
+
92
+ describe('getRelevantTrends', () => {
93
+ test('returns from Redis cache when available', async () => {
94
+ mockRedisGet.mockResolvedValueOnce(JSON.stringify(SAMPLE_TRENDS));
95
+
96
+ const result = await getRelevantTrends(6, 2);
97
+ expect(mockQuery).not.toHaveBeenCalled();
98
+ expect(result).toHaveLength(2);
99
+ expect(result[0].trend_name).toBe('Rice Water Protein'); // stage 6 match first
100
+ });
101
+
102
+ test('falls back to DB when Redis misses', async () => {
103
+ mockRedisGet.mockResolvedValueOnce(null);
104
+ mockQuery.mockResolvedValueOnce({ rows: SAMPLE_TRENDS });
105
+ mockRedisSet.mockResolvedValueOnce('OK');
106
+
107
+ const result = await getRelevantTrends(null, 3);
108
+ expect(mockQuery).toHaveBeenCalled();
109
+ expect(result).toHaveLength(3);
110
+ });
111
+
112
+ test('populates Redis after DB fetch', async () => {
113
+ mockRedisGet.mockResolvedValueOnce(null);
114
+ mockQuery.mockResolvedValueOnce({ rows: SAMPLE_TRENDS });
115
+ mockRedisSet.mockResolvedValueOnce('OK');
116
+
117
+ await getRelevantTrends(null, 2);
118
+ expect(mockRedisSet).toHaveBeenCalledWith(
119
+ 'hair_trends:current',
120
+ expect.any(String),
121
+ { EX: expect.any(Number) }
122
+ );
123
+ });
124
+
125
+ test('returns empty array without throwing when DB fails', async () => {
126
+ mockRedisGet.mockResolvedValueOnce(null);
127
+ mockQuery.mockRejectedValueOnce(new Error('DB down'));
128
+ expect(await getRelevantTrends(5, 2)).toEqual([]);
129
+ });
130
+ });
131
+
132
+ // ---------------------------------------------------------------------------
133
+ // getTrendContext
134
+ // ---------------------------------------------------------------------------
135
+
136
+ describe('getTrendContext', () => {
137
+ test('returns formatted trend block', async () => {
138
+ mockRedisGet.mockResolvedValueOnce(JSON.stringify(SAMPLE_TRENDS));
139
+ // isTrendsStale call (background) → DB check
140
+ mockQuery.mockResolvedValue({ rows: [{ latest: new Date() }] });
141
+
142
+ const result = await getTrendContext(3, 'explorer');
143
+ expect(result).toMatch(/^\[Trending in hair care:/);
144
+ expect(result).toMatch(/\]\n$/);
145
+ });
146
+
147
+ test('returns empty string when no trends exist', async () => {
148
+ mockRedisGet.mockResolvedValueOnce(null);
149
+ mockQuery
150
+ .mockResolvedValueOnce({ rows: [] }) // getRelevantTrends DB query
151
+ .mockResolvedValueOnce({ rows: [{ latest: null }] }); // isTrendsStale
152
+
153
+ const result = await getTrendContext(null, null);
154
+ expect(result).toBe('');
155
+ });
156
+
157
+ test('trend names are lowercased in output', async () => {
158
+ mockRedisGet.mockResolvedValueOnce(JSON.stringify([
159
+ { trend_name: 'Scalp Detox Routine', relevance_stages: [], trend_score: 0.9 },
160
+ ]));
161
+ mockQuery.mockResolvedValue({ rows: [{ latest: new Date() }] });
162
+
163
+ const result = await getTrendContext(null, null);
164
+ expect(result).toContain('scalp detox routine');
165
+ });
166
+ });