Skip to content

Commit

Permalink
feat: endpoint for adding alerts
Browse files Browse the repository at this point in the history
This adds a new endpoint,
`POST /projects/:projectId/remoteDetectionAlerts`, which adds remote
detection alerts.

It takes a payload like this:

```json
{
  "detectionDateStart": "2024-11-03T04:20:69Z",
  "detectionDateEnd": "2024-11-04T04:20:69Z",
  "sourceId": "abc123",
  "metadata": { "foo": "bar" },
  "geometry": {
    "type": "Point",
    "coordinates": [12, 34]
  }
}
```

And, if successful, responds with HTTP status 201 and an empty body.
  • Loading branch information
EvanHahn committed Nov 4, 2024
1 parent 4f0b0c6 commit 1717036
Show file tree
Hide file tree
Showing 6 changed files with 338 additions and 38 deletions.
35 changes: 35 additions & 0 deletions src/routes.js
Original file line number Diff line number Diff line change
Expand Up @@ -295,6 +295,41 @@ export default async function routes(
},
)

fastify.post(
'/projects/:projectPublicId/remoteDetectionAlerts',
{
schema: {
params: Type.Object({
projectPublicId: BASE32_STRING_32_BYTES,
}),
body: schemas.remoteDetectionAlertToAdd,
response: {
201: Type.Literal(''),
403: { $ref: 'HttpError' },
404: { $ref: 'HttpError' },
},
},
async preHandler(req) {
verifyBearerAuth(req)
await ensureProjectExists(this, req)
},
},
/**
* @this {FastifyInstance}
*/
async function (req, reply) {
const { projectPublicId } = req.params
const project = await this.comapeo.getProject(projectPublicId)

await project.remoteDetectionAlert.create({
schemaName: 'remoteDetectionAlert',
...req.body,
})

reply.status(201).send()
},
)

fastify.get(
'/projects/:projectPublicId/attachments/:driveDiscoveryId/:type/:name',
{
Expand Down
22 changes: 22 additions & 0 deletions src/schemas.js
Original file line number Diff line number Diff line change
Expand Up @@ -44,3 +44,25 @@ export const observationResult = Type.Object({
]),
),
})

export const remoteDetectionAlertToAdd = Type.Object({
detectionDateStart: dateTimeString,
detectionDateEnd: dateTimeString,
sourceId: Type.String({ minLength: 1 }),
metadata: Type.Record(
Type.String(),
Type.Union([
Type.Boolean(),
Type.Number(),
Type.String(),
Type.Null(),
Type.Array(
Type.Union([Type.Boolean(), Type.Number(), Type.String(), Type.Null()]),
),
]),
),
geometry: Type.Object({
type: Type.Literal('Point'),
coordinates: Type.Tuple([longitude, latitude]),
}),
})
241 changes: 241 additions & 0 deletions test/add-alerts-endpoint.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,241 @@
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'
import test from 'node:test'

import { remoteDetectionAlertToAdd } from '../src/schemas.js'
import {
BEARER_TOKEN,
createTestServer,
getManagerOptions,
omit,
randomAddProjectBody,
randomProjectPublicId,
runWithRetries,
} from './test-helpers.js'

/** @import { RemoteDetectionAlertValue } from '@comapeo/schema'*/
/** @import { FastifyInstance } from 'fastify' */

test('returns a 403 if no auth is provided', async (t) => {
const server = createTestServer(t)

const response = await server.inject({
method: 'POST',
url: `/projects/${randomProjectPublicId()}/remoteDetectionAlerts`,
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(generateAlert()),
})
assert.equal(response.statusCode, 403)
})

test('returns a 403 if incorrect auth is provided', async (t) => {
const server = createTestServer(t)

const projectPublicId = await addProject(server)

const response = await server.inject({
method: 'POST',
url: `/projects/${projectPublicId}/remoteDetectionAlerts`,
headers: {
Authorization: 'Bearer bad',
'Content-Type': 'application/json',
},
body: JSON.stringify(generateAlert()),
})
assert.equal(response.statusCode, 403)
})

test('returns a 403 if trying to add alerts to a non-existent project', async (t) => {
const server = createTestServer(t)

const response = await server.inject({
method: 'POST',
url: `/projects/${randomProjectPublicId()}/remoteDetectionAlerts`,
headers: {
Authorization: 'Bearer bad',
'Content-Type': 'application/json',
},
body: JSON.stringify(generateAlert()),
})
assert.equal(response.statusCode, 403)
})

