import { describe, expect, it } from 'vitest'; import { ObjectId } from 'mongodb'; import { buildMatchContent, contentKey, kmRounded } from '../src/matcher/content.js'; import { distanceMeters, isHit } from '../src/matcher/geo.js'; import { matchFire, type MatchContext } from '../src/matcher/matcher.js'; import type { FireDoc, SubscriptionDoc } from '../src/matcher/types.js'; function fire(over: Partial = {}): FireDoc { return { _id: new ObjectId(), lat: 40.4168, lon: -3.7038, when: new Date('2026-07-13T12:00:00Z'), type: 'viirs', createdAt: new Date('2026-07-13T12:05:00Z'), ...over, }; } function sub(over: Partial = {}): SubscriptionDoc { const lat = over.location?.lat ?? 40.42; const lon = over.location?.lon ?? -3.70; return { _id: new ObjectId(), location: { lat, lon }, geo: { type: 'Point', coordinates: [lon, lat] }, distance: 40, owner: 'u1', type: 'mobile', ...over, }; } describe('content', () => { it('maps fire type to i18n key', () => { expect(contentKey('viirs')).toBe('kmnasa'); expect(contentKey('modis')).toBe('kmnasa'); expect(contentKey('vecinal')).toBe('kmvecinal'); }); it('rounds km to 1 decimal like the old node', () => { expect(kmRounded(64543)).toBe(64.5); expect(kmRounded(500)).toBe(0.5); }); it('builds content verbatim incl. the Cyrillic км', () => { expect(buildMatchContent('viirs', 'es', 12.3)).toBe('🔥 Alerta de posible fuego a 12.3 км de distancia (fuente NASA)'); expect(buildMatchContent('viirs', 'en', 5)).toBe('🔥 Alert of possible fire in 5 км of distance (source NASA)'); expect(buildMatchContent('vecinal', 'es', 1.1)).toBe('🔥 Alerta vecinal de fuego a 1.1 км de distancia'); }); it('falls back to es for unknown langs', () => { expect(buildMatchContent('viirs', 'gl', 2)).toContain('lume'); }); }); describe('geo', () => { it('computes distance and hit boundary', () => { const s = sub({ location: { lat: 40.4168, lon: -3.7038 }, distance: 1 }); const f = fire({ lat: 40.4168, lon: -3.7038 }); expect(distanceMeters(s, f)).toBe(0); expect(isHit(s, 0)).toBe(true); }); it('respects the radius: just outside is not a hit', () => { const s = sub({ distance: 10 }); expect(isHit(s, 10_000)).toBe(true); // exactly 10km expect(isHit(s, 10_001)).toBe(false); }); }); function ctx(over: Partial = {}): MatchContext { return { candidateSubs: async () => [], existsNotifNear: async () => false, userLang: async () => 'es', ironPassword: 'x'.repeat(32), audienceTypes: new Set(['web', 'mobile']), now: () => new Date('2026-07-13T12:06:00Z'), ...over, }; } describe('matchFire', () => { it('generates a notification doc identical in shape to node-red', async () => { const f = fire(); const s = sub({ owner: 'u1', type: 'mobile', distance: 40 }); const notifs = await matchFire(f, ctx({ candidateSubs: async () => [s] })); expect(notifs).toHaveLength(1); const n = notifs[0]!; expect(n.userId).toBe('u1'); expect(n.subsId).toBe(s._id); expect(n.type).toBe('mobile'); expect(n.geo).toEqual({ type: 'Point', coordinates: [f.lon, f.lat] }); expect(n.when).toBe(f.when); expect(n.content).toContain('км'); expect(typeof n.sealed).toBe('string'); expect(n.sealed.startsWith('Fe26.2')).toBe(true); }); it('skips subs outside their radius', async () => { const far = sub({ location: { lat: 41.5, lon: -3.7 }, distance: 1 }); // ~120km away, radius 1km const notifs = await matchFire(fire(), ctx({ candidateSubs: async () => [far] })); expect(notifs).toHaveLength(0); }); it('dedupes against existing notifications within 500m', async () => { const s = sub(); const notifs = await matchFire(fire(), ctx({ candidateSubs: async () => [s], existsNotifNear: async () => true })); expect(notifs).toHaveLength(0); }); it('emits at most one notification per user per fire', async () => { const s1 = sub({ _id: new ObjectId(), owner: 'u1', distance: 50 }); const s2 = sub({ _id: new ObjectId(), owner: 'u1', distance: 50 }); const notifs = await matchFire(fire(), ctx({ candidateSubs: async () => [s1, s2] })); expect(notifs).toHaveLength(1); expect(notifs[0]!.subsId).toBe(s1._id); // first hit wins }); it('ignores subscription types outside the audience (e.g. telegram)', async () => { const tg = sub({ type: 'telegram', chatId: 123 }); const notifs = await matchFire(fire(), ctx({ candidateSubs: async () => [tg] })); expect(notifs).toHaveLength(0); }); it('uses the owner language for content', async () => { const s = sub({ owner: 'uEN' }); const notifs = await matchFire(fire(), ctx({ candidateSubs: async () => [s], userLang: async () => 'en' })); expect(notifs[0]!.content).toContain('source NASA'); }); });