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

122
docs/legacy-matching.md Normal file
View file

@ -0,0 +1,122 @@
# Comportamiento del matching viejo (fase 1b — caracterización)
> Fuente: subflow "send notifications" de node-red en
> `todos-contra-el-fuego/telegram-bot/flows.json` (copia rescatada, fase 0), más
> `todos-contra-el-fuego-web`: `imports/api/Subscriptions/Subscriptions.js`,
> `imports/api/ActiveFires/ActiveFires.js`, `imports/modules/url-encode.js`,
> `imports/api/Fires/server/publications.js`, y locales node-red.
>
> Objetivo: reproducir **campo a campo** los docs `notifications` que node-red
> crea, para que los workers de la fase 1a no noten el cambio de origen.
## 0. Entorno (recordatorio)
- MongoDB **3.2****no hay change streams**. La ingesta de fuegos es por
**polling** de `activefires` (marca de tiempo / checkpoint), no change stream.
- Driver `mongodb@3.7` (ya en el servicio). `subscriptions.geo` tiene índice
**2dsphere** → las geo-queries `$near`/`$geoWithin` funcionan en 3.2.
## 1. Colecciones implicadas
### `activefires` (fuego)
```
{ _id, ourid:{type:'Point',coordinates:[lon,lat]}, lat, lon, when:Date,
type:'modis'|'viirs'|'vecinal', acq_date, acq_time, confidence, frp, ...,
createdAt:Date, updatedAt:Date, fireUnion?:ObjectId }
```
La importación NASA (cron de `incinera` cada 15 min) inserta/actualiza aquí.
`createdAt` marca cuándo entró el fuego → base para el polling de fuegos nuevos.
### `subscriptions`
```
{ _id:ObjectId, location:{lat,lon}, geo:{type:'Point',coordinates:[lon,lat]},
distance:Number, // RADIO de la sub en km
owner:String, // userId
type:String, // 'web' | 'mobile' | 'telegram'
chatId?:Number, telegramBot?:String, // solo type 'telegram'
createdAt, updatedAt }
```
### `notifications` (lo que hay que generar — ver `legacy-behavior.md` §1)
Campos escritos por node-red: `userId`, `subsId`, `content`, `geo`(Point),
`type`, `when`, `sealed`, `createdAt`, `updatedAt`. Sin `notified`/`emailNotified`
(pendiente → lo consumen los workers de 1a).
## 2. Reglas del matching (por cada fuego)
1. **Candidatas (geo grueso)**: query sobre `subscriptions` con
`geo $near { $geometry: Point(fireLon,fireLat), $minDistance:0, $maxDistance:1000000 }`
(**1000 km** de prefiltro), filtrando la audiencia:
- Telegram: `{ telegramBot: <bot> }`.
- Si el flag global `notifyWebUsers` está activo, además:
`$or: [{telegramBot},{type:'web'},{type:'mobile'}]`.
- **Fase 1b (workers 1a) solo genera para `type ∈ {web, mobile}`**; el envío
Telegram es fase 1c.
2. **Filtro fino por distancia**: por cada candidata,
`dist = geolib.getDistance(sub.location, fire)` (metros, great-circle).
Si `dist/1000 <= sub.distance` (km) → **hit**.
- `km = Math.round(dist/1000 * 10) / 10` (1 decimal) para el texto.
3. **Dedupe 500 m**: antes de insertar, buscar en `notifications` del mismo
`userId` una notif con `geo $near { $maxDistance: 500 }` (0,5 km). **Si existe
alguna → NO se crea** (ya notificado en esa zona). node-red guarda además
`cacheKey = "lat,lon"` (informativo; el dedupe real es el geo-query 500 m).
4. **Contenido** (`content`), i18n por lang del **owner** de la sub:
- `type` fuego `modis`/`viirs` → clave **`kmnasa`**.
- `type` fuego `vecinal` → clave **`kmvecinal`**.
- Interpolando `{{km}}`. ⚠️ Las cadenas llevan literalmente **`км`** (cirílico,
no `km`) — es un error histórico de las traducciones; se replica verbatim.
5. **`sealed`**: `Iron.seal(firePayload, ironPassword, Iron.defaults)` con el
paquete **`iron`** (Hapi Iron). `firePayload` = el doc del fuego con
`ourversion:"1"` y **sin `_id`**; contiene al menos `{lon,lat,when,type}`. La
web lo descifra en `imports/modules/url-encode.js` (`Iron.unseal`) para la
ruta `fire/<sealed>` y la app.
- **Nota**: Iron usa IV/salt aleatorios → `sealed` **no es byte-idéntico**
entre ejecuciones ni sistemas, y **no debe compararse por bytes**. Lo que
importa es que **descifre al fuego correcto** con el mismo `ironPassword`.
El shadow compara `sealed` **descifrándolo**, no como string.
6. **Doc insertado** en `notifications`:
```
{ userId: sub.owner, subsId: sub._id, content, type: sub.type,
geo: { type:'Point', coordinates:[fire.lon, fire.lat] },
when: fire.when, sealed, createdAt: now, updatedAt: now }
```
## 3. Cadenas i18n (verbatim de los locales node-red)
`kmnasa`:
- es: `🔥 Alerta de posible fuego a {{km}} км de distancia (fuente NASA)`
- en: `🔥 Alert of possible fire in {{km}} км of distance (source NASA)`
- gl: `🔥 Alerta posible lume a {{km}} км de distancia (orixe NASA)`
- ast: `🔥 Alerta de fueu posible a {{km}} км de distancia (fonte NASA)`
- pt: `🔥 Alerta, possível fogo posto em {{km}} км de distância (origem NASA)`
`kmvecinal`:
- es: `🔥 Alerta vecinal de fuego a {{km}} км de distancia`
- gl: `🔥 Alerta veciñal de lume a {{km}} км de distancia`
- ast: `🔥 Alerta vecinal de fueu a {{km}} км de distancia`
(El sufijo `:` y el prefijo `🔥 ` los quita `trim()` en los workers — ver
`legacy-behavior.md` §5. Aquí el contenido NO lleva `:` final; node-red lo
añade solo para el mensaje de Telegram, no para el doc `notifications`.)
## 4. Ingesta de fuegos (fase 1b, sin change streams)
- **Polling** de `activefires` por `createdAt > checkpoint` (fuegos nuevos desde
la última pasada), con checkpoint persistido (colección propia del servicio,
p.ej. `matcher_state`). Alternativa/disparo: al final del import NASA.
- Re-detecciones (mismo fuego re-visto): el **dedupe 500 m** evita duplicar; no
hace falta tratar `updatedAt` para no re-notificar.
- **"Union of fires"**: `activefires` tiene `fireUnion` (agrupación de fuegos
cercanos, lógica de 2018). Pendiente de confirmar si afecta a qué fuego
dispara notificación; por defecto se procesa por fuego individual y el dedupe
500 m agrupa la vecindad. **Anotar como punto a verificar en el shadow.**
## 5. Puntos abiertos a validar en shadow
- ¿El matching viejo notifica solo fuegos `createdAt` nuevos, o re-procesa? (el
cron NASA corre cada 15 min; el subflow procesa el batch del import).
- Efecto exacto de `fireUnion` / "union of fires" en el disparo.
- `notifyWebUsers`: ¿está activo en prod? (determina si se generan notifs
web/mobile o solo telegram). Verificar el global en la config de node-red.
- Lang usado para `content`: el del **owner** de la sub (users.lang). Confirmar
con casos reales en shadow.

