quality: web debt batch (tests runner, FireContainer, rate-limit, maps, i18n)

Independent-of-prod quality debt from PENDIENTE.md §2:

- test: migrate broken Jest -> meteortesting:mocha. Tests rewritten to chai +
  Meteor 3 async APIs, moved to test/server/ (server-only). test/server/
  00-setup.test.js re-runs the collection2/accounts init that `meteor test`
  skips (no server/main.js). New comments method + mediaAnalyzers coverage.
  Dropped rest.test.js (removed meteor/http; covered by smoke/). 36 passing.
- fix: scope FireContainer read by the URL _id on the archive route instead of
  a selector-less FiresCollection.findOne() (imports/ui/pages/Fires/Fires.js).
- feat: rate-limit abusable publications via rateLimitSubscriptions (fireFrom*
  and comments.forReference 5/1000ms; geo subs 10/1000ms).
- perf: append loading=async to the Google Maps loader URL (Gkeys.js).
- deps: meteor-accounts-t9n 2.0 -> 2.6 (no gl build -> keep gl->es fallback,
  documented); add 3 missing gl/common.json keys (0 missing now).
- deps: drop jest/babel/enzyme, add chai.
This commit is contained in:
vjrj 2026-07-21 22:59:06 +02:00
parent bc778bfd97
commit 6b6f02d92d
27 changed files with 602 additions and 7042 deletions

View file

@ -0,0 +1,14 @@
/* eslint-env mocha */
/* eslint-disable import/no-absolute-path */
// `meteor test` does NOT load server/main.js, so the app's
// server/00-collection2-init.js never runs and Mongo.Collection.prototype
// .attachSchema stays unpatched — every collection with a schema then throws at
// import time. This file mirrors that init. The `00-` prefix makes Meteor load
// it before the other test/server/*.test.js files (same-directory files load
// alphabetically), so attachSchema exists before any collection is imported.
// It intentionally defines no tests.
import 'meteor/aldeed:collection2/static.js';
// Force accounts-base to initialize so Meteor.users exists for method tests
// (server/main.js, which normally pulls in the accounts setup, is not loaded).
import 'meteor/accounts-base';

View file

@ -0,0 +1,32 @@
/* eslint-env mocha */
/* eslint-disable func-names, prefer-arrow-callback */
/* eslint-disable import/no-absolute-path */
import { expect } from 'chai';
import ActiveFiresUnion from '/imports/api/ActiveFiresUnion/ActiveFiresUnion';
const centerid = { type: 'Point', coordinates: [-5.905956029891968, 43.55727622097011] };
const shape = { type: 'Polygon', coordinates: [[[-5.9084, 43.5558], [-5.906, 43.5558], [-5.906, 43.5566], [-5.9036, 43.5566], [-5.9036, 43.5583], [-5.9061, 43.5583], [-5.9061, 43.558], [-5.9084, 43.558], [-5.9084, 43.5558]]] };
// `new Date(...)` (the old test used bare `Date(...)`, which yields a string and
// fails collection2 schema validation on a Date field).
const fireUnion = { centerid, shape, when: new Date('2018-05-02T16:11:04.617Z') };
describe('activeFireUnion insert', () => {
it('should insert fire union', async () => {
// Remove any leftover from a previous run
const alreadyInserted = await ActiveFiresUnion.findOneAsync({ centerid });
if (alreadyInserted) {
await ActiveFiresUnion.removeAsync(alreadyInserted._id);
}
const insertedId = await ActiveFiresUnion.insertAsync(fireUnion);
ActiveFiresUnion.schema.validate(fireUnion);
const inserted = await ActiveFiresUnion.findOneAsync(insertedId);
delete inserted._id;
expect(inserted).to.deep.equal(fireUnion);
await ActiveFiresUnion.removeAsync(insertedId);
expect(await ActiveFiresUnion.find({ _id: insertedId }).countAsync()).to.equal(0);
});
});

View file

