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.
package/.env CHANGED
@@ -14,7 +14,7 @@ REDIS_PASSWORD=OyLRjkTPuCGzQ2mNzA3Uhx5HIqWza8QC
14
14
  REDIS_USERNAME=default
15
15
 
16
16
  #GENKIT
17
- GENKIT_API_KEY=AIzaSyBB5ZYwktOFI3R3j_vs8U7CxwKgS3XNgM0
17
+ GENKIT_API_KEY=AQ.Ab8RN6Irt-ueXxfkkZm2MEkvaTr5HfHicLIjyYFHkrancWvQjA
18
18
 
19
19
  #OpenAI
20
20
  OPENAI_API_KEY=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
package/README.md CHANGED
Binary file
package/index.js CHANGED
@@ -53,6 +53,8 @@ const modules = {
53
53
  getAllYoutubeVideos: safeRequire('./src/utils', 'utils')?.getAllYoutubeVideos,
54
54
  getAllTestimonials: safeRequire('./src/utils', 'utils')?.getAllTestimonials,
55
55
  getAdditionalInstructions: safeRequire('./src/utils', 'utils')?.getAdditionalInstructions,
56
+ getTrainingData: safeRequire('./src/utils', 'utils')?.getTrainingData,
57
+ getChatbotWelcomeSettings: safeRequire('./src/utils', 'utils')?.getChatbotWelcomeSettings,
56
58
  };
57
59
 
58
60
  // Smart systems - Load with proper error handling and fallbacks
@@ -121,6 +123,43 @@ for (const [moduleName, modulePath] of Object.entries(smartModulePaths)) {
121
123
  }
122
124
  }
123
125
 
126
+ // Trend-aware knowledge
127
+ const trendModules = {
128
+ trendManager: safeRequire('./src/trendManager', 'trendManager'),
129
+ getTrendContext: safeRequire('./src/trendManager', 'trendManager')?.getTrendContext,
130
+ generateTrends: safeRequire('./src/trendManager', 'trendManager')?.generateTrends,
131
+ };
132
+
133
+ // Conversation-driving behavior
134
+ const conversationDriverModules = {
135
+ conversationDriver: safeRequire('./src/conversationDriver', 'conversationDriver'),
136
+ getConversationDirective: safeRequire('./src/conversationDriver', 'conversationDriver')?.getConversationDirective,
137
+ };
138
+
139
+ // Adaptive Personality Engine
140
+ const personalityModules = {
141
+ personaClassifier: safeRequire('./src/personaClassifier', 'personaClassifier'),
142
+ buildMessageContext: safeRequire('./src/messageContextBuilder', 'messageContextBuilder')?.buildMessageContext,
143
+ };
144
+
145
+ // Tool-enabled agents
146
+ const agentToolModules = {
147
+ agentTools: safeRequire('./src/agentTools', 'agentTools'),
148
+ globalTools: safeRequire('./src/agentTools', 'agentTools')?.globalTools,
149
+ createUserTools: safeRequire('./src/agentTools', 'agentTools')?.createUserTools,
150
+ };
151
+
152
+ // D2C funnel-event tracking
153
+ const funnelEventModules = {
154
+ logFunnelEvent: safeRequire('./src/funnelEvents', 'funnelEvents')?.logFunnelEvent,
155
+ };
156
+
157
+ // Local Agent Memory (self-hosted, TAM-inspired)
158
+ const agentMemoryModules = {
159
+ extractAndStoreMemories: safeRequire('./src/agentMemory', 'agentMemory')?.extractAndStoreMemories,
160
+ retrieveRelevantMemories: safeRequire('./src/agentMemory', 'agentMemory')?.retrieveRelevantMemories,
161
+ };
162
+
124
163
  // Enhanced systems
