diff --git a/api-interop-layer/_tests_to_copy/Test/AlertUtilityGeometry.php.test b/api-interop-layer/_tests_to_copy/Test/AlertUtilityGeometry.php.test deleted file mode 100644 index d685cd579..000000000 --- a/api-interop-layer/_tests_to_copy/Test/AlertUtilityGeometry.php.test +++ /dev/null @@ -1,144 +0,0 @@ -dataLayer = $this->createMock(DataLayer::class); - } - - /** - * @group unit - * @group alert-utility - */ - public function testAlertHasGeometry(): void - { - $actual = AlertUtility::getGeometryAsJSON( - (object) ["geometry" => "existing geometry"], - $this->dataLayer, - ); - $this->assertEquals("existing geometry", $actual); - } - - /** - * @group unit - * @group alert-utility - */ - public function testAlertHasZones(): void - { - $alert = (object) [ - "geometry" => false, - "properties" => (object) [ - "affectedZones" => ["zone 1", "zone 2", "zone 3"], - ], - ]; - - $zoneShapes = (object) ["shape" => '{"combined":"zones"}']; - - $this->dataLayer->method("databaseFetch")->will( - $this->returnValueMap([ - // Return the shapes for the selected zones - [ - "SELECT ST_ASGEOJSON( - ST_SIMPLIFY( - ST_SRID( - ST_COLLECT(shape), - 0 - ), - 0.003 - ) - ) - as shape - FROM weathergov_geo_zones - WHERE id IN ('zone 1','zone 2','zone 3')", - [], - $zoneShapes, - ], - ]), - ); - - $expected = (object) [ - "combined" => "zones", - ]; - - $actual = AlertUtility::getGeometryAsJSON($alert, $this->dataLayer); - $this->assertEquals($expected, $actual); - } - - /** - * @group unit - * @group alert-utility - */ - public function testAlertHasCounties(): void - { - $alert = (object) [ - "geometry" => false, - "properties" => (object) [ - "geocode" => (object) [ - // SAME code is FIPS code with a leading zero. The leading - // zero gets stripped out, so we need to include it here - // so we get what we expect later. - "SAME" => ["0county 1", "0county 2", "0county 3"], - ], - ], - ]; - - $countyShapes = (object) ["shape" => '{"combined":"county"}']; - - $this->dataLayer->method("databaseFetch")->will( - $this->returnValueMap([ - // Return the shapes for the selected zones. Note that county - // FIPS codes are numeric, so they are not quoted in the query. - [ - "SELECT ST_ASGEOJSON( - ST_SIMPLIFY( - ST_SRID( - ST_COLLECT(shape), - 0 - ), - 0.003 - ) - ) - as shape - FROM weathergov_geo_counties - WHERE countyFips IN (county 1,county 2,county 3)", - [], - $countyShapes, - ], - ]), - ); - - $expected = (object) [ - "combined" => "county", - ]; - - $actual = AlertUtility::getGeometryAsJSON($alert, $this->dataLayer); - $this->assertEquals($expected, $actual); - } - - /** - * @group unit - * @group alert-utility - */ - public function testAintGotNothing(): void - { - $actual = AlertUtility::getGeometryAsJSON( - (object) ["geometry" => false, "properties" => (object) []], - $this->dataLayer, - ); - $this->assertEquals("", $actual); - } -} diff --git a/api-interop-layer/data/alerts/geometry.js b/api-interop-layer/data/alerts/geometry.js new file mode 100644 index 000000000..3783e425a --- /dev/null +++ b/api-interop-layer/data/alerts/geometry.js @@ -0,0 +1,72 @@ +const unwindGeometryCollection = (geojson, parentIsCollection = false) => { + if (geojson.type === "GeometryCollection") { + const geometries = geojson.geometries.flatMap((geometry) => + unwindGeometryCollection(geometry, true), + ); + if (parentIsCollection) { + return geometries; + } + + geojson.geometries = geometries; + return geojson; + } + + return geojson; +}; + +export const generateAlertGeometry = async (db, rawAlert) => { + // if the alert already has geometry, nothing to do + if (rawAlert.geometry) { + return unwindGeometryCollection(rawAlert.geometry); + } + + // if we have affected zones, generate a geometry from zones + const zones = rawAlert.properties.affectedZones; + if (Array.isArray(zones) && zones.length > 0) { + const sql = ` + SELECT ST_ASGEOJSON( + ST_SIMPLIFY( + ST_SRID( + ST_COLLECT(shape), + 0 + ), + 0.003 + ) + ) + AS shape + FROM weathergov_geo_zones + WHERE id IN (${zones.map(() => "?").join(",")})`; + const [{ shape }] = await db.query(sql, zones); + if (shape) { + return unwindGeometryCollection(shape); + } + } + + // if all geocodes are the same, generate a geometry from geocodes + const counties = rawAlert.properties.geocode?.SAME; + if (Array.isArray(counties) && counties.length > 0) { + const sql = ` + SELECT ST_ASGEOJSON( + ST_SIMPLIFY( + ST_SRID( + ST_COLLECT(shape), + 0 + ), + 0.003 + ) + ) + AS shape + FROM weathergov_geo_counties + WHERE countyFips IN (${counties.map((c) => `'${c.slice(1)}'`).join(",")})`; + const [{ shape }] = await db.query(sql); + + if (shape) { + return unwindGeometryCollection(shape); + } + } + + // we cannot generate a geometry. + return null; +} + +export default { generateAlertGeometry }; diff --git a/api-interop-layer/data/alerts/geometry.test.js b/api-interop-layer/data/alerts/geometry.test.js new file mode 100644 index 000000000..185801557 --- /dev/null +++ b/api-interop-layer/data/alerts/geometry.test.js @@ -0,0 +1,100 @@ +import sinon from "sinon"; +import { expect } from "chai"; +import * as mariadb from "mariadb"; +import { generateAlertGeometry } from "./geometry.js"; + +describe("alert geometries", () => { + const sandbox = sinon.createSandbox(); + const db = { + query: sandbox.stub(), + end: () => Promise.resolve(), + }; + + // Do this before everything, so it'll happen before any describe blocks run, + // otherwise the connection creation won't be stubbed when the script is first + // imported below. + before(() => { + mariadb.default.createConnection.resolves(db); + }); + + beforeEach(() => { + sandbox.resetBehavior(); + sandbox.resetHistory(); + }); + + it("returns an alert with a geometry as-is", async () => { + const rawAlert = { + geometry: "existing geometry", + }; + + const geometry = await generateAlertGeometry(db, rawAlert); + expect(geometry).to.equal("existing geometry"); + }); + + it("autogenerates a geometry from affected zones", async () => { + const affectedZones = ["zone 1", "zone 2", "zone 3"]; + const rawAlert = { + geometry: false, + properties: { + affectedZones, + }, + }; + const query = `SELECT ST_ASGEOJSON( + ST_SIMPLIFY( + ST_SRID( + ST_COLLECT(shape), + 0 + ), + 0.003 + ) + ) + AS shape + FROM weathergov_geo_zones + WHERE id IN (?,?,?)`; + db.query.withArgs(sinon.match(query), sinon.match.same(affectedZones)) + .resolves([{ shape: { combined: "zones" } }]); + + const geometry = await generateAlertGeometry(db, rawAlert); + expect(geometry).to.eql({ combined: "zones" }); + }); + + it("autogenerates a geometry from same geocodes if no zones are present", async () => { + const rawAlert = { + geometry: false, + properties: { + geocode: { + // SAME code is FIPS code with a leading zero. The leading + // zero gets stripped out, so we need to include it here + // so we get what we expect later. + "SAME": ["0county 1", "0county 2", "0county 3"], + }, + }, + }; + const query = `SELECT ST_ASGEOJSON( + ST_SIMPLIFY( + ST_SRID( + ST_COLLECT(shape), + 0 + ), + 0.003 + ) + ) + AS shape + FROM weathergov_geo_counties + WHERE countyFips IN ('county 1','county 2','county 3')`; + db.query.withArgs(sinon.match(query)).resolves([{ shape: { combined: "county" } }]); + + const geometry = await generateAlertGeometry(db, rawAlert); + expect(geometry).to.eql({ combined: "county" }); + }); + + it("returns null geometry if no zones or different geocodes are present", async () => { + const rawAlert = { + geometry: false, + properties: {}, + }; + + const geometry = await generateAlertGeometry(db, rawAlert); + expect(geometry).to.be.null; + }); +}); diff --git a/api-interop-layer/data/alerts/index.js b/api-interop-layer/data/alerts/index.js index b566a2954..45af639e2 100644 --- a/api-interop-layer/data/alerts/index.js +++ b/api-interop-layer/data/alerts/index.js @@ -10,6 +10,7 @@ import { parseLocations, } from "./parse/index.js"; import sort from "./sort.js"; +import { generateAlertGeometry } from "./geometry.js"; const logger = createLogger("alerts"); const cachedAlerts = []; @@ -19,22 +20,6 @@ const metadata = { updated: null, }; -const unwindGeometryCollection = (geojson, parentIsCollection = false) => { - if (geojson.type === "GeometryCollection") { - const geometries = geojson.geometries.flatMap((geometry) => - unwindGeometryCollection(geometry, true), - ); - if (parentIsCollection) { - return geometries; - } - - geojson.geometries = geometries; - return geojson; - } - - return geojson; -}; - export const updateAlerts = async () => { logger.verbose("updating alerts"); const rawAlerts = await fetchAPIJson("/alerts/active?status=actual").then( @@ -144,62 +129,7 @@ export const updateAlerts = async () => { alert.finish = null; } - alert.geometry = rawAlert.geometry; - - if (alert.geometry === null) { - const zones = rawAlert.properties.affectedZones; - const counties = rawAlert.properties.geocode?.SAME; - - if (Array.isArray(zones) && zones.length > 0) { - const sql = ` - SELECT ST_ASGEOJSON( - ST_SIMPLIFY( - ST_SRID( - ST_COLLECT(shape), - 0 - ), - 0.003 - ) - ) - AS shape - FROM weathergov_geo_zones - WHERE id IN (${zones.map(() => "?").join(",")})`; - const [{ shape }] = await db.query(sql, zones); - - if (shape) { - alert.geometry = shape; - } - } - - if ( - alert.geometry === null && - Array.isArray(counties) && - counties.length > 0 - ) { - const sql = ` - SELECT ST_ASGEOJSON( - ST_SIMPLIFY( - ST_SRID( - ST_COLLECT(shape), - 0 - ), - 0.003 - ) - ) - AS shape - FROM weathergov_geo_counties - WHERE countyFips IN (${counties.map((c) => `'${c.slice(1)}'`).join(",")})`; - const [{ shape }] = await db.query(sql); - - if (shape) { - alert.geometry = shape; - } - } - - if (alert.geometry) { - alert.geometry = unwindGeometryCollection(alert.geometry); - } - } + alert.geometry = await generateAlertGeometry(db, rawAlert); } alerts.sort(sort);