From 6d183116ac6ff5517aff5ceec9c2508279b7252e Mon Sep 17 00:00:00 2001 From: Evan Hahn Date: Wed, 11 Dec 2024 17:05:24 +0000 Subject: [PATCH 1/2] WIP: list alerts --- src/routes.js | 44 ++++++++++++++++++++++++++++++++++++++++++++ src/schemas.js | 14 +++++++++++++- 2 files changed, 57 insertions(+), 1 deletion(-) diff --git a/src/routes.js b/src/routes.js index f0a9dcc..fdfe9b2 100644 --- a/src/routes.js +++ b/src/routes.js @@ -321,6 +321,50 @@ export default async function routes( }, ) + fastify.get( + '/projects/:projectPublicId/remoteDetectionAlerts', + { + schema: { + params: Type.Object({ + projectPublicId: BASE32_STRING_32_BYTES, + }), + response: { + 200: Type.Object({ + data: Type.Array(schemas.remoteDetectionAlertResult), + }), + '4xx': schemas.errorResponse, + }, + }, + async preHandler(req) { + verifyBearerAuth(req) + await ensureProjectExists(this, req) + }, + }, + /** + * @this {FastifyInstance} + */ + async function (req) { + const { projectPublicId } = req.params + const project = await this.comapeo.getProject(projectPublicId) + + return { + data: ( + await project.remoteDetectionAlert.getMany({ includeDeleted: true }) + ).map((alert) => ({ + docId: alert.docId, + createdAt: alert.createdAt, + updatedAt: alert.updatedAt, + deleted: alert.deleted, + detectionDateStart: alert.detectionDateStart, + detectionDateEnd: alert.detectionDateEnd, + sourceId: alert.sourceId, + metadata: alert.metadata, + geometry: alert.geometry, + })), + } + }, + ) + fastify.post( '/projects/:projectPublicId/remoteDetectionAlerts', { diff --git a/src/schemas.js b/src/schemas.js index 11a7917..8bef506 100644 --- a/src/schemas.js +++ b/src/schemas.js @@ -52,7 +52,7 @@ export const observationResult = Type.Object({ ), }) -export const remoteDetectionAlertToAdd = Type.Object({ +const remoteDetectionAlertCommon = { detectionDateStart: dateTimeString, detectionDateEnd: dateTimeString, sourceId: Type.String({ minLength: 1 }), @@ -72,4 +72,16 @@ export const remoteDetectionAlertToAdd = Type.Object({ type: Type.Literal('Point'), coordinates: Type.Tuple([longitude, latitude]), }), +} + +export const remoteDetectionAlertToAdd = Type.Object({ + ...remoteDetectionAlertCommon, +}) + +export const remoteDetectionAlertResult = Type.Object({ + docId: Type.String(), + createdAt: dateTimeString, + updatedAt: dateTimeString, + deleted: Type.Boolean(), + ...remoteDetectionAlertCommon, }) From 57daf797a6b3f5a978b43c56ac573a0c91c0239c Mon Sep 17 00:00:00 2001 From: Gregor MacLennan Date: Thu, 23 Jan 2025 21:01:49 +0000 Subject: [PATCH 2/2] chore: add tests for list-alerts --- package-lock.json | 95 ++++++++++++++++----- package.json | 3 +- test/add-alerts-endpoint.js | 47 +---------- test/list-alerts-endpoint.js | 159 +++++++++++++++++++++++++++++++++++ test/test-helpers.js | 49 +++++++++++ 5 files changed, 286 insertions(+), 67 deletions(-) create mode 100644 test/list-alerts-endpoint.js diff --git a/package-lock.json b/package-lock.json index 807b7a4..e5108ba 100644 --- a/package-lock.json +++ b/package-lock.json @@ -22,7 +22,8 @@ "devDependencies": { "@comapeo/schema": "^1.2.0", "@eslint/js": "^9.14.0", - "@mapeo/mock-data": "^2.1.1", + "@garbee/iso8601": "^1.0.3", + "@mapeo/mock-data": "^2.1.5", "@trivago/prettier-plugin-sort-imports": "^4.3.0", "@types/node": "^22.8.4", "@types/ws": "^8.5.13", @@ -748,9 +749,9 @@ } }, "node_modules/@faker-js/faker": { - "version": "8.4.1", - "resolved": "https://registry.npmjs.org/@faker-js/faker/-/faker-8.4.1.tgz", - "integrity": "sha512-XQ3cU+Q8Uqmrbf2e0cIC/QN43sTBSC8KF12u29Mb47tWrt2hAgBXSgpZMj4Ao8Uk0iJcU99QsOCaIL8934obCg==", + "version": "9.4.0", + "resolved": "https://registry.npmjs.org/@faker-js/faker/-/faker-9.4.0.tgz", + "integrity": "sha512-85+k0AxaZSTowL0gXp8zYWDIrWclTbRPg/pm/V0dSFZ6W6D4lhcG3uuZl4zLsEKfEvs69xDbLN2cHQudwp95JA==", "dev": true, "funding": [ { @@ -760,8 +761,8 @@ ], "license": "MIT", "engines": { - "node": "^14.17.0 || ^16.13.0 || >=18.0.0", - "npm": ">=6.14.13" + "node": ">=18.0.0", + "npm": ">=9.0.0" } }, "node_modules/@fastify/accept-negotiator": { @@ -944,6 +945,13 @@ "ws": "^8.0.0" } }, + "node_modules/@garbee/iso8601": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@garbee/iso8601/-/iso8601-1.0.3.tgz", + "integrity": "sha512-Q7/bzVSojECgkRrwSFxiYRkmLaJv7T1ueLX3LeaGrljB816LHBJ1h3zMjLNIUKtiZ18FEPmBhJuUeZ/1aXL2zA==", + "dev": true, + "license": "Unlicense" + }, "node_modules/@humanfs/core": { "version": "0.19.1", "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz", @@ -1107,6 +1115,32 @@ "@jridgewell/sourcemap-codec": "^1.4.14" } }, + "node_modules/@jsep-plugin/assignment": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/@jsep-plugin/assignment/-/assignment-1.3.0.tgz", + "integrity": "sha512-VVgV+CXrhbMI3aSusQyclHkenWSAm95WaiKrMxRFam3JSUiIaQjoMIw2sEs/OX4XifnqeQUN4DYbJjlA8EfktQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 10.16.0" + }, + "peerDependencies": { + "jsep": "^0.4.0||^1.0.0" + } + }, + "node_modules/@jsep-plugin/regex": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/@jsep-plugin/regex/-/regex-1.0.4.tgz", + "integrity": "sha512-q7qL4Mgjs1vByCaTnDFcBnV9HS7GVPJX5vyVoCgZHNSC9rjwIlmbXG5sUuorR5ndfHAIlJ8pVStxvjXHbNvtUg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 10.16.0" + }, + "peerDependencies": { + "jsep": "^0.4.0||^1.0.0" + } + }, "node_modules/@lukeed/ms": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/@lukeed/ms/-/ms-2.0.2.tgz", @@ -1160,23 +1194,23 @@ } }, "node_modules/@mapeo/mock-data": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/@mapeo/mock-data/-/mock-data-2.1.1.tgz", - "integrity": "sha512-BBR10Dk+eqlm+r75gj/kvMNFSyAuueT2zrxOvMw/Yj9lVxFF4E1077oKzGDu0JD/4bbthBdQYTDT7Xl463zxNw==", + "version": "2.1.5", + "resolved": "https://registry.npmjs.org/@mapeo/mock-data/-/mock-data-2.1.5.tgz", + "integrity": "sha512-pYFpQ3EJMPtEchftz++T8Cr6huSVMb2LS8LmOKmMSEj2XQDfy4XED+BsG6xww4ykILL5Me64AinYCMEBmgYWIw==", "dev": true, "license": "MIT", "dependencies": { - "@faker-js/faker": "^8.3.1", + "@faker-js/faker": "^9.2.0", "dereference-json-schema": "^0.2.1", - "json-schema-faker": "^0.5.3", - "type-fest": "^4.8.0" + "json-schema-faker": "^0.5.8", + "type-fest": "^4.29.1" }, "bin": { "generate-mapeo-data": "bin/generate-mapeo-data.js", "list-mapeo-schemas": "bin/list-mapeo-schemas.js" }, "peerDependencies": { - "@comapeo/schema": "^1.1.1" + "@comapeo/schema": "^1.4.1" } }, "node_modules/@maplibre/maplibre-gl-style-spec": { @@ -4788,6 +4822,16 @@ "js-yaml": "bin/js-yaml.js" } }, + "node_modules/jsep": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/jsep/-/jsep-1.4.0.tgz", + "integrity": "sha512-B7qPcEVE3NVkmSJbaYxvv4cHkVW7DQsZz13pUMrfS8z8Q/BuShN+gcTXrUlPiGqM2/t/EEaI030bpxMqY8gMlw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 10.16.0" + } + }, "node_modules/jsesc": { "version": "2.5.2", "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-2.5.2.tgz", @@ -4816,14 +4860,14 @@ "license": "MIT" }, "node_modules/json-schema-faker": { - "version": "0.5.6", - "resolved": "https://registry.npmjs.org/json-schema-faker/-/json-schema-faker-0.5.6.tgz", - "integrity": "sha512-u/cFC26/GDxh2vPiAC8B8xVvpXAW+QYtG2mijEbKrimCk8IHtiwQBjCE8TwvowdhALWq9IcdIWZ+/8ocXvdL3Q==", + "version": "0.5.8", + "resolved": "https://registry.npmjs.org/json-schema-faker/-/json-schema-faker-0.5.8.tgz", + "integrity": "sha512-sqzPEbEDlpiH8U1tfmJHScXHy52onvMxITPsHyhe/jhS83g8TX6ruvRqt/ot1bXUPRsh7Ps1sWqJiBxIXmW5Xw==", "dev": true, "license": "MIT", "dependencies": { "json-schema-ref-parser": "^6.1.0", - "jsonpath-plus": "^7.2.0" + "jsonpath-plus": "^10.1.0" }, "bin": { "jsf": "bin/gen.cjs" @@ -4921,13 +4965,22 @@ } }, "node_modules/jsonpath-plus": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/jsonpath-plus/-/jsonpath-plus-7.2.0.tgz", - "integrity": "sha512-zBfiUPM5nD0YZSBT/o/fbCUlCcepMIdP0CJZxM1+KgA4f2T206f6VAg9e7mX35+KlMaIc5qXW34f3BnwJ3w+RA==", + "version": "10.2.0", + "resolved": "https://registry.npmjs.org/jsonpath-plus/-/jsonpath-plus-10.2.0.tgz", + "integrity": "sha512-T9V+8iNYKFL2n2rF+w02LBOT2JjDnTjioaNFrxRy0Bv1y/hNsqR/EBK7Ojy2ythRHwmz2cRIls+9JitQGZC/sw==", "dev": true, "license": "MIT", + "dependencies": { + "@jsep-plugin/assignment": "^1.3.0", + "@jsep-plugin/regex": "^1.0.4", + "jsep": "^1.4.0" + }, + "bin": { + "jsonpath": "bin/jsonpath-cli.js", + "jsonpath-plus": "bin/jsonpath-cli.js" + }, "engines": { - "node": ">=12.0.0" + "node": ">=18.0.0" } }, "node_modules/keyv": { diff --git a/package.json b/package.json index f908f84..654781e 100644 --- a/package.json +++ b/package.json @@ -43,7 +43,8 @@ "devDependencies": { "@comapeo/schema": "^1.2.0", "@eslint/js": "^9.14.0", - "@mapeo/mock-data": "^2.1.1", + "@garbee/iso8601": "^1.0.3", + "@mapeo/mock-data": "^2.1.5", "@trivago/prettier-plugin-sort-imports": "^4.3.0", "@types/node": "^22.8.4", "@types/ws": "^8.5.13", diff --git a/test/add-alerts-endpoint.js b/test/add-alerts-endpoint.js index ef8f8c1..0f28f77 100644 --- a/test/add-alerts-endpoint.js +++ b/test/add-alerts-endpoint.js @@ -1,7 +1,5 @@ import { MapeoManager } from '@comapeo/core' -import { valueOf } from '@comapeo/schema' import { keyToPublicId as projectKeyToPublicId } from '@mapeo/crypto' -import { generate } from '@mapeo/mock-data' import { Value } from '@sinclair/typebox/value' import assert from 'node:assert/strict' @@ -11,6 +9,8 @@ import { remoteDetectionAlertToAdd } from '../src/schemas.js' import { BEARER_TOKEN, createTestServer, + generateAlert, + generateAlerts, getManagerOptions, omit, randomAddProjectBody, @@ -221,46 +221,3 @@ async function addProject(server) { const { projectKey } = body return projectKeyToPublicId(Buffer.from(projectKey, 'hex')) } - -function generateAlert() { - const [result] = generateAlerts(1, ['Point']) - assert(result) - return result -} - -const SUPPORTED_GEOMETRY_TYPES = /** @type {const} */ ([ - 'Point', - 'MultiPoint', - 'LineString', - 'MultiLineString', - 'Polygon', - 'MultiPolygon', -]) - -/** - * @param {number} count - * @param {ReadonlyArray} [geometryTypes] - */ -function generateAlerts(count, geometryTypes = SUPPORTED_GEOMETRY_TYPES) { - if (count < geometryTypes.length) { - throw new Error( - 'test setup: count must be at least as large as geometryTypes', - ) - } - // Hacky, but should get the job done ensuring we have all geometry types in the test - const alerts = [] - for (const geometryType of geometryTypes) { - /** @type {import('@comapeo/schema').RemoteDetectionAlert | undefined} */ - let alert - while (!alert || alert.geometry.type !== geometryType) { - ;[alert] = generate('remoteDetectionAlert', { count: 1 }) - } - alerts.push(alert) - } - // eslint-disable-next-line prefer-spread - alerts.push.apply( - alerts, - generate('remoteDetectionAlert', { count: count - alerts.length }), - ) - return alerts.map((alert) => valueOf(alert)) -} diff --git a/test/list-alerts-endpoint.js b/test/list-alerts-endpoint.js new file mode 100644 index 0000000..73a1e25 --- /dev/null +++ b/test/list-alerts-endpoint.js @@ -0,0 +1,159 @@ +import { MapeoManager } from '@comapeo/core' +import { valueOf } from '@comapeo/schema' +import { isValidDateTime } from '@garbee/iso8601' +import { keyToPublicId as projectKeyToPublicId } from '@mapeo/crypto' + +import assert from 'node:assert/strict' +import test from 'node:test' + +import { + BEARER_TOKEN, + createTestServer, + generateAlert, + generateAlerts, + getManagerOptions, + randomAddProjectBody, + randomProjectPublicId, +} from './test-helpers.js' + +/** @import { FastifyInstance } from 'fastify' */ + +test('returns a 401 if no auth is provided', async (t) => { + const server = createTestServer(t) + + const response = await server.inject({ + method: 'GET', + url: `/projects/${randomProjectPublicId()}/remoteDetectionAlerts`, + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(generateAlert()), + }) + assert.equal(response.statusCode, 401) + assert.equal(response.json().error.code, 'UNAUTHORIZED') +}) + +test('returns a 401 if incorrect auth is provided', async (t) => { + const server = createTestServer(t) + + const projectPublicId = await addProject(server) + + const response = await server.inject({ + method: 'GET', + url: `/projects/${projectPublicId}/remoteDetectionAlerts`, + headers: { + Authorization: 'Bearer bad', + 'Content-Type': 'application/json', + }, + body: JSON.stringify(generateAlert()), + }) + assert.equal(response.statusCode, 401) + assert.equal(response.json().error.code, 'UNAUTHORIZED') +}) + +test('returns a 404 if trying to list alerts from a non-existent project', async (t) => { + const server = createTestServer(t) + + const response = await server.inject({ + method: 'GET', + url: `/projects/${randomProjectPublicId()}/remoteDetectionAlerts`, + headers: { + Authorization: 'Bearer ' + BEARER_TOKEN, + 'Content-Type': 'application/json', + }, + body: JSON.stringify(generateAlert()), + }) + assert.equal(response.statusCode, 404) + assert.equal(response.json().error.code, 'PROJECT_NOT_FOUND') +}) + +test.only('adding alerts', async (t) => { + const server = createTestServer(t) + + const serverAddress = await server.listen() + + const manager = new MapeoManager(getManagerOptions()) + const projectId = await manager.createProject({ name: 'CoMapeo project' }) + const project = await manager.getProject(projectId) + + await project.$member.addServerPeer(serverAddress, { + dangerouslyAllowInsecureConnections: true, + }) + + project.$sync.start() + project.$sync.connectServers() + await project.$sync.waitForSync('full') + const count = 100 + + const generatedAlerts = generateAlerts(count) + + await Promise.all( + generatedAlerts.map(async (alert) => { + const response = await server.inject({ + method: 'POST', + url: `/projects/${projectId}/remoteDetectionAlerts`, + headers: { + Authorization: 'Bearer ' + BEARER_TOKEN, + 'Content-Type': 'application/json', + }, + body: JSON.stringify(alert), + }) + assert.equal(response.statusCode, 201) + assert.equal(response.body, '') + }), + ) + + const response = await server.inject({ + method: 'GET', + url: `/projects/${projectId}/remoteDetectionAlerts`, + headers: { + Authorization: 'Bearer ' + BEARER_TOKEN, + }, + }) + + const alertsValues = response.json().data.map(valueOf) + + assert.equal(alertsValues.length, count) + + assert.deepEqual( + new Set(alertsValues.map(normalizeISODateStrings)), + new Set(generatedAlerts.map(normalizeISODateStrings)), + ) +}) + +/** + * @param {FastifyInstance} server + * @returns {Promise} a promise that resolves with the project's public ID + */ +async function addProject(server) { + const body = randomAddProjectBody() + const response = await server.inject({ + method: 'PUT', + url: '/projects', + body, + }) + assert.equal(response.statusCode, 200, 'test setup: adding a project') + + const { projectKey } = body + return projectKeyToPublicId(Buffer.from(projectKey, 'hex')) +} + +/** + * @template {Record} T + * @param {T} obj + * @returns {T} + */ +function normalizeISODateStrings(obj) { + /** @type {any} */ + const normalized = {} + for (const key in normalized) { + if (typeof obj[key] === 'string' && isValidDateTime(obj[key])) { + normalized[key] = new Date(obj[key]).toISOString() + } else if (obj[key] === null || typeof obj[key] !== 'object') { + normalized[key] = obj[key] + } else if (Array.isArray(obj[key])) { + normalized[key] = obj[key].map(normalizeISODateStrings) + } else { + normalized[key] = normalizeISODateStrings(obj[key]) + } + } + return normalized +} diff --git a/test/test-helpers.js b/test/test-helpers.js index 871a9e4..8a71512 100644 --- a/test/test-helpers.js +++ b/test/test-helpers.js @@ -1,10 +1,13 @@ +import { valueOf } from '@comapeo/schema' import { KeyManager, keyToPublicId as projectKeyToPublicId, } from '@mapeo/crypto' +import { generate } from '@mapeo/mock-data' import createFastify from 'fastify' import RAM from 'random-access-memory' +import assert from 'node:assert/strict' import { randomBytes } from 'node:crypto' import { setTimeout as delay } from 'node:timers/promises' @@ -114,3 +117,49 @@ export async function runWithRetries(retries, fn) { } return fn() } + +export function generateAlert() { + const [result] = generateAlerts(1, ['Point']) + assert(result) + return result +} + +const SUPPORTED_GEOMETRY_TYPES = /** @type {const} */ ([ + 'Point', + 'MultiPoint', + 'LineString', + 'MultiLineString', + 'Polygon', + 'MultiPolygon', +]) + +/** + * @param {number} count + * @param {ReadonlyArray} [geometryTypes] + */ +export function generateAlerts( + count, + geometryTypes = SUPPORTED_GEOMETRY_TYPES, +) { + if (count < geometryTypes.length) { + throw new Error( + 'test setup: count must be at least as large as geometryTypes', + ) + } + // Hacky, but should get the job done ensuring we have all geometry types in the test + const alerts = [] + for (const geometryType of geometryTypes) { + /** @type {import('@comapeo/schema').RemoteDetectionAlert | undefined} */ + let alert + while (!alert || alert.geometry.type !== geometryType) { + ;[alert] = generate('remoteDetectionAlert', { count: 1 }) + } + alerts.push(alert) + } + // eslint-disable-next-line prefer-spread + alerts.push.apply( + alerts, + generate('remoteDetectionAlert', { count: count - alerts.length }), + ) + return alerts.map((alert) => valueOf(alert)) +}