63
package-lock.json generated
View file

@ -8,8 +8,10 @@
"name": "tcef-notifications", "name": "tcef-notifications",
"version": "0.1.0", "version": "0.1.0",
"dependencies": { "dependencies": {
"@hapi/iron": "^7.0.1",
"bullmq": "^5.34.0", "bullmq": "^5.34.0",
"firebase-admin": "^13.0.2", "firebase-admin": "^13.0.2",
"geolib": "^3.3.14",
"handlebars": "^4.7.8", "handlebars": "^4.7.8",
"ioredis": "^5.4.2", "ioredis": "^5.4.2",
"juice": "^11.0.0", "juice": "^11.0.0",
@ -704,6 +706,61 @@
"node": ">=6" "node": ">=6"
} }
}, },
"node_modules/@hapi/b64": {
"version": "6.0.1",
"resolved": "https://registry.npmjs.org/@hapi/b64/-/b64-6.0.1.tgz",
"integrity": "sha512-ZvjX4JQReUmBheeCq+S9YavcnMMHWqx3S0jHNXWIM1kQDxB9cyfSycpVvjfrKcIS8Mh5N3hmu/YKo4Iag9g2Kw==",
"license": "BSD-3-Clause",
"dependencies": {
"@hapi/hoek": "^11.0.2"
}
},
"node_modules/@hapi/boom": {
"version": "10.0.1",
"resolved": "https://registry.npmjs.org/@hapi/boom/-/boom-10.0.1.tgz",
"integrity": "sha512-ERcCZaEjdH3OgSJlyjVk8pHIFeus91CjKP3v+MpgBNp5IvGzP2l/bRiD78nqYcKPaZdbKkK5vDBVPd2ohHBlsA==",
"license": "BSD-3-Clause",
"dependencies": {
"@hapi/hoek": "^11.0.2"
}
},
"node_modules/@hapi/bourne": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/@hapi/bourne/-/bourne-3.0.0.tgz",
"integrity": "sha512-Waj1cwPXJDucOib4a3bAISsKJVb15MKi9IvmTI/7ssVEm6sywXGjVJDhl6/umt1pK1ZS7PacXU3A1PmFKHEZ2w==",
"license": "BSD-3-Clause"
},
"node_modules/@hapi/cryptiles": {
"version": "6.0.3",
"resolved": "https://registry.npmjs.org/@hapi/cryptiles/-/cryptiles-6.0.3.tgz",
"integrity": "sha512-r6VKalpbMHz4ci3gFjFysBmhwCg70RpYZy6OkjEpdXzAYnYFX5XsW7n4YMJvuIYpnMwLxGUjK/cBhA7X3JDvXw==",
"license": "BSD-3-Clause",
"dependencies": {
"@hapi/boom": "^10.0.1"
},
"engines": {
"node": ">=14.0.0"
}
},
"node_modules/@hapi/hoek": {
"version": "11.0.7",
"resolved": "https://registry.npmjs.org/@hapi/hoek/-/hoek-11.0.7.tgz",
"integrity": "sha512-HV5undWkKzcB4RZUusqOpcgxOaq6VOAH7zhhIr2g3G8NF/MlFO75SjOr2NfuSx0Mh40+1FqCkagKLJRykUWoFQ==",
"license": "BSD-3-Clause"
},
"node_modules/@hapi/iron": {
"version": "7.0.1",
"resolved": "https://registry.npmjs.org/@hapi/iron/-/iron-7.0.1.tgz",
"integrity": "sha512-tEZnrOujKpS6jLKliyWBl3A9PaE+ppuL/+gkbyPPDb/l2KSKQyH4lhMkVb+sBhwN+qaxxlig01JRqB8dk/mPxQ==",
"license": "BSD-3-Clause",
"dependencies": {
"@hapi/b64": "^6.0.1",
"@hapi/boom": "^10.0.1",
"@hapi/bourne": "^3.0.0",
"@hapi/cryptiles": "^6.0.1",
"@hapi/hoek": "^11.0.2"
}
},
"node_modules/@ioredis/commands": { "node_modules/@ioredis/commands": {
"version": "1.10.0", "version": "1.10.0",
"resolved": "https://registry.npmjs.org/@ioredis/commands/-/commands-1.10.0.tgz", "resolved": "https://registry.npmjs.org/@ioredis/commands/-/commands-1.10.0.tgz",
@ -2573,6 +2630,12 @@
"url": "https://opencollective.com/node-fetch" "url": "https://opencollective.com/node-fetch"
} }
}, },
"node_modules/geolib": {
"version": "3.3.14",
"resolved": "https://registry.npmjs.org/geolib/-/geolib-3.3.14.tgz",
"integrity": "sha512-uQ1772h3OjhWvL/HhSRZTMjBKIKoc4wFksLDqzqOkuG/2TgBbTwFamU0Disx3sNFk/BOweHyhKoVaYDuLIpsxQ==",
"license": "MIT"
},
"node_modules/get-caller-file": { "node_modules/get-caller-file": {
"version": "2.0.5", "version": "2.0.5",
"resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz",

View file

@ -16,8 +16,10 @@
"test:watch": "vitest" "test:watch": "vitest"
}, },
"dependencies": { "dependencies": {
"@hapi/iron": "^7.0.1",
"bullmq": "^5.34.0", "bullmq": "^5.34.0",
"firebase-admin": "^13.0.2", "firebase-admin": "^13.0.2",
"geolib": "^3.3.14",
"handlebars": "^4.7.8", "handlebars": "^4.7.8",
"ioredis": "^5.4.2", "ioredis": "^5.4.2",
"juice": "^11.0.0", "juice": "^11.0.0",

39
src/matcher/content.ts Normal file
View file

@ -0,0 +1,39 @@
import type { Lang } from '../messages.js';
/**
* i18n strings for the alert content, verbatim from the node-red locales
* (docs/legacy-matching.md §3). NOTE the literal Cyrillic "км" a historic
* typo in the translations, reproduced exactly so output matches the old system.
*/
export const KM_I18N: Record<'kmnasa' | 'kmvecinal', Partial<Record<Lang, string>>> = {
kmnasa: {
es: '🔥 Alerta de posible fuego a {{km}} км de distancia (fuente NASA)',
en: '🔥 Alert of possible fire in {{km}} км of distance (source NASA)',
gl: '🔥 Alerta posible lume a {{km}} км de distancia (orixe NASA)',
},
kmvecinal: {
es: '🔥 Alerta vecinal de fuego a {{km}} км de distancia',
gl: '🔥 Alerta veciñal de lume a {{km}} км de distancia',
},
};
/** Which i18n key a fire type maps to (modis/viirs → NASA, vecinal → neighbour). */
export function contentKey(fireType: string): 'kmnasa' | 'kmvecinal' {
return fireType === 'vecinal' ? 'kmvecinal' : 'kmnasa';
}
/**
* Distance in km rounded to 1 decimal, exactly as the old node-red node:
* `Math.round(dist/1000 * 10) / 10`.
*/
export function kmRounded(distanceMeters: number): number {
return Math.round((distanceMeters / 1000) * 10) / 10;
}
/** Build the notification `content` string for a fire+subscriber, per old rules. */
export function buildMatchContent(fireType: string, lang: Lang, km: number): string {
const key = contentKey(fireType);
const table = KM_I18N[key];
const template = table[lang] ?? table.es ?? key;
return template.replaceAll('{{km}}', String(km));
}

19
src/matcher/geo.ts Normal file
View file

@ -0,0 +1,19 @@
import { getDistance } from 'geolib';
import type { FireDoc, SubscriptionDoc } from './types.js';
/**
* Great-circle distance in metres between a subscription and a fire, using the
* SAME library (geolib.getDistance) the old node-red flow used, so distances
* and therefore the `dist/1000 <= sub.distance` boundary are identical.
*/
export function distanceMeters(sub: SubscriptionDoc, fire: Pick<FireDoc, 'lat' | 'lon'>): number {
return getDistance(
{ latitude: sub.location.lat, longitude: sub.location.lon },
{ latitude: fire.lat, longitude: fire.lon },
);
}
/** A subscription is hit when the fire is within its radius: dist/1000 <= sub.distance (km). */
export function isHit(sub: SubscriptionDoc, distMeters: number): boolean {
return distMeters / 1000 <= sub.distance;
}

84
src/matcher/matcher.ts Normal file
View file

@ -0,0 +1,84 @@
import type { ObjectId } from 'mongodb';
import { normalizeLang } from '../messages.js';
import { buildMatchContent, kmRounded } from './content.js';
import { distanceMeters, isHit } from './geo.js';
import { sealFire } from './seal.js';
import type { FireDoc, SubscriptionDoc } from './types.js';
/** A `notifications` doc generated by the matcher (same shape node-red writes). */
export interface GeneratedNotif {
userId: string;
subsId: ObjectId;
content: string;
type: string; // sub.type: 'web' | 'mobile'
geo: { type: 'Point'; coordinates: [number, number] }; // [fire.lon, fire.lat]
when: Date;
sealed: string;
createdAt: Date;
updatedAt: Date;
}
export interface MatchContext {
/** Candidate subs: geo $near fire within 1000km, already filtered to audienceTypes. */
candidateSubs(fire: FireDoc): Promise<SubscriptionDoc[]>;
/** Dedupe: is there already a notification for this user within `meters` of the fire? */
existsNotifNear(userId: string, lon: number, lat: number, meters: number): Promise<boolean>;
/** Owner language (users.lang) for the content string. */
userLang(userId: string): Promise<string | undefined>;
ironPassword: string;
/** Only these subscription types produce notifications here (workers 1a consume web/mobile). */
audienceTypes: Set<string>;
now(): Date;
}
/** The 500 m dedupe radius from the old flow. */
export const DEDUPE_METERS = 500;
/**
* Match one fire against subscriptions and return the notification docs to
* create faithfully reproducing the node-red matching (docs/legacy-matching.md).
*
* Dedupe is twofold: (a) against existing notifications within 500 m for the
* user (`existsNotifNear`), and (b) intra-batch at most ONE notification per
* user per fire, since every notif for a fire shares the fire's coordinates and
* would fall inside the 500 m radius of the first.
*/
export async function matchFire(fire: FireDoc, ctx: MatchContext): Promise<GeneratedNotif[]> {
const subs = await ctx.candidateSubs(fire);
const now = ctx.now();
const out: GeneratedNotif[] = [];
const seenUsers = new Set<string>();
for (const sub of subs) {
if (!ctx.audienceTypes.has(sub.type)) continue;
if (seenUsers.has(sub.owner)) continue; // one per user per fire
const dist = distanceMeters(sub, fire);
if (!isHit(sub, dist)) continue;
// Dedupe against already-stored notifications within 500 m for this user.
if (await ctx.existsNotifNear(sub.owner, fire.lon, fire.lat, DEDUPE_METERS)) {
seenUsers.add(sub.owner);
continue;
}
const lang = normalizeLang(await ctx.userLang(sub.owner));
const content = buildMatchContent(fire.type, lang, kmRounded(dist));
const sealed = await sealFire(fire, ctx.ironPassword);
out.push({
userId: sub.owner,
subsId: sub._id,
content,
type: sub.type,
geo: { type: 'Point', coordinates: [fire.lon, fire.lat] },
when: fire.when,
sealed,
createdAt: now,
updatedAt: now,
});
seenUsers.add(sub.owner);
}
return out;
}

23
src/matcher/seal.ts Normal file
View file

@ -0,0 +1,23 @@
import Iron from '@hapi/iron';
import type { FireDoc } from './types.js';
/**
* Build the object that gets sealed into `notification.sealed`, mirroring the
* old node-red node: the fire payload WITHOUT `_id`, plus `ourversion: "1"`.
* The web only reads {lon, lat, when, type} on unseal (findFire), but we seal
* the whole fire (minus _id) to stay faithful.
*/
export function fireSealPayload(fire: FireDoc): Record<string, unknown> {
const { _id, ...rest } = fire;
return { ...rest, ourversion: '1' };
}
/**
* Seal with Hapi Iron (Fe26.2), matching the web's `iron@5` + `Iron.defaults`
* and the same `ironPassword`. Iron uses random salt/IV, so the output is NOT
* byte-stable it is meant to be decrypted, not compared. The web unseals it
* for the `fire/<sealed>` route.
*/
export async function sealFire(fire: FireDoc, ironPassword: string): Promise<string> {
return Iron.seal(fireSealPayload(fire), ironPassword, Iron.defaults);
}

27
src/matcher/types.ts Normal file
View file

@ -0,0 +1,27 @@
import type { ObjectId } from 'mongodb';
/** A document in `activefires` (see docs/legacy-matching.md §1). */
export interface FireDoc {
_id: ObjectId;
ourid?: { type: 'Point'; coordinates: [number, number] }; // [lon, lat]
lat: number;
lon: number;
when: Date;
type: 'modis' | 'viirs' | 'vecinal' | string;
createdAt?: Date;
updatedAt?: Date;
fireUnion?: ObjectId;
[k: string]: unknown; // other NASA fields (acq_date, frp, confidence, ...)
}
/** A document in `subscriptions`. */
export interface SubscriptionDoc {
_id: ObjectId;
location: { lat: number; lon: number };
geo: { type: 'Point'; coordinates: [number, number] }; // [lon, lat]
distance: number; // radius in km
owner: string; // userId
type: 'web' | 'mobile' | 'telegram' | string;
chatId?: number;
telegramBot?: string;
}

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');
});
});