@ -0,0 +1,122 @@
/* eslint-env mocha */
/* eslint-disable func-names, prefer-arrow-callback */
/* eslint-disable import/no-absolute-path */
import { expect } from 'chai';
import { Meteor } from 'meteor/meteor';
import Comments from '/imports/api/Comments/Comments';
import getMediaFromContent from '/imports/api/Comments/mediaAnalyzers';
// `meteor test` skips server/main.js, so nothing registers the comment methods.
// Importing the module runs its Meteor.methods({...}) so the handlers exist.
import '/imports/api/Comments/server/methods';
// -- Embed detection (pure) --------------------------------------------------
describe('comment media analyzers', () => {
it('detects an image url', () => {
const media = getMediaFromContent('look at http://example.com/photo.jpg here');
expect(media.type).to.equal('image');
expect(media.content).to.match(/\.jpg$/);
});
it('detects a youtube url and rewrites it to an embed url', () => {
const media = getMediaFromContent('see https://www.youtube.com/watch?v=abc123 now');
expect(media).to.deep.equal({ type: 'youtube', content: 'https://www.youtube.com/embed/abc123' });
});
it('returns {} for plain text', () => {
expect(getMediaFromContent('just some words with no media')).to.deep.equal({});
});
});
// -- Comment methods (server, via method handlers) ---------------------------
// The methods gate on `this.userId`, so invoke the handlers with a fake
// invocation context instead of going through a real login.
describe('comment methods', () => {
const ref = 'fire-ref-under-test';
const owner = 'user-owner';
const other = 'user-other';
const handlers = Meteor.server.method_handlers;
const insert = (userId, content) => handlers['comments.insert'].apply({ userId }, [ref, content]);
const edit = (userId, id, content) => handlers['comments.edit'].apply({ userId }, [id, content]);
const remove = (userId, id) => handlers['comments.remove'].apply({ userId }, [id]);
const like = (userId, id) => handlers['comments.like'].apply({ userId }, [id]);
const dislike = (userId, id) => handlers['comments.dislike'].apply({ userId }, [id]);
beforeEach(async () => {
await Comments.removeAsync({ referenceId: ref });
});
it('insert creates an approved comment with detected media', async () => {
const id = await insert(owner, 'hi http://example.com/pic.png');
const c = await Comments.findOneAsync(id);
expect(c.userId).to.equal(owner);
expect(c.referenceId).to.equal(ref);
expect(c.status).to.equal('approved');
expect(c.media.type).to.equal('image');
expect(c.likes).to.deep.equal([]);
expect(c.dislikes).to.deep.equal([]);
});
it('insert requires a logged-in user', async () => {
let threw = false;
try {
await handlers['comments.insert'].apply({}, [ref, 'nope']);
} catch (e) {
threw = true;
}
expect(threw).to.equal(true);
});
it('insert rejects an empty comment', async () => {
let threw = false;
try {
await insert(owner, ' ');
} catch (e) {
threw = true;
}
expect(threw).to.equal(true);
});
it('edit is allowed for the owner and denied for others', async () => {
const id = await insert(owner, 'original');
let threw = false;
try {
await edit(other, id, 'hijacked');
} catch (e) {
threw = true;
}
expect(threw).to.equal(true);
await edit(owner, id, 'edited');
expect((await Comments.findOneAsync(id)).content).to.equal('edited');
});
it('like then dislike toggles the arrays', async () => {
const id = await insert(owner, 'toggle me');
await like(other, id);
expect((await Comments.findOneAsync(id)).likes).to.include(other);
await dislike(other, id);
const c = await Comments.findOneAsync(id);
expect(c.dislikes).to.include(other);
expect(c.likes).to.not.include(other);
});
it('remove is allowed for the owner only', async () => {
const id = await insert(owner, 'delete me');
let threw = false;
try {
await remove(other, id);
} catch (e) {
threw = true;
}
expect(threw).to.equal(true);
await remove(owner, id);
expect(await Comments.findOneAsync(id)).to.equal(undefined);
});
});

27
test/server/email.test.js Normal file
View file

@ -0,0 +1,27 @@
/* eslint-env mocha */
/* eslint-disable func-names, prefer-arrow-callback */
/* eslint-disable import/no-absolute-path */
import { expect } from 'chai';
import { subjectTruncate } from '/imports/modules/server/send-email';
describe('check email utilities', () => {
it('small words', () => {
expect(subjectTruncate.apply('something', [50, true])).to.equal('something');
});
it('big sentences', () => {
expect(subjectTruncate.apply('Lorem ipsum dolor sit amet, consectetur adipiscing elit. Nam eget sed.'))
.to.equal('Lorem ipsum dolor sit amet, consectetur adipiscing elit. Nam eget sed.');
});
it('big sentences space break', () => {
expect(subjectTruncate.apply('Lorem ipsum dolor sit amet, consectetur adipiscing elit. Morbi mollis cras amet.'))
.to.equal('Lorem ipsum dolor sit amet, consectetur adipiscing elit. Morbi...');
});
it('big sentences no break', () => {
expect(subjectTruncate.apply('Lorem ipsum dolor sit amet, consectetur adipiscing elit. Morbi mollis cras amet.', [70, false]))
.to.equal('Lorem ipsum dolor sit amet, consectetur adipiscing elit. Morbi mollis...');
});
});

View file

