Fase 1a: driver mongodb v3.7 (produccion es MongoDB 3.2)

El smoke-test contra prod revelo MongoDB 3.2.11 (EOL): el driver v6 exige >=4.2
y no conecta. Cambios:

- mongodb ^3.7.4 + @types/mongodb; db.ts con useUnifiedTopology, sin retryWrites
- sends.ts: deteccion de duplicate-key (11000) sin depender de MongoServerError
- poller.ts: FilterQuery en vez de Filter
- tests de integracion migrados de mongodb-memory-server a un fake en memoria
  (test/fake-repo.ts) porque no hay mongod 3.x ejecutable en hosts modernos;
  emula operadores ($ne/$in/$gte/$exists), $set/$unset e indice unico (11000)
- docs: legacy-behavior.md §0 (entorno MongoDB 3.2, sin change streams -> fase
  1b usara polling; recomendacion de upgrade de Mongo como fase propia)

47 tests en verde, typecheck y build OK. Datos reales del import: 413 pendientes
FCM, 0 email, 8176 users con fireBaseToken.
This commit is contained in:
vjrj 2026-07-13 00:58:27 +02:00
parent d20e168c90
commit a5ba2cdcf1
11 changed files with 400 additions and 777 deletions

165
test/fake-repo.ts Normal file
View file

@ -0,0 +1,165 @@
/**
* In-memory fake of the Mongo collections used by the service. Production runs
* MongoDB 3.2, for which no mongod binary is downloadable/runnable on modern
* dev/CI hosts (glibc/libssl). So the DB integration tests run against this
* fake, which faithfully emulates the subset of behaviour the code relies on:
* the query operators used ($ne/$in/$gte/$exists/equality), $set/$unset updates,
* projection/limit, and crucially unique-index duplicate-key errors (11000)
* that back the idempotency guarantee.
*/
import type { Repos } from '../src/db.js';
type Doc = Record<string, any>;
function isObjectId(v: any): boolean {
return v != null && typeof v === 'object' && typeof v.equals === 'function' && v._bsontype === 'ObjectID';
}
function valEq(a: any, b: any): boolean {
if (a === b) return true;
if (a == null || b == null) return a === b;
if (isObjectId(a)) {
try {
return a.equals(b);
} catch {
return false;
}
}
if (isObjectId(b)) return b.equals(a);
if (a instanceof Date && b instanceof Date) return a.getTime() === b.getTime();
return false;
}
function isOperatorObject(cond: any): boolean {
if (cond == null || typeof cond !== 'object') return false;
if (cond instanceof Date || isObjectId(cond)) return false;
const keys = Object.keys(cond);
return keys.length > 0 && keys.every((k) => k.startsWith('$'));
}
function matchField(docVal: any, cond: any): boolean {
if (isOperatorObject(cond)) {
return Object.entries(cond).every(([op, v]) => {
switch (op) {
case '$ne':
return !valEq(docVal, v);
case '$in':
return Array.isArray(v) && v.some((x) => valEq(docVal, x));
case '$gte':
return docVal != null && docVal >= (v as any);
case '$lte':
return docVal != null && docVal <= (v as any);
case '$exists':
return (docVal !== undefined) === v;
default:
throw new Error(`fake-repo: unsupported operator ${op}`);
}
});
}
return valEq(docVal, cond);
}
function matches(doc: Doc, filter: Doc): boolean {
return Object.entries(filter).every(([k, cond]) => matchField(doc[k], cond));
}
function applyUpdate(doc: Doc, update: Doc): void {
if (update.$set) Object.assign(doc, update.$set);
if (update.$unset) for (const k of Object.keys(update.$unset)) delete doc[k];
}
class FakeCollection {
docs: Doc[] = [];
private uniqueFields: string[] | null = null;
async createIndex(spec: Doc, opts?: { unique?: boolean }): Promise<string> {
if (opts?.unique) this.uniqueFields = Object.keys(spec);
return 'idx';
}
private violatesUnique(doc: Doc): boolean {
if (!this.uniqueFields) return false;
return this.docs.some((d) => this.uniqueFields!.every((f) => valEq(d[f], doc[f])));
}
async insertOne(doc: Doc): Promise<{ insertedId: any }> {
if (this.violatesUnique(doc)) {
const err: any = new Error('E11000 duplicate key error');
err.code = 11000;
throw err;
}
const copy = { ...doc };
this.docs.push(copy);
return { insertedId: copy._id };
}
async insertMany(docs: Doc[]): Promise<{ insertedCount: number }> {
for (const d of docs) await this.insertOne(d);
return { insertedCount: docs.length };
}
async findOne(filter: Doc = {}): Promise<Doc | null> {
return this.docs.find((d) => matches(d, filter)) ?? null;
}
async updateOne(filter: Doc, update: Doc): Promise<{ modifiedCount: number }> {
const d = this.docs.find((x) => matches(x, filter));
if (!d) return { modifiedCount: 0 };
applyUpdate(d, update);
return { modifiedCount: 1 };
}
async updateMany(filter: Doc, update: Doc): Promise<{ modifiedCount: number }> {
const ds = this.docs.filter((x) => matches(x, filter));
for (const d of ds) applyUpdate(d, update);
return { modifiedCount: ds.length };
}
async deleteOne(filter: Doc): Promise<{ deletedCount: number }> {
const i = this.docs.findIndex((d) => matches(d, filter));
if (i === -1) return { deletedCount: 0 };
this.docs.splice(i, 1);
return { deletedCount: 1 };
}
async deleteMany(filter: Doc = {}): Promise<{ deletedCount: number }> {
const before = this.docs.length;
this.docs = this.docs.filter((d) => !matches(d, filter));
return { deletedCount: before - this.docs.length };
}
async countDocuments(filter: Doc = {}): Promise<number> {
return this.docs.filter((d) => matches(d, filter)).length;
}
find(filter: Doc = {}, _opts?: Doc) {
let result = this.docs.filter((d) => matches(d, filter));
const cursor = {
project() {
return cursor;
},
limit(n: number) {
result = result.slice(0, n);
return cursor;
},
async toArray() {
return result.map((d) => ({ ...d }));
},
};
return cursor;
}
}
export function makeRepos(): Repos {
const notifications = new FakeCollection();
const users = new FakeCollection();
const sends = new FakeCollection();
return {
client: {} as any,
db: {} as any,
notifications: notifications as any,
users: users as any,
sends: sends as any,
close: async () => {},
};
}