test('returns a 400 if trying to add invalid alerts', async (t) => {
const server = createTestServer(t)

const projectPublicId = await addProject(server)

const alertKeys = /** @type {const} */ ([
'detectionDateStart',
'detectionDateEnd',
'sourceId',
'metadata',
'geometry',
])

const badAlerts = [
{},
{
...generateAlert(),
detectionDateStart: 'not a date',
},
{
...generateAlert(),
detectionDateEnd: 'not a date',
},
{
...generateAlert(),
geometry: {
type: 'Point',
coordinates: [-181.01, 0],
},
},
{
...generateAlert(),
geometry: {
type: 'Point',
coordinates: [181.01, 0],
},
},
{
...generateAlert(),
geometry: {
type: 'Point',
coordinates: [0, -90.01],
},
},
{
...generateAlert(),
geometry: {
type: 'Point',
coordinates: [0, 90.01],
},
},
{
...generateAlert(),
geometry: {
type: 'MultiPoint',
coordinates: [
[1, 2],
[3, 4],
],
},
},
...alertKeys.flatMap((keyToMessUp) => [
omit(generateAlert(), keyToMessUp),
{ ...generateAlert(), [keyToMessUp]: null },
]),
]

await Promise.all(
badAlerts.map(async (badAlert) => {
const body = JSON.stringify(badAlert)
assert(
!Value.Check(remoteDetectionAlertToAdd, body),
`test setup: ${body} should be invalid`,
)

const response = await server.inject({
method: 'POST',
url: `/projects/${projectPublicId}/remoteDetectionAlerts`,
headers: {
Authorization: 'Bearer ' + BEARER_TOKEN,
'Content-Type': 'application/json',
},
body,
})
assert.equal(
response.statusCode,
400,
`${body} should be invalid and return a 400`,
)
}),
)
})

test('adding alerts', async (t) => {
const server = createTestServer(t)
const serverAddressPromise = server.listen()

const manager = new MapeoManager(getManagerOptions())
const projectId = await manager.createProject({ name: 'CoMapeo project' })
const project = await manager.getProject(projectId)
t.after(() => project.close())

const serverAddress = await serverAddressPromise
const serverUrl = new URL(serverAddress)
await project.$member.addServerPeer(serverAddress, {
dangerouslyAllowInsecureConnections: true,
})

const alert = generateAlert()

const response = await server.inject({
authority: serverUrl.host,
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, '')

project.$sync.start()
project.$sync.connectServers()

await project.$sync.waitForSync('full')

// It's possible that the client thinks it's synced but doesn't know about
// the server's alert yet, so we try a few times.
await runWithRetries(3, async () => {
const alerts = await project.remoteDetectionAlert.getMany()
const hasOurAlert = alerts.some((a) => a.sourceId === alert.sourceId)
assert(hasOurAlert, 'alert was added and synced')
})
})

/**
* @param {FastifyInstance} server
* @returns {Promise<string>} 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'))
}

/**
* @param {number} min
* @param {number} max
* @returns {number}
*/
const randomNumber = (min, max) => min + Math.random() * (max - min)
const randomLatitude = randomNumber.bind(null, -90, 90)
const randomLongitude = randomNumber.bind(null, -180, 180)

function generateAlert() {
const remoteDetectionAlertDoc = generate('remoteDetectionAlert')[0]
assert(remoteDetectionAlertDoc)
return valueOf({
...remoteDetectionAlertDoc,
geometry: {
type: 'Point',
coordinates: [randomLongitude(), randomLatitude()],
},
})
}
14 changes: 1 addition & 13 deletions test/add-project-endpoint.js
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import test from 'node:test'

import {
createTestServer,
omit,
randomAddProjectBody,
randomHex,
} from './test-helpers.js'
Expand Down Expand Up @@ -210,16 +211,3 @@ test('adding the same project twice is idempotent', async (t) => {
})
assert.equal(secondResponse.statusCode, 200)
})

/**
* @template {object} T
* @template {keyof T} K
* @param {T} obj
* @param {K} key
* @returns {Omit<T, K>}
*/
function omit(obj, key) {
const result = { ...obj }
delete result[key]
return result
}
26 changes: 2 additions & 24 deletions test/observations-endpoint.js
Original file line number Diff line number Diff line change
Expand Up @@ -7,13 +7,14 @@ import { map } from 'iterpal'
import assert from 'node:assert/strict'
import * as fs from 'node:fs/promises'
import test from 'node:test'
import { setTimeout as delay } from 'node:timers/promises'

import {
BEARER_TOKEN,
createTestServer,
getManagerOptions,
randomAddProjectBody,
randomProjectPublicId,
runWithRetries,
} from './test-helpers.js'

/** @import { ObservationValue } from '@comapeo/schema'*/
Expand Down Expand Up @@ -167,12 +168,6 @@ test('returning observations with fetchable attachments', async (t) => {
)
})

function randomProjectPublicId() {
return projectKeyToPublicId(
Buffer.from(randomAddProjectBody().projectKey, 'hex'),
)
}

function generateObservation() {
const observationDoc = generate('observation')[0]
assert(observationDoc)
Expand All @@ -195,23 +190,6 @@ function blobToAttachment(blob) {
}
}

/**
* @template T
* @param {number} retries
* @param {() => Promise<T>} fn
* @returns {Promise<T>}
*/
async function runWithRetries(retries, fn) {
for (let i = 0; i < retries - 1; i++) {
try {
return await fn()
} catch {
await delay(500)
}
}
return fn()
}

/**
* @param {object} options
* @param {FastifyInstance} options.server
Expand Down
Loading

0 comments on commit 1717036

Please sign in to comment.