@ -0,0 +1,63 @@
/* eslint-env mocha */
/* eslint-disable func-names, prefer-arrow-callback */
/* eslint-disable import/no-absolute-path */
import { expect } from 'chai';
import urlEnc from '/imports/modules/url-encode';
// NOTE: the old suite also seeded ActiveFires via @cleverbeagle/seeder and
// round-tripped whole docs. Those cases were documented-broken (Date fields came
// back as strings) and depended on a dev-only seeder package that is no longer
// installed, so they were dropped. The crypto round-trip below is the real
// contract this module owes.
describe('url encoding', () => {
it('should encrypt and decrypt basic objects', async () => {
const obj = { lat: 2 };
const sealed = await urlEnc.encrypt(obj);
const unsealed = await urlEnc.decrypt(sealed);
expect(unsealed).to.deep.equal(obj);
});
it('should encrypt and decrypt objects with date', async () => {
const obj = { lat: 40.234503, lon: -3.350386, when: (new Date()).toString() };
const sealed = await urlEnc.encrypt(obj);
const unsealed = await urlEnc.decrypt(sealed);
expect(unsealed).to.deep.equal(obj);
});
it('should encrypt complete objects and produce url-safe, salted output', async () => {
const obj = {
ourid: { type: 'Point', coordinates: [24.813, 9.223] },
type: 'modis',
lat: 9.223,
lon: 24.813,
when: new Date('2017-12-13T00:00:00.000Z').toISOString(),
scan: 1,
track: 1,
satellite: 'A',
confidence: 'nominal',
version: '6.0NRT',
frp: 6.5,
daynight: null,
brightness: 305.9,
bright_t31: 292.6
};
const sealed = await urlEnc.encrypt(obj);
const sealed2 = await urlEnc.encrypt(obj);
const unsealed = await urlEnc.decrypt(sealed);
expect(unsealed).to.deep.equal(obj);
expect(sealed).to.equal(encodeURI(sealed)); // already url-safe
expect(sealed).to.not.equal(sealed2); // salted: same input, different output
expect(sealed).to.not.include('/');
expect(sealed).to.not.include('%');
});
it('should decrypt a node-red produced blob without throwing', async () => {
const sealed = 'Fe26.2**7ae64199b871901d16a6ba031198c57b43b4a9231e15b851ff80014a8de230ca*RkB3_Uu_oJnTefyzB-Ocqg*2Z9rXYf--GxLJnYESHvdK5rC6lOMlSbMJ6YQyR2Mgpl57iBYopAHPWRLNTBgNkbUHFYa0IlEjdyeEz5VvLDtVvlSr1Sam-Cx8GUai4mharZO5-Wkze0dTr7M56WofSC9jpkv3kANrpsrit3HASE_E-5Z1JVYQVQqehw-VbSZTorrYj0GCJiAgXE5I0iY4boXRDYCv7fm_pzcuAbHlnNkKT1GbMFEPjyVViP4mVMRI5PDUhcWb0f62goMX4UnYLZXum4oYOIr84DG8AxqIdW2L94yslt0EZkD3yVhvKEGoauMpy6ry2illpSgaMaj4sU3AKWIjfAsJXvM6bHwbNh6G1_BJfCapuu3KijBBQPQMAnhYuwLAlY56WN-Y2efNIBJkp__kO6ak2nFqu8PufpxE-cv0uW7x6GWUtCpnFO2SeUJWVVMddUgJjLiM8Hkdl1v9huB0WdIvsoPEV0ZOwHZaC3jtdKFSO7Xe6LdqTXjXmdBMwtOv-HefyaB8LnEN6dAeKWwJuDu7g03QFTryDIiuwtpQ8mcLWTJGHcxoaluNhUESOBULkWSv_E1vOM1_fFv**1e41101b27c5da58bc11177448e91d88d7801bf911088a895844db0c542dfa7c*dPlOhtXJcyR47ezuL7CEgC2Z8d6C1asNOiqZmhD5TXY';
const unsealed = await urlEnc.decrypt(sealed);
expect(unsealed).to.be.an('object');
});
});

32
test/server/file.test.js Normal file
View file

@ -0,0 +1,32 @@
/* eslint-env mocha */
/* eslint-disable func-names, prefer-arrow-callback */
/* eslint-disable import/no-absolute-path */
import { expect } from 'chai';
import { getFileNameOfLang } from '/imports/api/Utility/server/files.js';
describe('get file of page', () => {
it('lang es retrieve es', () => {
expect(getFileNameOfLang('pages', 'about', 'md', 'es')).to.equal('pages/about-es.md');
});
it('lang en retrieve en', () => {
expect(getFileNameOfLang('pages', 'about', 'md', 'en')).to.equal('pages/about-en.md');
});
it('lang gl retrieve es', () => {
expect(getFileNameOfLang('pages', 'about', 'md', 'gl')).to.equal('pages/about-es.md');
});
it('lang eu retrieve es', () => {
expect(getFileNameOfLang('pages', 'about', 'md', 'eu')).to.equal('pages/about-es.md');
});
it('lang ca retrieve es', () => {
expect(getFileNameOfLang('pages', 'about', 'md', 'ca')).to.equal('pages/about-es.md');
});
it('lang es-PE retrieve es', () => {
expect(getFileNameOfLang('pages', 'about', 'md', 'es-PE')).to.equal('pages/about-es.md');
});
});

