Skip to content

Commit 6ac12f8

Browse files
Copilothotlong
andcommitted
feat: add structured pino logger in core, CacheManager.resetInstance(), and tests
Co-authored-by: hotlong <50353452+hotlong@users.noreply.github.com>
1 parent 5cf6084 commit 6ac12f8

5 files changed

Lines changed: 197 additions & 0 deletions

File tree

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
2+
import { CacheManager } from '../../src/cache-manager';
3+
4+
describe('CacheManager — resetInstance test isolation', () => {
5+
afterEach(() => {
6+
CacheManager.resetInstance();
7+
});
8+
9+
it('should fully reset singleton so next getInstance creates fresh instance', async () => {
10+
const cm1 = CacheManager.getInstance();
11+
await cm1.set('key', 'value', 60);
12+
expect(await cm1.get('key')).toBe('value');
13+
14+
CacheManager.resetInstance();
15+
16+
const cm2 = CacheManager.getInstance();
17+
// After reset, the new instance should have an empty cache
18+
expect(await cm2.get('key')).toBeNull();
19+
expect(cm2.getStats().size).toBe(0);
20+
});
21+
22+
it('should allow a different config after reset', () => {
23+
const cm1 = CacheManager.getInstance({ defaultTtl: 300 });
24+
CacheManager.resetInstance();
25+
26+
const cm2 = CacheManager.getInstance({ defaultTtl: 600 });
27+
// Should be a brand new instance (not same reference as cm1)
28+
expect(cm2).not.toBe(cm1);
29+
});
30+
31+
it('should not throw when called before any getInstance', () => {
32+
// Fresh state — no instance exists yet
33+
expect(() => CacheManager.resetInstance()).not.toThrow();
34+
});
35+
36+
it('should not throw when called twice in a row', () => {
37+
CacheManager.getInstance();
38+
CacheManager.resetInstance();
39+
expect(() => CacheManager.resetInstance()).not.toThrow();
40+
});
41+
42+
it('should isolate cache state between test runs', async () => {
43+
// Simulate test A
44+
const cmA = CacheManager.getInstance();
45+
await cmA.set('shared', 'from-test-a', 60);
46+
CacheManager.resetInstance();
47+
48+
// Simulate test B — should not see test A's data
49+
const cmB = CacheManager.getInstance();
50+
expect(await cmB.get('shared')).toBeNull();
51+
});
52+
});

packages/ai/src/cache-manager.ts

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,19 @@ export class CacheManager {
5757
return CacheManager.instance;
5858
}
5959

