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 +1 -1
- package/README.md +0 -0
- package/index.js +46 -1
- package/migrations/004_add_partner_products.sql +25 -0
- package/migrations/005_add_hair_trends.sql +23 -0
- package/migrations/006_add_funnel_events.sql +17 -0
- package/migrations/007_add_agent_memory.sql +23 -0
- package/package.json +3 -2
- package/src/__tests__/agentTools.test.js +165 -0
- package/src/__tests__/cacheManager.test.js +196 -0
- package/src/__tests__/conversation.test.js +151 -0
- package/src/__tests__/conversationDriver.test.js +233 -0
- package/src/__tests__/messageContextBuilder.test.js +192 -0
- package/src/__tests__/personaClassifier.test.js +253 -0
- package/src/__tests__/postResponseProcessing.test.js +199 -0
- package/src/__tests__/trendManager.test.js +166 -0
- package/src/agentMemory.js +122 -0
- package/src/agentTools.js +236 -0
- package/src/ai.js +1 -1
- package/src/cacheManager.js +17 -7
- package/src/config.js +2 -2
- package/src/conversation.js +1 -1
- package/src/conversationDriver.js +230 -0
- package/src/funnelEvents.js +32 -0
- package/src/messageContextBuilder.js +84 -0
- package/src/personaClassifier.js +214 -0
- package/src/postResponseProcessing.js +33 -3
- package/src/trendManager.js +240 -0
- package/src/unifiedChatHandler.js +2 -2
- package/src/utils.js +27 -5
package/.env
CHANGED
|
@@ -14,7 +14,7 @@ REDIS_PASSWORD=OyLRjkTPuCGzQ2mNzA3Uhx5HIqWza8QC
|
|
|
14
14
|
REDIS_USERNAME=default
|
|
15
15
|
|
|
16
16
|
#GENKIT
|
|
17
|
-
GENKIT_API_KEY=
|
|
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.
|
|
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
|
+
});
|