diff --git a/test/server/publications.test.js b/test/server/publications.test.js new file mode 100644 index 0000000..4650eaf --- /dev/null +++ b/test/server/publications.test.js @@ -0,0 +1,200 @@ +/* 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 ActiveFires from '/imports/api/ActiveFires/ActiveFires'; +import ActiveFiresUnion from '/imports/api/ActiveFiresUnion/ActiveFiresUnion'; +import Subscriptions from '/imports/api/Subscriptions/Subscriptions'; +import '/imports/api/ActiveFires/server/publications'; +import '/imports/api/ActiveFiresUnion/server/publications'; +import '/imports/api/Subscriptions/server/publications'; + +// The map calls these three on every pan and zoom. They are also the publications +// nobody could change with any confidence, because a wrong bounding box just +// returns fewer fires and looks like "no fires around here". + +const handler = name => Meteor.server.publish_handlers[name]; + +const runPublication = async (name, context, ...args) => { + const result = await handler(name).apply(context, args); + return result; +}; + +const fireAt = (lat, lon) => ({ + ourid: { type: 'Point', coordinates: [lon, lat] }, + lat, + lon, + type: 'viirs', + when: new Date('2026-07-30T05:30:00Z'), + scan: 0.5, + track: 0.4 +}); + +// A small square around (lat, lon). +const squareAt = (lat, lon, size = 0.01) => ({ + type: 'Polygon', + coordinates: [[ + [lon - size, lat - size], + [lon + size, lat - size], + [lon + size, lat + size], + [lon - size, lat + size], + [lon - size, lat - size] + ]] +}); + +const OVIEDO = { lat: 43.36, lon: -5.84 }; +const MADRID = { lat: 40.41, lon: -3.7 }; + +// north-east lng/lat, south-west lng/lat, as the client sends them. +const boxAround = (p, pad = 0.5) => [p.lon + pad, p.lat + pad, p.lon - pad, p.lat - pad]; + +describe('geo publications', function () { + this.timeout(30000); + + beforeEach(async function () { + await ActiveFires.removeAsync({}); + await ActiveFiresUnion.removeAsync({}); + await Subscriptions.removeAsync({}); + }); + + after(async function () { + await ActiveFires.removeAsync({}); + await ActiveFiresUnion.removeAsync({}); + await Subscriptions.removeAsync({}); + }); + + describe('activefiresmyloc', function () { + it('returns only the fires inside the requested box', async function () { + await ActiveFires.insertAsync(fireAt(OVIEDO.lat, OVIEDO.lon)); + await ActiveFires.insertAsync(fireAt(MADRID.lat, MADRID.lon)); + + const cursor = await runPublication('activefiresmyloc', { userId: null }, ...boxAround(OVIEDO), false); + const fires = await cursor.fetchAsync(); + + expect(fires).to.have.lengthOf(1); + expect(fires[0].lat).to.equal(OVIEDO.lat); + }); + + it('publishes only the fields the map needs', async function () { + await ActiveFires.insertAsync(fireAt(OVIEDO.lat, OVIEDO.lon)); + + const cursor = await runPublication('activefiresmyloc', { userId: null }, ...boxAround(OVIEDO), false); + const [fire] = await cursor.fetchAsync(); + + expect(Object.keys(fire).sort()).to.deep.equal(['_id', 'lat', 'lon', 'scan', 'track', 'when']); + }); + + it('returns nothing for a box with no fires in it', async function () { + await ActiveFires.insertAsync(fireAt(MADRID.lat, MADRID.lon)); + + const cursor = await runPublication('activefiresmyloc', { userId: null }, ...boxAround(OVIEDO), false); + expect(await cursor.countAsync()).to.equal(0); + }); + + it('refuses coordinates outside the world', async function () { + let threw = false; + try { + await runPublication('activefiresmyloc', { userId: null }, 200, 10, -200, -10, false); + } catch (e) { + threw = true; + } + expect(threw, 'a longitude of 200 was accepted').to.equal(true); + }); + + it('refuses a non-boolean withMarks', async function () { + let threw = false; + try { + await runPublication('activefiresmyloc', { userId: null }, ...boxAround(OVIEDO), 'yes'); + } catch (e) { + threw = true; + } + expect(threw).to.equal(true); + }); + }); + + describe('activefiresunionmyloc', function () { + it('returns the union shapes that intersect the box', async function () { + await ActiveFiresUnion.insertAsync({ + centerid: { type: 'Point', coordinates: [OVIEDO.lon, OVIEDO.lat] }, + shape: squareAt(OVIEDO.lat, OVIEDO.lon), + when: new Date('2026-07-30T05:30:00Z') + }); + await ActiveFiresUnion.insertAsync({ + centerid: { type: 'Point', coordinates: [MADRID.lon, MADRID.lat] }, + shape: squareAt(MADRID.lat, MADRID.lon), + when: new Date('2026-07-30T05:30:00Z') + }); + + const cursor = await runPublication('activefiresunionmyloc', { userId: null }, ...boxAround(OVIEDO), false); + const unions = await cursor.fetchAsync(); + + expect(unions).to.have.lengthOf(1); + expect(unions[0].centerid.coordinates[0]).to.equal(OVIEDO.lon); + }); + + // $geoIntersects, not $geoWithin: a shape bigger than the viewport still has + // to be drawn, which is exactly what a zoomed-in user sees. + it('returns a shape that only partially overlaps the box', async function () { + await ActiveFiresUnion.insertAsync({ + centerid: { type: 'Point', coordinates: [OVIEDO.lon, OVIEDO.lat] }, + shape: squareAt(OVIEDO.lat, OVIEDO.lon, 2), + when: new Date('2026-07-30T05:30:00Z') + }); + + const cursor = await runPublication('activefiresunionmyloc', { userId: null }, ...boxAround(OVIEDO, 0.1), false); + expect(await cursor.countAsync()).to.equal(1); + }); + + it('returns nothing for a box far from every shape', async function () { + await ActiveFiresUnion.insertAsync({ + centerid: { type: 'Point', coordinates: [MADRID.lon, MADRID.lat] }, + shape: squareAt(MADRID.lat, MADRID.lon), + when: new Date('2026-07-30T05:30:00Z') + }); + + const cursor = await runPublication('activefiresunionmyloc', { userId: null }, ...boxAround(OVIEDO), false); + expect(await cursor.countAsync()).to.equal(0); + }); + + it('refuses coordinates outside the world', async function () { + let threw = false; + try { + await runPublication('activefiresunionmyloc', { userId: null }, 10, 100, -10, -100, false); + } catch (e) { + threw = true; + } + expect(threw).to.equal(true); + }); + }); + + describe('mysubscriptions', function () { + const subFor = (owner, lat, lon) => ({ + location: { lat, lon }, + geo: { type: 'Point', coordinates: [lon, lat] }, + distance: 10, + owner, + type: 'web' + }); + + it('publishes only the subscriptions of the logged-in user', async function () { + await Subscriptions.insertAsync(subFor('user-a', OVIEDO.lat, OVIEDO.lon)); + await Subscriptions.insertAsync(subFor('user-a', MADRID.lat, MADRID.lon)); + await Subscriptions.insertAsync(subFor('user-b', MADRID.lat, MADRID.lon)); + + const cursor = await runPublication('mysubscriptions', { userId: 'user-a' }); + const subs = await cursor.fetchAsync(); + + expect(subs).to.have.lengthOf(2); + subs.forEach(s => expect(s.owner).to.equal('user-a')); + }); + + it('publishes nothing to an anonymous visitor', async function () { + await Subscriptions.insertAsync(subFor('user-a', OVIEDO.lat, OVIEDO.lon)); + + const cursor = await runPublication('mysubscriptions', { userId: null }); + expect(await cursor.countAsync()).to.equal(0); + }); + }); +});