60+
/**
61+
* Reset singleton instance for test isolation.
62+
* Call in beforeEach/afterEach to ensure a clean slate between tests.
63+
*/
64+
static resetInstance(): void {
65+
if (CacheManager.instance) {
66+
CacheManager.instance.memoryCache.clear();
67+
CacheManager.instance.redisClient = null;
68+
CacheManager.instance.useRedis = false;
69+
}
70+
CacheManager.instance = undefined as unknown as CacheManager;
71+
}
72+
6073
/**
6174
* Initialize Redis client
6275
*/
Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,95 @@
1+
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
2+
import { createLogger, logger } from '../../src/logger';
3+
4+
describe('Logger', () => {
5+
const originalEnv = process.env.LOG_LEVEL;
6+
7+
afterEach(() => {
8+
if (originalEnv === undefined) {
9+
delete process.env.LOG_LEVEL;
10+
} else {
11+
process.env.LOG_LEVEL = originalEnv;
12+
}
13+
});
14+
15+
describe('createLogger', () => {
16+
it('should create a logger with the specified module name', () => {
17+
const log = createLogger('crm:account');
18+
// pino exposes bindings() which includes "name"
19+
expect((log as any).bindings().name).toBe('crm:account');
20+
});
21+
22+
it('should default to info level', () => {
23+
const log = createLogger('test:module');
24+
expect(log.level).toBe('info');
25+
});
26+
27+
it('should accept a custom level via options', () => {
28+
const log = createLogger('test:debug', { level: 'debug' });
29+
expect(log.level).toBe('debug');
30+
});
31+
32+
it('should respect LOG_LEVEL env variable', () => {
33+
process.env.LOG_LEVEL = 'warn';
34+
const log = createLogger('test:env');
35+
expect(log.level).toBe('warn');
36+
});
37+
38+
it('should prefer explicit option over env variable', () => {
39+
process.env.LOG_LEVEL = 'warn';
40+
const log = createLogger('test:override', { level: 'error' });
41+
expect(log.level).toBe('error');
42+
});
43+
});
44+
45+
describe('logger (default instance)', () => {
46+
it('should be a valid pino logger', () => {
47+
expect(typeof logger.info).toBe('function');
48+
expect(typeof logger.warn).toBe('function');
49+
expect(typeof logger.error).toBe('function');
50+
expect(typeof logger.debug).toBe('function');
51+
});
52+
53+
it('should have name "hotcrm"', () => {
54+
expect((logger as any).bindings().name).toBe('hotcrm');
55+
});
56+
});
57+
58+
describe('structured output', () => {
59+
it('should produce JSON output with structured data', () => {
60+
const chunks: string[] = [];
61+
const dest = {
62+
write(chunk: string) { chunks.push(chunk); },
63+
} as any;
64+
65+
// Create a pino logger writing to our mock destination
66+
const pino = require('pino');
67+
const log = pino({ name: 'test:json', level: 'info' }, dest);
68+
log.info({ accountId: '123', score: 85 }, 'Account scored');
69+
70+
expect(chunks.length).toBe(1);
71+
const parsed = JSON.parse(chunks[0]);
72+
expect(parsed.name).toBe('test:json');
73+
expect(parsed.accountId).toBe('123');
74+
expect(parsed.score).toBe(85);
75+
expect(parsed.msg).toBe('Account scored');
76+
expect(parsed.level).toBe(30); // pino info = 30
77+
});
78+
79+
it('should not contain emoji in structured output', () => {
80+
const chunks: string[] = [];
81+
const dest = {
82+
write(chunk: string) { chunks.push(chunk); },
83+
} as any;
84+
85+
const pino = require('pino');
86+
const log = pino({ name: 'test:no-emoji', level: 'info' }, dest);
87+
log.info('Account created successfully');
88+
89+
const parsed = JSON.parse(chunks[0]);
90+
// Verify no emoji characters (common emoji ranges)
91+
const emojiRegex = /[\u{1F300}-\u{1F9FF}]|[\u{2600}-\u{26FF}]|[\u{2700}-\u{27BF}]/u;
92+
expect(emojiRegex.test(parsed.msg)).toBe(false);
93+
});
94+
});
95+
});

packages/core/src/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
11
// Core platform exports
22
export const VERSION = '1.0.0';
33
export * from './zod-helper.js';
4+
export { createLogger, logger } from './logger.js';
5+
export type { LogLevel, LoggerOptions } from './logger.js';

packages/core/src/logger.ts

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
/**
2+
* Structured Logger — pino-based logging for HotCRM
3+
*
4+
* Provides leveled, machine-parseable logging (debug/info/warn/error).
5+
* All hook, action, and service files should use this instead of console.log.
6+
*
7+
* Usage:
8+
* import { createLogger } from '@hotcrm/core';
9+
* const logger = createLogger('crm:account');
10+
* logger.info({ accountId }, 'Account created');
11+
* logger.error({ err }, 'Failed to update account');
12+
*/
13+
14+
import pino from 'pino';
15+
16+
export type LogLevel = 'debug' | 'info' | 'warn' | 'error' | 'fatal' | 'silent';
17+
18+
export interface LoggerOptions {
19+
/** Minimum log level (default: from LOG_LEVEL env or 'info') */
20+
level?: LogLevel;
21+
}
22+
23+
/**
24+
* Create a child logger scoped to a specific module.
25+
*
26+
* @param module Hierarchical module name, e.g. 'crm:account' or 'ai:cache'
27+
* @param options Optional overrides
28+
*/
29+
export function createLogger(module: string, options?: LoggerOptions): pino.Logger {
30+
const level = options?.level ?? (process.env.LOG_LEVEL as LogLevel) ?? 'info';
31+
return pino({ name: module, level });
32+
}
33+
34+
/** Shared root logger instance */
35+
export const logger: pino.Logger = createLogger('hotcrm');

0 commit comments

Comments
 (0)