Fase 1b: nucleo del matcher geoespacial (caracterizado + testeado)

Caracterizacion del matching viejo de node-red en docs/legacy-matching.md
(reglas: candidatas geo $near 1000km, filtro fino geolib dist/1000<=sub.distance,
dedupe 500m, content i18n kmnasa/kmvecinal con 'км' cirilico verbatim, sealed via
Iron). Modulos:

- matcher/content.ts: i18n kmnasa/kmvecinal + km redondeado (Math.round dist/1000*10/10)
- matcher/geo.ts: geolib.getDistance (identico al viejo) + isHit
- matcher/seal.ts: @hapi/iron (Fe26.2) — COMPATIBLE con el iron@5 de la web
  (verificado: sello en servicio -> unseal en web round-trip OK)
- matcher/matcher.ts: matchFire(fire, ctx) -> docs notifications identicos a
  node-red; dedupe 500m + 1 notif/usuario/fuego; solo audiencia web/mobile
- deps: @hapi/iron, geolib

63 tests en verde (incluye interop CJS/ESM en dist). MongoDB 3.2 -> ingesta por
polling (sin change streams), pendiente de cablear.
This commit is contained in:
vjrj 2026-07-13 17:58:43 +02:00
parent 4f8d866226
commit b3aeb6108f
9 changed files with 507 additions and 0 deletions

128
test/matcher.test.ts Normal file
View file

@ -0,0 +1,128 @@
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> = {}): 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> = {}): 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> = {}): 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');
});
});