diff --git a/src/server.ts b/src/server.ts index 26bf0ee..20d1587 100644 --- a/src/server.ts +++ b/src/server.ts @@ -84,6 +84,12 @@ export class WebhookServer { Metrics.set('allowed_groups_total_allowed', allowed); Metrics.set('allowed_groups_total_blocked', blocked); } catch {} + // Exponer métrica con el tiempo restante hasta el próximo group sync (o -1 si scheduler inactivo) + try { + const secs = GroupSyncService.getSecondsUntilNextGroupSync(); + const val = (secs == null || !Number.isFinite(secs)) ? -1 : secs; + Metrics.set('group_sync_seconds_until_next', val); + } catch {} const format = (process.env.METRICS_FORMAT || 'prom').toLowerCase() === 'json' ? 'json' : 'prom'; const body = Metrics.render(format as any); return new Response(body, { diff --git a/src/services/group-sync.ts b/src/services/group-sync.ts index efc6179..2f241a1 100644 --- a/src/services/group-sync.ts +++ b/src/services/group-sync.ts @@ -71,6 +71,8 @@ export class GroupSyncService { private static _groupsSchedulerRunning = false; private static _membersTimer: any = null; private static _membersSchedulerRunning = false; + private static _groupsIntervalMs: number | null = null; + private static _groupsNextTickAt: number | null = null; static async syncGroups(force: boolean = false): Promise<{ added: number; updated: number }> { if (!this.shouldSync(force)) { @@ -682,8 +684,12 @@ export class GroupSyncService { if (process.env.NODE_ENV === 'development' && interval < 10000) { interval = 10000; } + this._groupsIntervalMs = interval; + this._groupsNextTickAt = Date.now() + interval; this._groupsTimer = setInterval(() => { + // Programar el siguiente tick antes de ejecutar la sincronización + this._groupsNextTickAt = Date.now() + (this._groupsIntervalMs ?? interval); this.syncGroups().catch(err => { console.error('❌ Groups scheduler run error:', err); }); @@ -696,6 +702,15 @@ export class GroupSyncService { clearInterval(this._groupsTimer); this._groupsTimer = null; } + this._groupsIntervalMs = null; + this._groupsNextTickAt = null; + } + + public static getSecondsUntilNextGroupSync(nowMs: number = Date.now()): number | null { + const next = this._groupsNextTickAt; + if (next == null) return null; + const secs = (next - nowMs) / 1000; + return secs > 0 ? secs : 0; } public static startMembersScheduler(): void { diff --git a/tests/unit/services/group-sync.scheduler.test.ts b/tests/unit/services/group-sync.scheduler.test.ts index 0f4b100..0363a7c 100644 --- a/tests/unit/services/group-sync.scheduler.test.ts +++ b/tests/unit/services/group-sync.scheduler.test.ts @@ -3,6 +3,7 @@ import { GroupSyncService } from '../../../src/services/group-sync'; const envBackup = { ...process.env }; let originalSyncMembers: any; +let originalSyncGroups: any; function sleep(ms: number) { return new Promise(res => setTimeout(res, ms)); @@ -11,11 +12,14 @@ function sleep(ms: number) { describe('GroupSyncService - scheduler de miembros', () => { beforeEach(() => { originalSyncMembers = GroupSyncService.syncMembersForActiveGroups; + originalSyncGroups = GroupSyncService.syncGroups; }); afterEach(() => { GroupSyncService.stopMembersScheduler(); + GroupSyncService.stopGroupsScheduler(); GroupSyncService.syncMembersForActiveGroups = originalSyncMembers; + GroupSyncService.syncGroups = originalSyncGroups; process.env = envBackup; }); @@ -45,4 +49,36 @@ describe('GroupSyncService - scheduler de miembros', () => { GroupSyncService.stopMembersScheduler(); expect(called).toBeGreaterThanOrEqual(1); }); + + test('groups scheduler no arranca en entorno de test', async () => { + process.env = { ...envBackup, NODE_ENV: 'test' }; + let called = 0; + GroupSyncService.syncGroups = async () => { + called++; + return { added: 0, updated: 0 }; + }; + + GroupSyncService.startGroupsScheduler(); + await sleep(100); + expect(called).toBe(0); + expect(GroupSyncService.getSecondsUntilNextGroupSync()).toBeNull(); + }); + + test('groups scheduler arranca en producción y programa next tick', async () => { + process.env = { ...envBackup, NODE_ENV: 'production', GROUP_SYNC_INTERVAL_MS: '30' }; + let called = 0; + GroupSyncService.syncGroups = async () => { + called++; + return { added: 0, updated: 0 }; + }; + + GroupSyncService.startGroupsScheduler(); + const secs1 = GroupSyncService.getSecondsUntilNextGroupSync(); + expect(secs1).not.toBeNull(); + expect(Number(secs1)).toBeGreaterThan(0); + + await sleep(120); + GroupSyncService.stopGroupsScheduler(); + expect(called).toBeGreaterThanOrEqual(1); + }); }); diff --git a/tests/unit/services/metrics-health.test.ts b/tests/unit/services/metrics-health.test.ts index a65810e..f13e169 100644 --- a/tests/unit/services/metrics-health.test.ts +++ b/tests/unit/services/metrics-health.test.ts @@ -37,6 +37,7 @@ describe('/metrics y /health (detallado)', () => { expect(body).toContain('last_sync_ok'); expect(body).toContain('active_groups 2'); expect(body).toContain('webhook_events_total_messages_upsert 3'); + expect(body).toContain('group_sync_seconds_until_next'); }); test('/health?full=1 devuelve JSON con contadores y snapshot', async () => {