View file

@ -0,0 +1,58 @@
/* eslint-env mocha */
/* eslint-disable func-names, prefer-arrow-callback */
/* eslint-disable import/no-absolute-path */
import { expect } from 'chai';
import SiteSettings from '/imports/api/SiteSettings/SiteSettings';
import SiteSettingsTypes from '/imports/api/SiteSettings/SiteSettingsTypes';
const setting = {
name: 'site-test',
value: 'Some value',
description: 'Some description',
isPublic: true,
type: 'string'
};
describe('site settings store', () => {
before(async () => {
await SiteSettings.createIndexAsync({ name: 1 }, { unique: true });
await SiteSettings.removeAsync({ name: setting.name });
});
it('should get settingstypes', () => {
expect(SiteSettingsTypes.string.value.type).to.equal(String);
});
it('should insert settings', async () => {
const id = await SiteSettings.insertAsync(setting);
SiteSettings.getSchema(setting.type).validate(setting);
const inserted = await SiteSettings.findOneAsync(id);
delete inserted._id;
expect(inserted).to.deep.equal(setting);
await SiteSettings.removeAsync(id);
expect(await SiteSettings.find({ _id: id }).countAsync()).to.equal(0);
});
it('should not be inserted twice', async () => {
const id = await SiteSettings.insertAsync(setting);
// The unique index on `name` must reject the duplicate.
let threw = false;
try {
await SiteSettings.insertAsync(setting);
} catch (e) {
threw = true;
}
expect(threw).to.equal(true);
expect(await SiteSettings.find(id).countAsync()).to.equal(1);
await SiteSettings.removeAsync(id);
});
it('should fail validation', () => {
expect(() => {
SiteSettings.getSchema('boolean').validate(setting);
}).to.throw('Value must be of type Boolean');
});
});

View file

@ -0,0 +1,51 @@
/* eslint-env mocha */
/* eslint-disable func-names, prefer-arrow-callback */
/* eslint-disable import/no-absolute-path */
import { expect } from 'chai';
import { composeIberiaTweet } from '/imports/api/ActiveFires/server/tweetFiresInZone';
const tr = (stats) => {
const keys = Object.keys(stats);
const newStats = [];
keys.map((key) => {
newStats[key] = { count: stats[key] };
});
return newStats;
};
describe('compose tweets of fires', () => {
it('No fires', () => {
expect(composeIberiaTweet(0, { })).to.equal('');
});
it('a fire in a zone', () => {
expect(composeIberiaTweet(1, tr({ Asturias: 1 }))).to.equal('Un #fuego activo en #Asturias');
});
it('some fires in a zone', () => {
expect(composeIberiaTweet(4, tr({ Asturias: 4 }))).to.equal('4 #fuegos activos en #Asturias');
});
it('some fires in two zones', () => {
expect(composeIberiaTweet(5, tr({ Asturias: 1, Madrid: 4 }))).to.equal('Un #fuego activo en #Asturias y otros 4 en #Madrid');
});
it('some fires in some zones', () => {
expect(composeIberiaTweet(8, tr({ Catalunya: 3, Asturias: 1, Madrid: 4 }))).to.equal('3 #fuegos activos en #Catalunya, otro en #Asturias y otros 4 en #Madrid');
});
it('other fires in some zones', () => {
expect(composeIberiaTweet(6, tr({ Catalunya: 1, Asturias: 1, Madrid: 4 }))).to.equal('Un #fuego activo en #Catalunya, otro en #Asturias y otros 4 en #Madrid');
});
it('other fires in two zones', () => {
expect(composeIberiaTweet(2, tr({ Catalunya: 1, Asturias: 1 }))).to.equal('Un #fuego activo en #Catalunya y otro en #Asturias');
});
it('many fires in many zones', () => {
expect(composeIberiaTweet(11, tr({
Cataluña: 3, Cantabria: 1, Andalucia: 4, Galicia: 1, Madrid: 2
}))).to.equal('3 #fuegos activos en #Cataluña, otro en #Cantabria, otros 4 en #Andalucia, otro en #Galicia y otros 2 en #Madrid');
});
});