View file

@ -1,30 +1,14 @@
import { afterAll, beforeAll, beforeEach, describe, expect, it } from 'vitest';
import type { MongoMemoryServer } from 'mongodb-memory-server';
import { beforeEach, describe, expect, it } from 'vitest';
import type { Repos } from '../src/db.js';
import type { Config } from '../src/config.js';
import { processEmailJob, processFcmJob, type Deps } from '../src/handlers.js';
import { aNotif, aUser, fakeFcm, fakeMailer, silentLog, startMongo } from './helpers.js';
import { aNotif, aUser, fakeFcm, fakeMailer, freshRepos, silentLog, testCfg } from './helpers.js';
let server: MongoMemoryServer;
let repos: Repos;
let baseCfg: Config;
beforeAll(async () => {
const s = await startMongo();
server = s.server;
repos = s.repos;
baseCfg = s.cfg;
});
afterAll(async () => {
await repos.close();
await server.stop();
});
const baseCfg: Config = testCfg();
beforeEach(async () => {
await repos.notifications.deleteMany({});
await repos.users.deleteMany({});
await repos.sends.deleteMany({});
repos = await freshRepos();
});
function deps(over: Partial<Deps> = {}): Deps {

View file

@ -1,21 +1,20 @@
import { MongoMemoryServer } from 'mongodb-memory-server';
import { pino } from 'pino';
import { ObjectId } from 'mongodb';
import { connect, ensureIndexes, type Repos } from '../src/db.js';
import type { Repos } from '../src/db.js';
import type { Config } from '../src/config.js';
import type { FcmClient, FcmSendResult } from '../src/fcm.js';
import type { Mailer } from '../src/mailer.js';
import type { EmailContent, PushPayload } from '../src/messages.js';
import type { NotificationDoc, UserDoc } from '../src/types.js';
import { makeRepos } from './fake-repo.js';
export const silentLog = pino({ level: 'silent' });
export async function startMongo(): Promise<{ server: MongoMemoryServer; repos: Repos; cfg: Config }> {
const server = await MongoMemoryServer.create();
const cfg = testCfg({ mongoUrl: server.getUri(), dbName: 'fuegos' });
const repos = await connect(cfg);
await ensureIndexes(repos);
return { server, repos, cfg };
/** Fresh in-memory repos with the idempotency unique index applied. */
export async function freshRepos(): Promise<Repos> {
const repos = makeRepos();
await repos.sends.createIndex({ notificationId: 1, channel: 1 }, { unique: true, name: 'uniq' });
return repos;
}
export function testCfg(over: Partial<Config> = {}): Config {

View file

@ -1,29 +1,17 @@
import { afterAll, beforeAll, beforeEach, describe, expect, it } from 'vitest';
import { beforeEach, describe, expect, it } from 'vitest';
import type { Queue } from 'bullmq';
import type { MongoMemoryServer } from 'mongodb-memory-server';
import type { Repos } from '../src/db.js';
import type { Config } from '../src/config.js';
import { createPoller } from '../src/poller.js';
import { pendingFilter } from '../src/poller.js';
import type { Channel, SendJob } from '../src/types.js';
import { aNotif, silentLog, startMongo } from './helpers.js';
import { aNotif, freshRepos, silentLog, testCfg } from './helpers.js';
let server: MongoMemoryServer;
let repos: Repos;
let baseCfg: Config;
const baseCfg: Config = testCfg();
beforeAll(async () => {
const s = await startMongo();
server = s.server;
repos = s.repos;
baseCfg = s.cfg;
});
afterAll(async () => {
await repos.close();
await server.stop();
});
beforeEach(async () => {
await repos.notifications.deleteMany({});
repos = await freshRepos();
});
/** Fake queue recording add() calls. */