test(subsUnion): cover the queue, the fast path and the size guard

The cases are the incidents: a worker failure must leave the stored union alone
instead of overwriting it with "null" (50ca9cc), an incremental merge must refuse
to run on state that is not exactly one behind, recomputes must coalesce while
adds must not be dropped, and legacy telegram subscriptions (no radius, no
location) must be skipped without throwing.

Also the 16 MiB case the FIXME asked for: a 20 MB union comes back under the cap,
still parseable, with the degradation recorded.
This commit is contained in:
vjrj 2026-08-01 18:02:20 +02:00
parent 124ed4e135
commit 6e448bda55
2 changed files with 605 additions and 0 deletions

View file

@ -0,0 +1,120 @@
/* eslint-env mocha */
/* eslint-disable func-names, prefer-arrow-callback */
/* eslint-disable import/no-absolute-path */
import { expect } from 'chai';
import limitUnionSize, { byteLength, MAX_UNION_BYTES } from '/imports/startup/server/unionSizeGuard';
// A closed ring of `points` vertices around (lon, lat). Coordinates are left at
// full double precision on purpose: that is what turf hands back, and it is what
// makes the JSON big.
const ring = (lon, lat, points, radius = 0.2) => {
const out = [];
for (let i = 0; i < points; i += 1) {
const angle = (2 * Math.PI * i) / points;
out.push([lon + (radius * Math.cos(angle)), lat + (radius * Math.sin(angle))]);
}
out.push(out[0]);
return out;
};
const multiPolygon = (count, points) => {
const coordinates = [];
for (let i = 0; i < count; i += 1) {
coordinates.push([ring(-10 + ((i % 100) * 0.5), 35 + (Math.floor(i / 100) * 0.5), points)]);
}
return { type: 'Feature', properties: {}, geometry: { type: 'MultiPolygon', coordinates } };
};
const polygonsOf = u => u.geometry.coordinates;
const pointCount = u => polygonsOf(u).reduce((acc, poly) => acc + poly.reduce((a, r) => a + r.length, 0), 0);
describe('unionSizeGuard', function () {
// Building and serializing a 20 MB fixture takes a couple of seconds.
this.timeout(120000);
it('leaves a union that already fits completely alone', function () {
const union = multiPolygon(2, 20);
const result = limitUnionSize(union, MAX_UNION_BYTES);
expect(result.degraded).to.equal(null);
expect(result.union).to.deep.equal(union);
expect(result.json).to.equal(JSON.stringify(union));
});
it('handles a null union (no valid subscriptions) without inventing geometry', function () {
const result = limitUnionSize(null, MAX_UNION_BYTES);
expect(result.union).to.equal(null);
expect(result.json).to.equal('null');
expect(result.degraded).to.equal(null);
});
it('drops coordinate precision first, keeping every polygon', function () {
const union = multiPolygon(40, 60);
const budget = byteLength(JSON.stringify(union)) - 1000;
const result = limitUnionSize(union, budget);
expect(result.bytes).to.be.at.most(budget);
expect(result.degraded.steps[0]).to.match(/^precision:/);
expect(polygonsOf(result.union)).to.have.lengthOf(40);
});
it('decimates vertices when rounding is not enough, still keeping every polygon', function () {
const union = multiPolygon(40, 144);
const budget = Math.round(byteLength(JSON.stringify(union)) / 4);
const result = limitUnionSize(union, budget);
expect(result.bytes).to.be.at.most(budget);
expect(result.degraded.steps.some(s => s.startsWith('decimate:'))).to.equal(true);
expect(polygonsOf(result.union)).to.have.lengthOf(40);
expect(pointCount(result.union)).to.be.below(pointCount(union));
});
it('drops the smallest polygons last, and only when nothing else fits', function () {
const union = multiPolygon(60, 20);
// A budget only a couple of polygons can fit into.
const budget = Math.round(byteLength(JSON.stringify(union)) / 25);
const result = limitUnionSize(union, budget);
expect(result.bytes).to.be.at.most(budget);
expect(result.degraded.steps[result.degraded.steps.length - 1]).to.match(/^polygons:/);
expect(polygonsOf(result.union).length).to.be.below(60);
expect(polygonsOf(result.union).length).to.be.at.least(1);
});
it('keeps every ring closed and valid after degrading', function () {
const union = multiPolygon(30, 144);
const result = limitUnionSize(union, Math.round(byteLength(JSON.stringify(union)) / 6));
polygonsOf(result.union).forEach((polygon) => {
polygon.forEach((r) => {
expect(r.length).to.be.at.least(4);
expect(r[0]).to.deep.equal(r[r.length - 1]);
});
});
});
// The actual FIXME this module exists for: subsUnion.js used to hand Mongo
// whatever came out of turf, and a document over 16 MiB is simply rejected —
// the union would then stay frozen at the last one that happened to fit.
it('brings a union past Mongo 16 MiB document limit back under the cap', function () {
// ~20 MB of GeoJSON: 4000 subscription-sized circles at 144 steps each.
const union = multiPolygon(4000, 144);
const original = byteLength(JSON.stringify(union));
expect(original).to.be.above(16 * 1024 * 1024);
const result = limitUnionSize(union, MAX_UNION_BYTES);
expect(result.bytes).to.be.at.most(MAX_UNION_BYTES);
expect(byteLength(result.json)).to.equal(result.bytes);
expect(result.degraded.originalBytes).to.equal(original);
expect(result.union).to.not.equal(null);
// Still parseable GeoJSON, not a truncated string.
expect(JSON.parse(result.json).geometry.type).to.equal('MultiPolygon');
});
it('never returns something bigger than the cap, whatever the input', function () {
const budget = 2000;
const result = limitUnionSize(multiPolygon(500, 144), budget);
expect(result.bytes).to.be.at.most(budget);
});
});