125
164
  const enhancedModules = {
126
165
  Logger: safeRequire('./src/logger', 'Logger')?.Logger,
@@ -135,7 +174,13 @@ const enhancedModules = {
135
174
  module.exports = {
136
175
  ...modules,
137
176
  ...smartModules,
138
- ...enhancedModules
177
+ ...enhancedModules,
178
+ ...trendModules,
179
+ ...conversationDriverModules,
180
+ ...personalityModules,
181
+ ...agentToolModules,
182
+ ...funnelEventModules,
183
+ ...agentMemoryModules,
139
184
  };
140
185
 
141
186
  // Log export summary
@@ -0,0 +1,25 @@
1
+ -- Migration 004: Partner products table
2
+ -- Stores products from Myavana partner brands that the AI can recommend.
3
+ -- The searchServices agent tool queries this table alongside Myavana's own services.
4
+
5
+ CREATE TABLE IF NOT EXISTS partner_products (
6
+ id SERIAL PRIMARY KEY,
7
+ brand_name VARCHAR(255) NOT NULL,
8
+ product_name VARCHAR(255) NOT NULL,
9
+ description TEXT,
10
+ category VARCHAR(100), -- e.g. 'shampoo', 'conditioner', 'styler', 'treatment'
11
+ hair_types TEXT[], -- e.g. ARRAY['4a','4b','4c']
12
+ hair_concerns TEXT[], -- e.g. ARRAY['moisture','breakage','frizz']
13
+ price_usd NUMERIC(8,2),
14
+ url TEXT,
15
+ image_url TEXT,
16
+ in_stock BOOLEAN DEFAULT TRUE,
17
+ active BOOLEAN DEFAULT TRUE,
18
+ created_at TIMESTAMP DEFAULT NOW(),
19
+ updated_at TIMESTAMP DEFAULT NOW(),
20
+ UNIQUE (brand_name, product_name)
21
+ );
22
+
23
+ CREATE INDEX IF NOT EXISTS idx_partner_products_brand ON partner_products (brand_name);
24
+ CREATE INDEX IF NOT EXISTS idx_partner_products_category ON partner_products (category);
25
+ CREATE INDEX IF NOT EXISTS idx_partner_products_active ON partner_products (active) WHERE active = TRUE;
@@ -0,0 +1,23 @@
1
+ -- Migration 005: Hair trends table
2
+ -- Stores AI-generated and admin-curated trend awareness data.
3
+ -- Refreshed every 48h by trendManager.generateTrends().
4
+ -- The trendManager serves relevant rows into per-message context so the AI
5
+ -- stays current without the trends being baked into the 24h cached system prompt.
6
+
7
+ CREATE TABLE IF NOT EXISTS hair_trends (
8
+ id SERIAL PRIMARY KEY,
9
+ trend_name VARCHAR(255) NOT NULL,
10
+ description TEXT NOT NULL, -- one sentence: what it is and why it works
11
+ category VARCHAR(50) NOT NULL, -- 'technique' | 'ingredient' | 'style' | 'wellness'
12
+ relevance_stages INT[], -- journey stage numbers this applies to (null = all)
13
+ hair_types TEXT[], -- hair types this is most relevant for (null = all)
14
+ trend_score FLOAT DEFAULT 0.5, -- 0.0-1.0, how current/hot this trend is
15
+ source VARCHAR(100) DEFAULT 'ai-generated', -- 'ai-generated' | 'admin-curated'
16
+ active BOOLEAN DEFAULT TRUE,
17
+ generated_at TIMESTAMP DEFAULT NOW(),
18
+ expires_at TIMESTAMP DEFAULT (NOW() + INTERVAL '48 hours')
19
+ );
20
+
21
+ CREATE INDEX IF NOT EXISTS idx_hair_trends_active ON hair_trends (active, expires_at);
22
+ CREATE INDEX IF NOT EXISTS idx_hair_trends_category ON hair_trends (category);
23
+ CREATE INDEX IF NOT EXISTS idx_hair_trends_stages ON hair_trends USING GIN (relevance_stages);
@@ -0,0 +1,17 @@
1
+ -- Migration 006: Chatbot funnel events
2
+ -- Tracks D2C conversion-funnel milestones (intake, paywall, purchase, HHCP delivery,
3
+ -- consult triage) emitted by the widget/backend, independent of the single-column
4
+ -- conversation_metrics.conversion_event, so funnel drop-off can be queried per stage.
5
+
6
+ CREATE TABLE IF NOT EXISTS chatbot_funnel_events (
7
+ id SERIAL PRIMARY KEY,
8
+ user_id VARCHAR(255),
9
+ conversation_id VARCHAR(255),
10
+ event_type VARCHAR(100), -- intake_completed | paywall_shown | purchase_completed | hhcp_delivered | consult_triaged
11
+ metadata JSONB,
12
+ created_at TIMESTAMP DEFAULT NOW()
13
+ );
14
+
15
+ CREATE INDEX IF NOT EXISTS idx_funnel_events_user ON chatbot_funnel_events (user_id);
16
+ CREATE INDEX IF NOT EXISTS idx_funnel_events_conversation ON chatbot_funnel_events (conversation_id);
17
+ CREATE INDEX IF NOT EXISTS idx_funnel_events_type_created ON chatbot_funnel_events (event_type, created_at);
@@ -0,0 +1,23 @@
1
+ -- Migration 007: Local Agent Memory (self-hosted, TAM-inspired)
2
+ -- Durable, semantic long-term memory for the D2C widget's context, built entirely
3
+ -- on this Postgres instance via pgvector (no external memory-service vendor).
4
+ -- Requires the `vector` extension to be enabled on the target Postgres instance
5
+ -- (supported on modern RDS Postgres versions) before this migration is run.
6
+
7
+ CREATE EXTENSION IF NOT EXISTS vector;
8
+
9
+ CREATE TABLE IF NOT EXISTS agent_memories (
10
+ id SERIAL PRIMARY KEY,
11
+ user_id VARCHAR(255) NOT NULL,
12
+ memory_type VARCHAR(50), -- fact | preference | episodic
13
+ content TEXT NOT NULL,
14
+ embedding vector(768), -- matches the Google embedding model's output dimension
15
+ importance_score FLOAT DEFAULT 0.5,
16
+ source_conversation_id VARCHAR(255),
17
+ created_at TIMESTAMP DEFAULT NOW(),
18
+ last_accessed_at TIMESTAMP DEFAULT NOW(),
19
+ access_count INTEGER DEFAULT 0
20
+ );
21
+
22
+ CREATE INDEX IF NOT EXISTS idx_agent_memories_user ON agent_memories (user_id);
23
+ CREATE INDEX IF NOT EXISTS idx_agent_memories_embedding ON agent_memories USING hnsw (embedding vector_cosine_ops);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "myavana-bot-test-core",
3
- "version": "2.2.0",
3
+ "version": "2.3.0",
4
4
  "description": "Shared bot functionality with enhanced features",
5
5
  "main": "index.js",
6
6
  "scripts": {
@@ -24,7 +24,8 @@
24
24
  "pg": "^8.13.1",
25
25
  "redis": "^4.7.0",
26
26
  "uuid": "^9.0.1",
27
- "winston": "^3.11.0"
27
+ "winston": "^3.11.0",
28
+ "zod": "^3.25.76"
28
29
  },
29
30
  "devDependencies": {
30
31
  "@babel/preset-env": "^7.23.6",
@@ -0,0 +1,165 @@
1
+ // agentTools.test.js
2
+ // Tests tool handler logic by capturing handlers at definition time via mock.
3
+ // jest.mock factory variables must be prefixed with `mock` to be hoisting-safe.
4
+
5
+ const mockQuery = jest.fn();
6
+ const mockCapturedHandlers = {};
7
+
8
+ jest.mock('../database', () => ({
9
+ pgClient: { query: mockQuery },
10
+ redisClient: { get: jest.fn(), set: jest.fn() },
11
+ }));
12
+
13
+ jest.mock('../ai', () => ({
14
+ defineTool: (config, fn) => {
15
+ mockCapturedHandlers[config.name] = fn;
16
+ return { __toolName: config.name };
17
+ },
18
+ dynamicTool: (config, fn) => {
19
+ mockCapturedHandlers[config.name] = fn;
20
+ return { __toolName: config.name };
21
+ },
22
+ }));
23
+
24
+ // Load module after mocks — defineTool/dynamicTool calls populate mockCapturedHandlers
25
+ const { createUserTools } = require('../agentTools');
26
+ // Trigger dynamic tool registration for user 'u1'
27
+ createUserTools('u1');
28
+
29
+ beforeEach(() => jest.clearAllMocks());
30
+
31
+ // ---------------------------------------------------------------------------
32
+ // searchServices
33
+ // ---------------------------------------------------------------------------
34
+
35
+ describe('searchServices', () => {
36
+ test('returns matching rows from products table', async () => {
37
+ mockQuery
38
+ .mockResolvedValueOnce({ rows: [{ product_id: 1, product_name: 'Hair Analysis' }] })
39
+ .mockRejectedValueOnce(new Error('relation "partner_products" does not exist'));
40
+
41
+ const result = await mockCapturedHandlers['searchServices']({ query: 'analysis', limit: 5 });
42
+ expect(result).toEqual([{ product_id: 1, product_name: 'Hair Analysis' }]);
43
+ });
44
+
45
+ test('includes partner products when table exists', async () => {
46
+ mockQuery
47
+ .mockResolvedValueOnce({ rows: [{ product_name: 'Myavana Analysis' }] })
48
+ .mockResolvedValueOnce({ rows: [{ product_name: 'Partner Cream', source: 'partner' }] });
49
+
50
+ const result = await mockCapturedHandlers['searchServices']({ query: 'moisture', limit: 5 });
51
+ expect(result).toHaveLength(2);
52
+ expect(result[1].source).toBe('partner');
53
+ });
54
+
55
+ test('returns not-found message when no rows match', async () => {
56
+ mockQuery
57
+ .mockResolvedValueOnce({ rows: [] })
58
+ .mockRejectedValueOnce(new Error('table missing'));
59
+
60
+ const result = await mockCapturedHandlers['searchServices']({ query: 'xyz123' });
61
+ expect(result[0].message).toMatch(/No services or products found/);
62
+ });
63
+
64
+ test('returns error object without throwing when DB fails', async () => {
65
+ mockQuery.mockRejectedValue(new Error('DB down'));
66
+ const result = await mockCapturedHandlers['searchServices']({ query: 'anything' });
67
+ expect(result[0].error).toBeDefined();
68
+ });
69
+
70
+ test('trims combined results to limit', async () => {
71
+ const services = Array.from({ length: 4 }, (_, i) => ({ product_name: `Service ${i}` }));
72
+ const partners = Array.from({ length: 4 }, (_, i) => ({ product_name: `Partner ${i}` }));
73
+ mockQuery
74
+ .mockResolvedValueOnce({ rows: services })
75
+ .mockResolvedValueOnce({ rows: partners });
76
+
77
+ const result = await mockCapturedHandlers['searchServices']({ query: 'hair', limit: 5 });
78
+ expect(result.length).toBeLessThanOrEqual(5);
79
+ });
80
+ });
81
+
82
+ // ---------------------------------------------------------------------------
83
+ // lookupFAQ
84
+ // ---------------------------------------------------------------------------
85
+
86
+ describe('lookupFAQ', () => {
87
+ test('returns matching FAQ rows', async () => {
88
+ const faqs = [{ question: 'How do I cancel?', answer: 'Email support@myavana.com' }];
89
+ mockQuery.mockResolvedValueOnce({ rows: faqs });
90
+
91
+ const result = await mockCapturedHandlers['lookupFAQ']({ query: 'cancel', limit: 3 });
92
+ expect(result).toEqual(faqs);
93
+ });
94
+
95
+ test('returns fallback instruction when no rows match', async () => {
96
+ mockQuery.mockResolvedValueOnce({ rows: [] });
97
+ const result = await mockCapturedHandlers['lookupFAQ']({ query: 'unknown topic' });
98
+ expect(result[0].answer).toMatch(/Answer based on your knowledge/);
99
+ });
100
+
101
+ test('returns error object without throwing when DB fails', async () => {
102
+ mockQuery.mockRejectedValue(new Error('DB down'));
103
+ const result = await mockCapturedHandlers['lookupFAQ']({ query: 'anything' });
104
+ expect(result[0].error).toBeDefined();
105
+ });
106
+ });
107
+
108
+ // ---------------------------------------------------------------------------
109
+ // saveHairGoal (dynamic — registered via createUserTools('u1'))
110
+ // ---------------------------------------------------------------------------
111
+
112
+ describe('saveHairGoal', () => {
113
+ test('upserts goal and returns saved:true', async () => {
114
+ mockQuery.mockResolvedValueOnce({ rows: [] });
115
+
116
+ const result = await mockCapturedHandlers['saveHairGoal']({ goal: 'grow 4 inches by December' });
117
+ expect(result).toEqual({ saved: true, goal: 'grow 4 inches by December' });
118
+ expect(mockQuery).toHaveBeenCalledWith(
119
+ expect.stringContaining('INSERT INTO user_preferences'),
120
+ expect.arrayContaining(['u1'])
121
+ );
122
+ });
123
+
124
+ test('returns saved:false without throwing when DB fails', async () => {
125
+ mockQuery.mockRejectedValue(new Error('DB error'));
126
+ const result = await mockCapturedHandlers['saveHairGoal']({ goal: 'retain moisture' });
127
+ expect(result).toEqual({ saved: false, goal: 'retain moisture' });
128
+ });
129
+ });
130
+
131
+ // ---------------------------------------------------------------------------
132
+ // getUserHairProfile (dynamic — registered via createUserTools('u1'))
133
+ // ---------------------------------------------------------------------------
134
+
135
+ describe('getUserHairProfile', () => {
136
+ test('returns profile with null fields stripped', async () => {
137
+ mockQuery.mockResolvedValueOnce({
138
+ rows: [{ hair_type: '4c', hair_concerns: ['dryness'], porosity: null, elasticity: null }],
139
+ });
140
+
141
+ const result = await mockCapturedHandlers['getUserHairProfile']({});
142
+ expect(result).toEqual({ hair_type: '4c', hair_concerns: ['dryness'] });
143
+ expect(result.porosity).toBeUndefined();
144
+ });
145
+
146
+ test('returns not-found message when user has no profile row', async () => {
147
+ mockQuery.mockResolvedValueOnce({ rows: [] });
148
+ const result = await mockCapturedHandlers['getUserHairProfile']({});
149
+ expect(result.message).toMatch(/No hair profile/);
150
+ });
151
+
152
+ test('returns message when profile row has all-null fields', async () => {
153
+ mockQuery.mockResolvedValueOnce({
154
+ rows: [{ hair_type: null, hair_concerns: null, porosity: null }],
155
+ });
156
+ const result = await mockCapturedHandlers['getUserHairProfile']({});
157
+ expect(result.message).toMatch(/no attributes recorded/);
158
+ });
159
+
160
+ test('returns error object without throwing when DB fails', async () => {
161
+ mockQuery.mockRejectedValue(new Error('DB down'));
162
+ const result = await mockCapturedHandlers['getUserHairProfile']({});
163
+ expect(result.error).toBeDefined();
164
+ });
165
+ });
@@ -0,0 +1,196 @@
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
+ jest.mock('../ai', () => ({}));
11
+ jest.mock('@genkit-ai/google-genai', () => ({
12
+ googleAI: { model: jest.fn(() => 'mocked-model') },
13
+ }));
14
+ jest.mock('../config', () => ({ genkitApiKey: 'test-key' }));
15
+
16
+ const mockCacheCreate = jest.fn();
17
+ jest.mock('@google/genai', () => ({
18
+ GoogleGenAI: jest.fn().mockImplementation(() => ({
19
+ caches: { create: mockCacheCreate },
20
+ })),
21
+ }));
22
+
23
+ const CacheManager = require('../cacheManager');
24
+
25
+ beforeEach(() => {
26
+ jest.clearAllMocks();
27
+ // Clear in-memory cache — it's a plain Map, not a Jest mock, so clearAllMocks won't reset it
28
+ CacheManager.memoryCache.clear();
29
+ });
30
+
31
+ const futureDate = new Date(Date.now() + 10 * 60 * 60 * 1000); // 10 hours from now
32
+ const pastDate = new Date(Date.now() - 1000); // 1 second ago
33
+ const nearExpiryDate = new Date(Date.now() + 30 * 60 * 1000); // 30 min from now (< 1hr threshold)
34
+
35
+ describe('isCacheExpired', () => {
36
+ test('returns false for future expiry', () => {
37
+ expect(CacheManager.isCacheExpired({ expires_at: futureDate })).toBe(false);
38
+ });
39
+
40
+ test('returns true for past expiry', () => {
41
+ expect(CacheManager.isCacheExpired({ expires_at: pastDate })).toBe(true);
42
+ });
43
+ });
44
+
45
+ describe('checkCacheValidity', () => {
46
+ test('returns hasValidCache=false when no cache in any layer', async () => {
47
+ mockQuery.mockResolvedValue({ rows: [] });
48
+ mockRedisGet.mockResolvedValue(null);
49
+
50
+ const result = await CacheManager.checkCacheValidity('u1');
51
+
52
+ expect(result.hasValidCache).toBe(false);
53
+ expect(result.reason).toBe('no_cache');
54
+ });
55
+
56
+ test('returns hasValidCache=false when cache is expired', async () => {
57
+ mockQuery.mockResolvedValue({
58
+ rows: [{ cache_name: 'cn1', created_at: new Date(), expires_at: pastDate }],
59
+ });
60
+
61
+ const result = await CacheManager.checkCacheValidity('u1');
62
+
63
+ expect(result.hasValidCache).toBe(false);
64
+ expect(result.reason).toBe('expired');
65
+ });
66
+
67
+ test('returns hasValidCache=true for valid cache', async () => {
68
+ mockQuery.mockResolvedValue({
69
+ rows: [{ cache_name: 'cn1', created_at: new Date(), expires_at: futureDate }],
70
+ });
71
+
72
+ const result = await CacheManager.checkCacheValidity('u1');
73
+
74
+ expect(result.hasValidCache).toBe(true);
75
+ expect(result.cacheName).toBe('cn1');
76
+ expect(result.needsRefresh).toBe(false);
77
+ });
78
+
79
+ test('sets needsRefresh=true when cache is near expiry', async () => {
80
+ mockQuery.mockResolvedValue({
81
+ rows: [{ cache_name: 'cn1', created_at: new Date(), expires_at: nearExpiryDate }],
82
+ });
83
+
84
+ const result = await CacheManager.checkCacheValidity('u1');
85
+
86
+ expect(result.hasValidCache).toBe(true);
87
+ expect(result.needsRefresh).toBe(true);
88
+ });
89
+
90
+ test('returns hasValidCache=false when all layers empty after DB error', async () => {
91
+ mockQuery.mockRejectedValue(new Error('DB down'));
92
+
93
+ const result = await CacheManager.checkCacheValidity('u1');
94
+
95
+ expect(result.hasValidCache).toBe(false);
96
+ // getUserCache catches internally and returns null, so reason is 'no_cache' not 'error'
97
+ expect(result.reason).toBe('no_cache');
98
+ });
99
+ });
100
+
101
+ describe('saveUserCacheMultiLayer', () => {
102
+ test('saves to all layers when all succeed', async () => {
103
+ mockQuery.mockResolvedValue({ rows: [] });
104
+ mockRedisSet.mockResolvedValue('OK');
105
+
106
+ await CacheManager.saveUserCacheMultiLayer('u1', 'cache-name-123');
107
+
108
+ expect(mockQuery).toHaveBeenCalled();
109
+ expect(mockRedisSet).toHaveBeenCalledWith(
110
+ 'gemini_cache:u1',
111
+ expect.any(String),
112
+ { EX: CacheManager.CACHE_TTL_SECONDS }
113
+ );
114
+ });
115
+
116
+ test('does NOT call setex (redis v3 API)', async () => {
117
+ mockQuery.mockResolvedValue({ rows: [] });
118
+ mockRedisSet.mockResolvedValue('OK');
119
+
120
+ await CacheManager.saveUserCacheMultiLayer('u1', 'cache-name-123');
121
+
122
+ // Ensure old redis v3 API is not used
123
+ expect(mockRedisSet).toHaveBeenCalled();
124
+ const call = mockRedisSet.mock.calls[0];
125
+ // redis v4 set(key, value, options) — third arg is options object, not a number
126
+ expect(typeof call[2]).toBe('object');
127
+ expect(call[2]).toHaveProperty('EX');
128
+ });
129
+
130
+ test('continues when Postgres fails but Redis succeeds', async () => {
131
+ mockQuery.mockRejectedValue(new Error('Postgres down'));
132
+ mockRedisSet.mockResolvedValue('OK');
133
+
134
+ // Should not throw — memory layer always succeeds
135
+ await expect(
136
+ CacheManager.saveUserCacheMultiLayer('u1', 'cache-name-123')
137
+ ).resolves.not.toThrow();
138
+ });
139
+
140
+ test('throws when all layers fail', async () => {
141
+ mockQuery.mockRejectedValue(new Error('Postgres down'));
142
+ mockRedisSet.mockRejectedValue(new Error('Redis down'));
143
+ // Memory always succeeds so this won't throw — the guard is for all 3 failing
144
+ // Simulate memory failure by spying
145
+ const spy = jest.spyOn(CacheManager, 'saveUserCacheToMemory').mockImplementation(() => {
146
+ throw new Error('Memory down');
147
+ });
148
+
149
+ await expect(
150
+ CacheManager.saveUserCacheMultiLayer('u1', 'cache-name-123')
151
+ ).rejects.toThrow('All cache storage layers failed');
152
+
153
+ spy.mockRestore();
154
+ });
155
+ });
156
+
157
+ describe('getOrCreateCache', () => {
158
+ test('returns existing cache when valid cache found', async () => {
159
+ mockQuery.mockResolvedValue({
160
+ rows: [{ cache_name: 'existing-cache', created_at: new Date(), expires_at: futureDate }],
161
+ });
162
+
163
+ const result = await CacheManager.getOrCreateCache('u1', 'system prompt text');
164
+
165
+ expect(result.cacheName).toBe('existing-cache');
166
+ expect(result.isNewCache).toBe(false);
167
+ expect(mockCacheCreate).not.toHaveBeenCalled();
168
+ });
169
+
170
+ test('creates new cache when none exists', async () => {
171
+ mockQuery.mockResolvedValue({ rows: [] });
172
+ mockRedisGet.mockResolvedValue(null);
173
+ mockCacheCreate.mockResolvedValue({ name: 'new-cache-name' });
174
+ mockRedisSet.mockResolvedValue('OK');
175
+
176
+ const result = await CacheManager.getOrCreateCache('u1', 'system prompt text');
177
+
178
+ expect(mockCacheCreate).toHaveBeenCalledWith(expect.objectContaining({
179
+ model: 'gemini-3.7-flash',
180
+ config: expect.objectContaining({ systemInstruction: 'system prompt text' }),
181
+ }));
182
+ expect(result.cacheName).toBe('new-cache-name');
183
+ expect(result.isNewCache).toBe(true);
184
+ });
185
+
186
+ test('returns null cacheName on creation failure', async () => {
187
+ mockQuery.mockResolvedValue({ rows: [] });
188
+ mockRedisGet.mockResolvedValue(null);
189
+ mockCacheCreate.mockRejectedValue(new Error('Gemini API error'));
190
+
191
+ const result = await CacheManager.getOrCreateCache('u1', 'system prompt text');
192
+
193
+ expect(result.cacheName).toBeNull();
194
+ expect(result.error).toBeDefined();
195
+ });
196
+ });