In shadow/dry-run/kill-switch modes, when the target has no token/recipient/
chat, or when not in the canary cohort, the handlers returned without marking
the notification, so the poller (pendingFilter on notified:{$ne:true})
re-selected the same batch every cycle forever (observed: fcm:500 every 10s).
Add a service-owned per-channel marker processedBy.<channel>, set in every
terminal 'won't/can't send' branch, and exclude marked docs in pendingFilter.
Unlike the shared notified/emailNotified/telegramNotified flags, processedBy is
invisible to the old system, so it never suppresses a real send in prod shadow.
- types.ts: NotificationDoc.processedBy?: Partial<Record<Channel, Date>>
- poller.ts: pendingFilter excludes processedBy.<channel>
- handlers.ts: markProcessed() in no-token/no-recipient/no-chat, decideSend=false,
dead-token and telegram-blocked branches
- fake-repo: dotted-path support in matching and $set/$unset
- tests: regression test + processedBy assertions (86 passing)
226 lines
7.1 KiB
TypeScript
226 lines
7.1 KiB
TypeScript
/**
|
|
* 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
|
|
* (including dotted paths like `processedBy.fcm`), 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>;
|
|
|
|
/** Read a possibly-dotted path (`a.b.c`) from a doc, like MongoDB does. */
|
|
function getPath(doc: Doc, key: string): any {
|
|
if (!key.includes('.')) return doc[key];
|
|
return key.split('.').reduce<any>((cur, part) => (cur == null ? undefined : cur[part]), doc);
|
|
}
|
|
|
|
/** Set a possibly-dotted path, creating intermediate objects as MongoDB would. */
|
|
function setPath(doc: Doc, key: string, value: any): void {
|
|
if (!key.includes('.')) {
|
|
doc[key] = value;
|
|
return;
|
|
}
|
|
const parts = key.split('.');
|
|
let cur = doc;
|
|
for (const part of parts.slice(0, -1)) {
|
|
if (cur[part] == null || typeof cur[part] !== 'object') cur[part] = {};
|
|
cur = cur[part];
|
|
}
|
|
cur[parts[parts.length - 1]!] = value;
|
|
}
|
|
|
|
/** Delete a possibly-dotted path. */
|
|
function delPath(doc: Doc, key: string): void {
|
|
if (!key.includes('.')) {
|
|
delete doc[key];
|
|
return;
|
|
}
|
|
const parts = key.split('.');
|
|
let cur = doc;
|
|
for (const part of parts.slice(0, -1)) {
|
|
if (cur[part] == null) return;
|
|
cur = cur[part];
|
|
}
|
|
delete cur[parts[parts.length - 1]!];
|
|
}
|
|
|
|
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 '$gt':
|
|
return docVal != null && docVal > (v as any);
|
|
case '$lte':
|
|
return docVal != null && docVal <= (v as any);
|
|
case '$lt':
|
|
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(getPath(doc, k), cond));
|
|
}
|
|
|
|
function applyUpdate(doc: Doc, update: Doc): void {
|
|
if (update.$set) for (const [k, v] of Object.entries(update.$set)) setPath(doc, k, v);
|
|
if (update.$unset) for (const k of Object.keys(update.$unset)) delPath(doc, k);
|
|
}
|
|
|
|
class FakeCollection {
|
|
docs: Doc[] = [];
|
|
collectionName = 'fake';
|
|
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, opts?: { upsert?: boolean }): Promise<{ modifiedCount: number; upsertedCount: number }> {
|
|
const d = this.docs.find((x) => matches(x, filter));
|
|
if (!d) {
|
|
if (opts?.upsert) {
|
|
const created: Doc = { ...filter };
|
|
applyUpdate(created, update);
|
|
this.docs.push(created);
|
|
return { modifiedCount: 0, upsertedCount: 1 };
|
|
}
|
|
return { modifiedCount: 0, upsertedCount: 0 };
|
|
}
|
|
applyUpdate(d, update);
|
|
return { modifiedCount: 1, upsertedCount: 0 };
|
|
}
|
|
|
|
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;
|
|
},
|
|
sort(spec: Doc) {
|
|
const [field, dir] = Object.entries(spec)[0] as [string, number];
|
|
result = [...result].sort((a, b) => {
|
|
const av = a[field];
|
|
const bv = b[field];
|
|
if (av === bv) return 0;
|
|
return (av < bv ? -1 : 1) * (dir < 0 ? -1 : 1);
|
|
});
|
|
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 {
|
|
return {
|
|
client: {} as any,
|
|
db: {} as any,
|
|
notifications: new FakeCollection() as any,
|
|
users: new FakeCollection() as any,
|
|
sends: new FakeCollection() as any,
|
|
subscriptions: new FakeCollection() as any,
|
|
activefires: new FakeCollection() as any,
|
|
notificationsShadow: new FakeCollection() as any,
|
|
matcherState: new FakeCollection() as any,
|
|
close: async () => {},
|
|
};
|
|
}
|