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.
128 lines
4.8 KiB
TypeScript
128 lines
4.8 KiB
TypeScript
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');
|
|
});
|
|
});
|