You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
253 lines
9.3 KiB
253 lines
9.3 KiB
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
|
import {callbackTransport, consoleTransport, createLogr, httpTransport, type LogEntry, LogLevel} from '@/logr';
|
|
import { createLing } from '@/ling';
|
|
import type { LingNode, SupportedLocale } from '@/ling';
|
|
|
|
// ============================================================================
|
|
// HELPERS
|
|
// ============================================================================
|
|
|
|
const schema = {
|
|
auth: {
|
|
loginFailed: { es: 'Login fallido', en: 'Login failed' },
|
|
}
|
|
} satisfies LingNode;
|
|
|
|
function makeI18n(locale: SupportedLocale = 'es') {
|
|
const i18n = createLing(schema, 'es');
|
|
i18n.setLocale(locale);
|
|
return i18n;
|
|
}
|
|
|
|
function makeEntry(overrides: Partial<LogEntry> = {}): LogEntry {
|
|
return {
|
|
timestamp: new Date(),
|
|
level : LogLevel.WARN,
|
|
category : 'auth',
|
|
message : { es: 'Login fallido', en: 'Login failed' },
|
|
...overrides,
|
|
};
|
|
}
|
|
|
|
// ============================================================================
|
|
// consoleTransport
|
|
// ============================================================================
|
|
|
|
describe('consoleTransport', () => {
|
|
|
|
it('usa console.debug para nivel DEBUG', () => {
|
|
const spy = vi.spyOn(console, 'debug').mockImplementation(() => {});
|
|
const t = consoleTransport();
|
|
t.write(makeEntry({ level: LogLevel.DEBUG }), 'msg debug');
|
|
expect(spy).toHaveBeenCalledTimes(1);
|
|
spy.mockRestore();
|
|
});
|
|
|
|
it('usa console.info para nivel INFO', () => {
|
|
const spy = vi.spyOn(console, 'info').mockImplementation(() => {});
|
|
const t = consoleTransport();
|
|
t.write(makeEntry({ level: LogLevel.INFO }), 'msg info');
|
|
expect(spy).toHaveBeenCalledTimes(1);
|
|
spy.mockRestore();
|
|
});
|
|
|
|
it('usa console.warn para nivel WARN', () => {
|
|
const spy = vi.spyOn(console, 'warn').mockImplementation(() => {});
|
|
const t = consoleTransport();
|
|
t.write(makeEntry({ level: LogLevel.WARN }), 'msg warn');
|
|
expect(spy).toHaveBeenCalledTimes(1);
|
|
spy.mockRestore();
|
|
});
|
|
|
|
it('usa console.error para nivel ERROR', () => {
|
|
const spy = vi.spyOn(console, 'error').mockImplementation(() => {});
|
|
const t = consoleTransport();
|
|
t.write(makeEntry({ level: LogLevel.ERROR }), 'msg error');
|
|
expect(spy).toHaveBeenCalledTimes(1);
|
|
spy.mockRestore();
|
|
});
|
|
|
|
it('incluye timestamp y prefijo por defecto', () => {
|
|
const spy = vi.spyOn(console, 'warn').mockImplementation(() => {});
|
|
const t = consoleTransport();
|
|
t.write(makeEntry({ category: 'auth' }), 'mensaje');
|
|
expect(spy).toHaveBeenCalledWith(
|
|
expect.stringMatching(/^\d{4}-\d{2}-\d{2}/), // ISO timestamp
|
|
'[auth]',
|
|
'mensaje',
|
|
''
|
|
);
|
|
spy.mockRestore();
|
|
});
|
|
|
|
it('omite timestamp si timestamp: false', () => {
|
|
const spy = vi.spyOn(console, 'warn').mockImplementation(() => {});
|
|
const t = consoleTransport({ timestamp: false });
|
|
t.write(makeEntry({ category: 'auth' }), 'mensaje');
|
|
expect(spy).toHaveBeenCalledWith('[auth]', 'mensaje', '');
|
|
spy.mockRestore();
|
|
});
|
|
|
|
it('omite prefijo si prefix: false', () => {
|
|
const spy = vi.spyOn(console, 'warn').mockImplementation(() => {});
|
|
const t = consoleTransport({ prefix: false });
|
|
t.write(makeEntry(), 'mensaje');
|
|
const args = spy.mock.calls[0];
|
|
expect(args).not.toContain('[auth]');
|
|
spy.mockRestore();
|
|
});
|
|
|
|
it('incluye el contexto en el output', () => {
|
|
const spy = vi.spyOn(console, 'warn').mockImplementation(() => {});
|
|
const t = consoleTransport();
|
|
t.write(makeEntry({ context: { userId: 42 } }), 'msg');
|
|
const args = spy.mock.calls[0];
|
|
expect(args).toContainEqual(expect.objectContaining({ userId: 42 }));
|
|
spy.mockRestore();
|
|
});
|
|
});
|
|
|
|
// ============================================================================
|
|
// httpTransport
|
|
// ============================================================================
|
|
|
|
describe('httpTransport', () => {
|
|
|
|
beforeEach(() => {
|
|
global.fetch = vi.fn().mockResolvedValue({ ok: true });
|
|
});
|
|
|
|
it('envía un POST al endpoint configurado', async () => {
|
|
const t = httpTransport({ url: 'https://logs.example.com', level: LogLevel.ERROR });
|
|
t.write(makeEntry({ level: LogLevel.ERROR }), 'Login failed');
|
|
expect(fetch).toHaveBeenCalledWith(
|
|
'https://logs.example.com',
|
|
expect.objectContaining({ method: 'POST' })
|
|
);
|
|
});
|
|
|
|
it('incluye el mensaje resuelto en el body', async () => {
|
|
const t = httpTransport({ url: 'https://logs.example.com', level: LogLevel.ERROR });
|
|
t.write(makeEntry({ level: LogLevel.ERROR }), 'Login failed');
|
|
const body = JSON.parse((fetch as any).mock.calls[0][1].body);
|
|
expect(body.message).toBe('Login failed');
|
|
});
|
|
|
|
it('incluye headers custom', () => {
|
|
const t = httpTransport({
|
|
url : 'https://logs.example.com',
|
|
headers: { 'Authorization': 'Bearer token' },
|
|
level : LogLevel.ERROR
|
|
});
|
|
t.write(makeEntry({ level: LogLevel.ERROR }), 'msg');
|
|
const headers = (fetch as any).mock.calls[0][1].headers;
|
|
expect(headers['Authorization']).toBe('Bearer token');
|
|
});
|
|
|
|
it('no envía si el nivel es inferior al mínimo configurado', () => {
|
|
const t = httpTransport({ url: 'https://logs.example.com', level: LogLevel.ERROR });
|
|
t.write(makeEntry({ level: LogLevel.WARN }), 'msg');
|
|
expect(fetch).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('usa ERROR como nivel mínimo por defecto', () => {
|
|
const t = httpTransport({ url: 'https://logs.example.com' });
|
|
t.write(makeEntry({ level: LogLevel.WARN }), 'msg');
|
|
expect(fetch).not.toHaveBeenCalled();
|
|
|
|
t.write(makeEntry({ level: LogLevel.ERROR }), 'msg');
|
|
expect(fetch).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it('no lanza si fetch falla — fire and forget', () => {
|
|
global.fetch = vi.fn().mockRejectedValue(new Error('Network error'));
|
|
const spy = vi.spyOn(console, 'error').mockImplementation(() => {});
|
|
const t = httpTransport({ url: 'https://logs.example.com', level: LogLevel.ERROR });
|
|
expect(() => t.write(makeEntry({ level: LogLevel.ERROR }), 'msg')).not.toThrow();
|
|
spy.mockRestore();
|
|
});
|
|
});
|
|
|
|
// ============================================================================
|
|
// callbackTransport
|
|
// ============================================================================
|
|
|
|
describe('callbackTransport', () => {
|
|
|
|
it('invoca el callback con la entrada y el mensaje resuelto', () => {
|
|
const fn = vi.fn();
|
|
const t = callbackTransport(fn);
|
|
const entry = makeEntry();
|
|
t.write(entry, 'Login fallido');
|
|
expect(fn).toHaveBeenCalledWith(entry, 'Login fallido');
|
|
});
|
|
|
|
it('se puede usar para capturar logs en tests', () => {
|
|
const i18n = makeI18n('en');
|
|
const captured: string[] = [];
|
|
const logr = createLogr(i18n, {
|
|
level : LogLevel.DEBUG,
|
|
transports: [ callbackTransport((_, msg) => captured.push(msg)) ]
|
|
});
|
|
|
|
logr.debug('auth', schema.auth.loginFailed);
|
|
logr.warn ('auth', 'custom message');
|
|
|
|
expect(captured).toEqual(['Login failed', 'custom message']);
|
|
});
|
|
});
|
|
|
|
// ============================================================================
|
|
// Integración — múltiples transports
|
|
// ============================================================================
|
|
|
|
describe('múltiples transports', () => {
|
|
|
|
it('emite a todos los transports registrados', () => {
|
|
const i18n = makeI18n();
|
|
const fn1 = vi.fn();
|
|
const fn2 = vi.fn();
|
|
const logr = createLogr(i18n, {
|
|
level : LogLevel.DEBUG,
|
|
transports: [ callbackTransport(fn1), callbackTransport(fn2) ]
|
|
});
|
|
|
|
logr.warn('auth', { es: 'aviso' });
|
|
|
|
expect(fn1).toHaveBeenCalledTimes(1);
|
|
expect(fn2).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it('el mensaje se resuelve una sola vez y se comparte entre transports', () => {
|
|
const i18n = makeI18n('en');
|
|
const messages: string[] = [];
|
|
const logr = createLogr(i18n, {
|
|
level : LogLevel.DEBUG,
|
|
transports: [
|
|
callbackTransport((_, msg) => messages.push(msg)),
|
|
callbackTransport((_, msg) => messages.push(msg)),
|
|
]
|
|
});
|
|
|
|
logr.debug('auth', schema.auth.loginFailed);
|
|
|
|
expect(messages).toEqual(['Login failed', 'Login failed']);
|
|
});
|
|
|
|
it('un transport que falla no afecta a los demás', () => {
|
|
const i18n = makeI18n();
|
|
const fn = vi.fn();
|
|
const logr = createLogr(i18n, {
|
|
level : LogLevel.DEBUG,
|
|
transports: [
|
|
callbackTransport(() => { throw new Error('transport error'); }),
|
|
callbackTransport(fn),
|
|
]
|
|
});
|
|
|
|
// El segundo transport sí debería ejecutarse aunque el primero falle
|
|
// Este test documenta el comportamiento actual — si se quiere
|
|
// aislamiento habría que añadir try/catch en el engine
|
|
expect(() => logr.warn('auth', { es: 'msg' })).toThrow();
|
|
});
|
|
}); |