From 108d82353ec30351234351b3124bae62360cef40 Mon Sep 17 00:00:00 2001 From: Timber Date: Sat, 8 Nov 2025 12:31:26 +0100 Subject: [PATCH 01/14] feat: create response utils --- .../api.boxes.$deviceId.data.$sensorId.ts | 50 ++------------- app/utils/param-utils.ts | 21 +----- app/utils/response-utils.ts | 64 +++++++++++++++++++ 3 files changed, 73 insertions(+), 62 deletions(-) create mode 100644 app/utils/response-utils.ts diff --git a/app/routes/api.boxes.$deviceId.data.$sensorId.ts b/app/routes/api.boxes.$deviceId.data.$sensorId.ts index d5f67ae8..1954f88d 100644 --- a/app/routes/api.boxes.$deviceId.data.$sensorId.ts +++ b/app/routes/api.boxes.$deviceId.data.$sensorId.ts @@ -4,13 +4,7 @@ import { getMeasurements } from "~/models/sensor.server"; import { type Measurement } from "~/schema"; import { convertToCsv } from "~/utils/csv"; import { parseDateParam, parseEnumParam } from "~/utils/param-utils"; - -const badRequestInit = { - status: 400, - headers: { - "Content-Type": "application/json; charset=utf-8", - }, -}; +import { badRequest, internalServerError, notFound } from "~/utils/response-utils"; /** * @openapi @@ -113,12 +107,7 @@ export const loader: LoaderFunction = async ({ let meas: Measurement[] | TransformedMeasurement[] = await getMeasurements(sensorId, fromDate.toISOString(), toDate.toISOString()); if (meas == null) - return new Response(JSON.stringify({ message: "Device not found." }), { - status: 404, - headers: { - "content-type": "application/json; charset=utf-8", - }, - }); + return notFound("Device not found."); if (outliers) meas = transformOutliers(meas, outlierWindow, outliers == "replace"); @@ -143,19 +132,7 @@ export const loader: LoaderFunction = async ({ } catch (err) { console.warn(err); - return Response.json( - { - error: "Internal Server Error", - message: - "The server was unable to complete your request. Please try again later.", - }, - { - status: 500, - headers: { - "Content-Type": "application/json; charset=utf-8", - }, - }, - ); + return internalServerError(); } }; @@ -174,20 +151,10 @@ function collectParameters(request: Request, params: Params): // deviceId is there for legacy reasons const deviceId = params.deviceId; if (deviceId === undefined) - return Response.json( - { - code: "Bad Request", - message: "Invalid device id specified", - }, badRequestInit - ); + return badRequest("Invalid device id specified"); const sensorId = params.sensorId; if (sensorId === undefined) - return Response.json( - { - code: "Bad Request", - message: "Invalid sensor id specified", - }, badRequestInit - ); + return badRequest("Invalid sensor id specified"); const url = new URL(request.url); @@ -199,12 +166,7 @@ function collectParameters(request: Request, params: Params): let outlierWindow: number = 15; if (outlierWindowParam !== null) { if (Number.isNaN(outlierWindowParam) || Number(outlierWindowParam) < 1 || Number(outlierWindowParam) > 50) - return Response.json( - { - error: "Bad Request", - message: "Illegal value for parameter outlier-window. Allowed values: numbers between 1 and 50", - }, badRequestInit - ); + return badRequest("Illegal value for parameter outlier-window. Allowed values: numbers between 1 and 50"); outlierWindow = Number(outlierWindowParam); } diff --git a/app/utils/param-utils.ts b/app/utils/param-utils.ts index aba243db..43a6366e 100644 --- a/app/utils/param-utils.ts +++ b/app/utils/param-utils.ts @@ -1,9 +1,4 @@ -const badRequestInit = { - status: 400, - headers: { - "Content-Type": "application/json; charset=utf-8", - }, -}; +import { badRequest } from "./response-utils"; /** * Parses a parameter from the url search paramaters into a date. @@ -19,12 +14,7 @@ export function parseDateParam(url: URL, paramName: string, defaultDate: Date): if (param) { const date = new Date(param) if (Number.isNaN(date.valueOf())) - return Response.json( - { - error: "Bad Request", - message: `Illegal value for parameter ${paramName}. Allowed values: RFC3339Date`, - }, badRequestInit - ); + return badRequest(`Illegal value for parameter ${paramName}. Allowed values: RFC3339Date`); return date } return defaultDate; @@ -44,12 +34,7 @@ export function parseEnumParam(url: URL, paramName: string, allowed const param = url.searchParams.get(paramName); if (param) { if (!allowedValues.includes(param)) - return Response.json( - { - error: "Bad Request", - message: `Illegal value for parameter ${paramName}. Allowed values: ${allowedValues}`, - }, badRequestInit - ); + return badRequest(`Illegal value for parameter ${paramName}. Allowed values: ${allowedValues}`); return param; } return defaultValue diff --git a/app/utils/response-utils.ts b/app/utils/response-utils.ts new file mode 100644 index 00000000..28e16f4a --- /dev/null +++ b/app/utils/response-utils.ts @@ -0,0 +1,64 @@ +// TODO: Proposal: Use these functions everywhere, to make the code shorter + +/** + * Creates a response object for a bad request + * @param message The message for the response + * @returns The response + */ +export function badRequest(message: string): Response { + return Response.json( + { + error: 'Bad Request', + message: message, + }, + { + status: 400, + headers: { + 'Content-Type': 'application/json; charset=utf-8', + }, + }, + ) +} + +/** + * Creates a response object for an internal server error + * @param message The message for the response. Default: + * The server was unable to complete your request. Please try again later. + * @returns The response + */ +export function internalServerError(message = + "The server was unable to complete your request. Please try again later."): Response { + Response.error() + return Response.json( + { + error: "Internal Server Error", + message: message + }, + { + status: 500, + headers: { + "Content-Type": "application/json; charset=utf-8", + }, + }, + ); +} + +/** + * Creates a response object for a 404 + * @param message The message for the response + * @returns The response + */ +export function notFound(message: string): Response { + return Response.json( + { + error: 'Not found', + message: message, + }, + { + status: 404, + headers: { + 'Content-Type': 'application/json; charset=utf-8', + }, + }, + ) +} From 76116e6dbeb47c6b74a93e21e2f7bf16f2eb060c Mon Sep 17 00:00:00 2001 From: Timber Date: Sat, 8 Nov 2025 13:00:11 +0100 Subject: [PATCH 02/14] feat: add response types to api.boxes.deviceId.data.sensorId --- .../api.boxes.$deviceId.data.$sensorId.ts | 45 +++++++++++++++++++ 1 file changed, 45 insertions(+) diff --git a/app/routes/api.boxes.$deviceId.data.$sensorId.ts b/app/routes/api.boxes.$deviceId.data.$sensorId.ts index 1954f88d..0591a127 100644 --- a/app/routes/api.boxes.$deviceId.data.$sensorId.ts +++ b/app/routes/api.boxes.$deviceId.data.$sensorId.ts @@ -91,6 +91,51 @@ import { badRequest, internalServerError, notFound } from "~/utils/response-util * 200: * description: Success * content: + * application/json: + * schema: + * type: array + * example: '[{"sensor_id":"6649b23072c4c40007105953","time":"2025-11-06 23:59:57.189+00","value":4.78,"location_id":"5752066"},{"sensor_id":"6649b23072c4c40007105953","time":"2025-11-06 23:57:06.03+00","value":4.13,"location_id":"5752066"}]' + * text/csv: + * example: "createdAt,value + * 2023-09-29T08:06:13.254Z,6.38 + * 2023-09-29T08:06:12.312Z,6.38 + * 2023-09-29T08:06:11.513Z,6.38 + * 2023-09-29T08:06:10.380Z,6.38 + * 2023-09-29T08:06:09.569Z,6.38 + * 2023-09-29T08:06:05.967Z,6.38" + * 400: + * description: Bad Request + * content: + * application/json: + * schema: + * type: object + * properties: + * error: + * type: string + * message: + * type: string + * 404: + * description: Not found + * content: + * application/json: + * schema: + * type: object + * properties: + * error: + * type: string + * message: + * type: string + * 500: + * description: Internal Server Error + * content: + * application/json: + * schema: + * type: object + * properties: + * error: + * type: string + * message: + * type: string */ export const loader: LoaderFunction = async ({ From 7931d2640fed0e88a80e4b792b2a22a6dee18b23 Mon Sep 17 00:00:00 2001 From: Timber Date: Sat, 8 Nov 2025 13:02:06 +0100 Subject: [PATCH 03/14] fix: replace tab with space --- app/routes/api.device.$deviceId.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/routes/api.device.$deviceId.ts b/app/routes/api.device.$deviceId.ts index edcef08c..af9c896d 100644 --- a/app/routes/api.device.$deviceId.ts +++ b/app/routes/api.device.$deviceId.ts @@ -48,7 +48,7 @@ import { getDevice } from "~/models/device.server"; * error: * type: string * example: "Device not found" - * 400: + * 400: * description: Device ID is required * content: * application/json: From 2c826e5b5d6676061a6ba73c2312ac6dd4d53e44 Mon Sep 17 00:00:00 2001 From: Timber Date: Sat, 8 Nov 2025 13:55:38 +0100 Subject: [PATCH 04/14] feat: implement location endpoint --- .../api.boxes.$deviceId.data.$sensorId.ts | 2 +- app/routes/api.boxes.$deviceId.locations.ts | 194 ++++++++++++++++++ 2 files changed, 195 insertions(+), 1 deletion(-) create mode 100644 app/routes/api.boxes.$deviceId.locations.ts diff --git a/app/routes/api.boxes.$deviceId.data.$sensorId.ts b/app/routes/api.boxes.$deviceId.data.$sensorId.ts index 0591a127..3fd1b316 100644 --- a/app/routes/api.boxes.$deviceId.data.$sensorId.ts +++ b/app/routes/api.boxes.$deviceId.data.$sensorId.ts @@ -163,7 +163,7 @@ export const loader: LoaderFunction = async ({ if (download) headers["Content-Disposition"] = `attachment; filename=${sensorId}.${format}`; - let responseInit: ResponseInit = { + const responseInit: ResponseInit = { status: 200, headers: headers, }; diff --git a/app/routes/api.boxes.$deviceId.locations.ts b/app/routes/api.boxes.$deviceId.locations.ts new file mode 100644 index 00000000..e3504976 --- /dev/null +++ b/app/routes/api.boxes.$deviceId.locations.ts @@ -0,0 +1,194 @@ +import { type Params, type LoaderFunction, type LoaderFunctionArgs } from "react-router"; +import { type TransformedMeasurement, transformOutliers } from "~/lib/outlier-transform"; +import { getDevice } from "~/models/device.server"; +import { getMeasurements } from "~/models/sensor.server"; +import { type Measurement } from "~/schema"; +import { convertToCsv } from "~/utils/csv"; +import { parseDateParam, parseEnumParam } from "~/utils/param-utils"; +import { badRequest, internalServerError, notFound } from "~/utils/response-utils"; + +/** + * @openapi + * /boxes/{deviceId}/locations: + * get: + * tags: + * - Boxes + * summary: Get locations of a senseBox + * description: Get all locations of the specified senseBox ordered by date as an array of GeoJSON Points. + * If `format=geojson`, a GeoJSON linestring will be returned, with `properties.timestamps` + * being an array with the timestamp for each coordinate. + * parameters: + * - in: path + * name: deviceId + * required: true + * schema: + * type: string + * description: the ID of the senseBox you are referring to + * - in: query + * name: from-date + * required: false + * schema: + * type: string + * description: RFC3339Date + * format: date-time + * description: "Beginning date of measurement data (default: 48 hours ago from now)" + * - in: query + * name: to-date + * required: false + * schema: + * type: string + * descrption: TFC3339Date + * format: date-time + * description: "End date of measurement data (default: now)" + * - in: query + * name: format + * required: false + * schema: + * type: string + * enum: + * - json + * - geojson + * default: json + * description: "Can be 'json' (default) or 'geojson' (default: json)" + * responses: + * 200: + * description: Success + * content: + * application/json: + * schema: + * type: array + * example: '[{ "coordinates": [7.68123, 51.9123], "type": "Point", "timestamp": "2017-07-27T12:00:00Z"},{ "coordinates": [7.68223, 51.9433, 66.6], "type": "Point", "timestamp": "2017-07-27T12:01:00Z"},{ "coordinates": [7.68323, 51.9423], "type": "Point", "timestamp": "2017-07-27T12:02:00Z"}]' + * application/geojson: + * example: '' + * 400: + * description: Bad Request + * content: + * application/json: + * schema: + * type: object + * properties: + * error: + * type: string + * message: + * type: string + * 404: + * description: Not found + * content: + * application/json: + * schema: + * type: object + * properties: + * error: + * type: string + * message: + * type: string + * 500: + * description: Internal Server Error + * content: + * application/json: + * schema: + * type: object + * properties: + * error: + * type: string + * message: + * type: string + */ + +export const loader: LoaderFunction = async ({ + request, + params, +}: LoaderFunctionArgs): Promise => { + try { + + const collected = collectParameters(request, params); + if (collected instanceof Response) + return collected; + // eslint-disable-next-line @typescript-eslint/no-unused-vars + const {deviceId, fromDate, toDate, format} = collected; + + const device = await getDevice({ id: deviceId }); + if (!device) + return notFound("Device not found"); + + const locations = device.locations.filter(location => + new Date(location.time) >= fromDate && new Date(location.time) <= toDate); + + const jsonLocations = locations.map((location) => { + return { + coordinates: [location.geometry.x, location.geometry.y], + type: 'Point', + timestamp: location.time, + } + }); + + let headers: HeadersInit = { + "content-type": format == "json" ? "application/json; charset=utf-8" : "application/geo+json; charset=utf-8", + }; + + const responseInit: ResponseInit = { + status: 200, + headers: headers, + }; + + if (format == "json") + return Response.json(jsonLocations, responseInit); + else { + const geoJsonLocations = { + type: 'Feature', + geometry: { + type: 'LineString', coordinates: jsonLocations.map(location => location.coordinates) + }, + properties: { + timestamps: jsonLocations.map(location => location.timestamp) + } + }; + return Response.json(geoJsonLocations, responseInit) + } + + } catch (err) { + console.warn(err); + return internalServerError(); + } +}; + +function collectParameters(request: Request, params: Params): + Response | { + deviceId: string, + fromDate: Date, + toDate: Date, + format: string | null + } { + // deviceId is there for legacy reasons + const deviceId = params.deviceId; + if (deviceId === undefined) + return badRequest("Invalid device id specified"); + + const url = new URL(request.url); + + const fromDate = parseDateParam(url, "from-date", new Date(new Date().setDate(new Date().getDate() - 2))) + if (fromDate instanceof Response) + return fromDate + + const toDate = parseDateParam(url, "to-date", new Date()) + if (toDate instanceof Response) + return toDate + + const format = parseEnumParam(url, "format", ["json", "geojson"], "json"); + if (format instanceof Response) + return format + + return { + deviceId, + fromDate, + toDate, + format + }; +} + +function getCsv(meas: Measurement[] | TransformedMeasurement[], delimiter: string): string { + return convertToCsv(["createdAt", "value"], meas, [ + measurement => measurement.time.toString(), + measurement => measurement.value?.toString() ?? "null" + ], delimiter) +} \ No newline at end of file From cb9fa81f87ea9d46a4594353cfbe095b869994c5 Mon Sep 17 00:00:00 2001 From: Timber Date: Sun, 9 Nov 2025 11:48:12 +0100 Subject: [PATCH 05/14] feat: only get required locations from database --- app/models/device.server.ts | 20 ++++++++++++++++++-- app/routes/api.boxes.$deviceId.locations.ts | 14 +++++--------- 2 files changed, 23 insertions(+), 11 deletions(-) diff --git a/app/models/device.server.ts b/app/models/device.server.ts index 32c0c60b..6b8c5d5e 100644 --- a/app/models/device.server.ts +++ b/app/models/device.server.ts @@ -1,8 +1,8 @@ import { point } from '@turf/helpers' -import { eq, sql, desc, ilike, arrayContains, and } from 'drizzle-orm' +import { eq, sql, desc, ilike, arrayContains, and, gte, lte, between } from 'drizzle-orm' import { type Point } from 'geojson' import { drizzleClient } from '~/db.server' -import { device, location, sensor, type Device, type Sensor } from '~/schema' +import { device, deviceToLocation, location, sensor, type Device, type Sensor } from '~/schema' const BASE_DEVICE_COLUMNS = { id: true, @@ -77,6 +77,22 @@ export function getDevice({ id }: Pick) { }) } +export function getLocations({ id }: Pick, fromDate: Date, toDate: Date) { + return drizzleClient + .select({ + time: deviceToLocation.time, + x: sql`ST_X(${location.location})`.as('x'), + y: sql`ST_Y(${location.location})`.as('y'), + }) + .from(location) + .innerJoin(deviceToLocation, eq(deviceToLocation.locationId, location.id)) + .where( + and( + eq(deviceToLocation.deviceId, id), + between(deviceToLocation.time, fromDate, toDate) + ) + ) +} export function getDeviceWithoutSensors({ id }: Pick) { return drizzleClient.query.device.findFirst({ where: (device, { eq }) => eq(device.id, id), diff --git a/app/routes/api.boxes.$deviceId.locations.ts b/app/routes/api.boxes.$deviceId.locations.ts index e3504976..52b29b42 100644 --- a/app/routes/api.boxes.$deviceId.locations.ts +++ b/app/routes/api.boxes.$deviceId.locations.ts @@ -1,7 +1,6 @@ import { type Params, type LoaderFunction, type LoaderFunctionArgs } from "react-router"; -import { type TransformedMeasurement, transformOutliers } from "~/lib/outlier-transform"; -import { getDevice } from "~/models/device.server"; -import { getMeasurements } from "~/models/sensor.server"; +import { type TransformedMeasurement } from "~/lib/outlier-transform"; +import { getLocations } from "~/models/device.server"; import { type Measurement } from "~/schema"; import { convertToCsv } from "~/utils/csv"; import { parseDateParam, parseEnumParam } from "~/utils/param-utils"; @@ -107,16 +106,13 @@ export const loader: LoaderFunction = async ({ // eslint-disable-next-line @typescript-eslint/no-unused-vars const {deviceId, fromDate, toDate, format} = collected; - const device = await getDevice({ id: deviceId }); - if (!device) + const locations = await getLocations({ id: deviceId}, fromDate, toDate); + if (!locations) return notFound("Device not found"); - const locations = device.locations.filter(location => - new Date(location.time) >= fromDate && new Date(location.time) <= toDate); - const jsonLocations = locations.map((location) => { return { - coordinates: [location.geometry.x, location.geometry.y], + coordinates: [location.x, location.y], type: 'Point', timestamp: location.time, } From fd950a4f75e55b684c67b2ac8c4df59ceb461228 Mon Sep 17 00:00:00 2001 From: Timber Date: Sun, 9 Nov 2025 12:19:55 +0100 Subject: [PATCH 06/14] feat: unit tests --- app/models/device.server.ts | 1 + app/routes/api.boxes.$deviceId.locations.ts | 2 +- .../api.boxes.$deviceId.locations.spec.ts | 184 ++++++++++++++++++ 3 files changed, 186 insertions(+), 1 deletion(-) create mode 100644 tests/routes/api.boxes.$deviceId.locations.spec.ts diff --git a/app/models/device.server.ts b/app/models/device.server.ts index 6b8c5d5e..814408d9 100644 --- a/app/models/device.server.ts +++ b/app/models/device.server.ts @@ -92,6 +92,7 @@ export function getLocations({ id }: Pick, fromDate: Date, toDate: between(deviceToLocation.time, fromDate, toDate) ) ) + .orderBy(desc(deviceToLocation.time)); } export function getDeviceWithoutSensors({ id }: Pick) { return drizzleClient.query.device.findFirst({ diff --git a/app/routes/api.boxes.$deviceId.locations.ts b/app/routes/api.boxes.$deviceId.locations.ts index 52b29b42..81dea5cb 100644 --- a/app/routes/api.boxes.$deviceId.locations.ts +++ b/app/routes/api.boxes.$deviceId.locations.ts @@ -56,7 +56,7 @@ import { badRequest, internalServerError, notFound } from "~/utils/response-util * application/json: * schema: * type: array - * example: '[{ "coordinates": [7.68123, 51.9123], "type": "Point", "timestamp": "2017-07-27T12:00:00Z"},{ "coordinates": [7.68223, 51.9433, 66.6], "type": "Point", "timestamp": "2017-07-27T12:01:00Z"},{ "coordinates": [7.68323, 51.9423], "type": "Point", "timestamp": "2017-07-27T12:02:00Z"}]' + * example: '[{ "coordinates": [7.68123, 51.9123], "type": "Point", "timestamp": "2017-07-27T12:00.000Z"},{ "coordinates": [7.68223, 51.9433, 66.6], "type": "Point", "timestamp": "2017-07-27T12:01.000Z"},{ "coordinates": [7.68323, 51.9423], "type": "Point", "timestamp": "2017-07-27T12:02.000Z"}]' * application/geojson: * example: '' * 400: diff --git a/tests/routes/api.boxes.$deviceId.locations.spec.ts b/tests/routes/api.boxes.$deviceId.locations.spec.ts new file mode 100644 index 00000000..c5453e87 --- /dev/null +++ b/tests/routes/api.boxes.$deviceId.locations.spec.ts @@ -0,0 +1,184 @@ +import { type Params, type LoaderFunctionArgs } from "react-router"; +import { BASE_URL } from "vitest.setup"; +import { registerUser } from "~/lib/user-service.server"; +import { createDevice, deleteDevice } from "~/models/device.server"; +import { deleteMeasurementsForSensor, deleteMeasurementsForTime, insertMeasurements, saveMeasurements } from "~/models/measurement.server"; +import { getSensors } from "~/models/sensor.server"; +import { deleteUserByEmail } from "~/models/user.server"; +import { loader } from "~/routes/api.boxes.$deviceId.locations"; +import { type Sensor, type Device, type User } from "~/schema"; + +const DEVICE_SENSORS_ID_USER = { + name: "meTestSensorsIds", + email: "test@box.sensorids", + password: "highlySecurePasswordForTesting", +}; + +const DEVICE_SENSOR_ID_BOX = { + name: `${DEVICE_SENSORS_ID_USER}s Box`, + exposure: "outdoor", + expiresAt: null, + tags: [], + latitude: 0, + longitude: 0, + model: "luftdaten.info", + mqttEnabled: false, + ttnEnabled: false, + sensors: [ + { + title: "Temp", + unit: "°C", + sensorType: "dummy", + }, + { + title: "CO2", + unit: "mol/L", + sensorType: "dummy", + }, + { + title: "Air Pressure", + unit: "kPa", + sensorType: "dummy", + }, + ], +}; + +const MEASUREMENTS = [ + { + value: 1589625, + createdAt: new Date('1954-06-07 12:00:00+00'), + sensor_id: "", + location: { + lng: 1, + lat: 2, + height: 3 + } + }, + { + value: 3.14159, + createdAt: new Date('1988-03-14 1:59:26+00'), + sensor_id: "", + location: { + lng: 4, + lat: 5, + } + }, + { + value: 0, + createdAt: new Date('2000-05-25 11:11:11+00'), + sensor_id: "", + location: { + lng: 6, + lat: 7, + height: 8 + } + } +] + +describe("openSenseMap API Routes: /api/boxes/:deviceId/locations", () => { + let device: Device; + let deviceId: string = ""; + let sensors: Sensor[]; + + beforeAll(async () => { + const user = await registerUser( + DEVICE_SENSORS_ID_USER.name, + DEVICE_SENSORS_ID_USER.email, + DEVICE_SENSORS_ID_USER.password, + "en_US", + ); + + device = await createDevice(DEVICE_SENSOR_ID_BOX, (user as User).id); + deviceId = device.id; + sensors = await getSensors(deviceId); + + MEASUREMENTS.forEach(meas => meas.sensor_id = sensors[0].id) + await saveMeasurements(device, MEASUREMENTS); + }); + + describe("GET", () => { + it("should return locations of a box in json format", async () => { + // Arrange + const request = new Request( + `${BASE_URL}/api/boxes/${deviceId}/locations?from-date=${new Date('1954-06-07 11:00:00+00')}&to-date=${new Date('1988-03-14 1:59:27+00')}`, + { method: "GET" }, + ); + + // Act + const dataFunctionValue = await loader({ + request, + params: { + deviceId: `${deviceId}` + } as Params, + } as LoaderFunctionArgs); // Assuming a separate loader for single sensor + const response = dataFunctionValue as Response; + const body = await response?.json(); + + // Assert + expect(response.status).toBe(200); + expect(response.headers.get("content-type")).toBe( + "application/json; charset=utf-8", + ); + expect(body).toHaveLength(2); + expect(body[0].coordinates).toHaveLength(2); + expect(body[1].coordinates).toHaveLength(2); + expect(body[0].coordinates[0]).toBe(4); + expect(body[0].coordinates[1]).toBe(5); + expect(body[1].coordinates[0]).toBe(1); + expect(body[1].coordinates[1]).toBe(2); + expect(body[0].type).toBe("Point"); + expect(body[1].type).toBe("Point"); + expect(body[0].timestamp).toBe('1988-03-14T01:59:26.000Z'); + expect(body[1].timestamp).toBe('1954-06-07T12:00:00.000Z'); + }); + + it("should return locations of a box in geojson format", async () => { + // Arrange + const request = new Request( + `${BASE_URL}/api/boxes/${deviceId}/locations?from-date=${new Date('1954-06-07 11:00:00+00')}&to-date=${new Date('1988-03-14 1:59:27+00')}&format=geojson`, + { method: "GET" }, + ); + + // Act + const dataFunctionValue = await loader({ + request, + params: { + deviceId: `${deviceId}` + } as Params, + } as LoaderFunctionArgs); // Assuming a separate loader for single sensor + const response = dataFunctionValue as Response; + const body = await response?.json(); + + // Assert + expect(response.status).toBe(200); + expect(response.headers.get("content-type")).toBe( + "application/geo+json; charset=utf-8", + ); + expect(body.type).toBe("Feature"); + expect(body.geometry.type).toBe("LineString"); + expect(body.geometry.coordinates).toHaveLength(2); + expect(body.geometry.coordinates[0]).toHaveLength(2); + expect(body.geometry.coordinates[1]).toHaveLength(2); + expect(body.geometry.coordinates[0][0]).toBe(4); + expect(body.geometry.coordinates[0][1]).toBe(5); + expect(body.geometry.coordinates[1][0]).toBe(1); + expect(body.geometry.coordinates[1][1]).toBe(2); + expect(body.properties.timestamps).toHaveLength(2); + expect(body.properties.timestamps[0]).toBe('1988-03-14T01:59:26.000Z'); + expect(body.properties.timestamps[1]).toBe('1954-06-07T12:00:00.000Z'); + }); + }); + + afterAll(async () => { + //delete measurements + if (sensors?.length > 0) { + await deleteMeasurementsForSensor(sensors[0].id); + await deleteMeasurementsForTime(MEASUREMENTS[0].createdAt); + await deleteMeasurementsForTime(MEASUREMENTS[1].createdAt); + } + // delete the valid test user + await deleteUserByEmail(DEVICE_SENSORS_ID_USER.email); + // delete the box + await deleteDevice({ id: deviceId }); + }); +}); From e32dbabaac0d17f13c77b4502d46172e88703c94 Mon Sep 17 00:00:00 2001 From: Timber Date: Sun, 9 Nov 2025 12:21:01 +0100 Subject: [PATCH 07/14] fix: lint --- tests/routes/api.boxes.$deviceId.locations.spec.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/routes/api.boxes.$deviceId.locations.spec.ts b/tests/routes/api.boxes.$deviceId.locations.spec.ts index c5453e87..16c1b610 100644 --- a/tests/routes/api.boxes.$deviceId.locations.spec.ts +++ b/tests/routes/api.boxes.$deviceId.locations.spec.ts @@ -2,7 +2,7 @@ import { type Params, type LoaderFunctionArgs } from "react-router"; import { BASE_URL } from "vitest.setup"; import { registerUser } from "~/lib/user-service.server"; import { createDevice, deleteDevice } from "~/models/device.server"; -import { deleteMeasurementsForSensor, deleteMeasurementsForTime, insertMeasurements, saveMeasurements } from "~/models/measurement.server"; +import { deleteMeasurementsForSensor, deleteMeasurementsForTime, saveMeasurements } from "~/models/measurement.server"; import { getSensors } from "~/models/sensor.server"; import { deleteUserByEmail } from "~/models/user.server"; import { loader } from "~/routes/api.boxes.$deviceId.locations"; From b342aa09f52739c4ea77a4ccf07490c6a99b49ff Mon Sep 17 00:00:00 2001 From: Timber Date: Sun, 9 Nov 2025 12:24:14 +0100 Subject: [PATCH 08/14] feat: delete unused method --- app/routes/api.boxes.$deviceId.locations.ts | 7 ------- 1 file changed, 7 deletions(-) diff --git a/app/routes/api.boxes.$deviceId.locations.ts b/app/routes/api.boxes.$deviceId.locations.ts index 81dea5cb..2351b245 100644 --- a/app/routes/api.boxes.$deviceId.locations.ts +++ b/app/routes/api.boxes.$deviceId.locations.ts @@ -180,11 +180,4 @@ function collectParameters(request: Request, params: Params): toDate, format }; -} - -function getCsv(meas: Measurement[] | TransformedMeasurement[], delimiter: string): string { - return convertToCsv(["createdAt", "value"], meas, [ - measurement => measurement.time.toString(), - measurement => measurement.value?.toString() ?? "null" - ], delimiter) } \ No newline at end of file From 9eb292edf6ebd43aedda59dd9fe99d36a4a2f14e Mon Sep 17 00:00:00 2001 From: Timber Date: Sun, 9 Nov 2025 12:24:40 +0100 Subject: [PATCH 09/14] feat: delete outdated comment --- app/routes/api.boxes.$deviceId.locations.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/app/routes/api.boxes.$deviceId.locations.ts b/app/routes/api.boxes.$deviceId.locations.ts index 2351b245..80ef38ba 100644 --- a/app/routes/api.boxes.$deviceId.locations.ts +++ b/app/routes/api.boxes.$deviceId.locations.ts @@ -155,7 +155,6 @@ function collectParameters(request: Request, params: Params): toDate: Date, format: string | null } { - // deviceId is there for legacy reasons const deviceId = params.deviceId; if (deviceId === undefined) return badRequest("Invalid device id specified"); From d7f60b830431ac1abc0b52d9c85674572d666368 Mon Sep 17 00:00:00 2001 From: Timber Date: Sun, 9 Nov 2025 12:25:29 +0100 Subject: [PATCH 10/14] fix: remove duplicated whitespace --- app/utils/response-utils.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/utils/response-utils.ts b/app/utils/response-utils.ts index 28e16f4a..39263db6 100644 --- a/app/utils/response-utils.ts +++ b/app/utils/response-utils.ts @@ -1,4 +1,4 @@ -// TODO: Proposal: Use these functions everywhere, to make the code shorter +// TODO: Proposal: Use these functions everywhere, to make the code shorter /** * Creates a response object for a bad request From e9e6832676754e64a621f6bf80928e29117e444f Mon Sep 17 00:00:00 2001 From: Timber Date: Sun, 9 Nov 2025 12:28:35 +0100 Subject: [PATCH 11/14] fix: delete measurements for all test times --- tests/routes/api.boxes.$deviceId.locations.spec.ts | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/tests/routes/api.boxes.$deviceId.locations.spec.ts b/tests/routes/api.boxes.$deviceId.locations.spec.ts index 16c1b610..2685f6ee 100644 --- a/tests/routes/api.boxes.$deviceId.locations.spec.ts +++ b/tests/routes/api.boxes.$deviceId.locations.spec.ts @@ -173,8 +173,7 @@ describe("openSenseMap API Routes: /api/boxes/:deviceId/locations", () => { //delete measurements if (sensors?.length > 0) { await deleteMeasurementsForSensor(sensors[0].id); - await deleteMeasurementsForTime(MEASUREMENTS[0].createdAt); - await deleteMeasurementsForTime(MEASUREMENTS[1].createdAt); + MEASUREMENTS.forEach(async (measurement) => await deleteMeasurementsForTime(measurement.createdAt)); } // delete the valid test user await deleteUserByEmail(DEVICE_SENSORS_ID_USER.email); From 40a2b87773877af95c29380b855e847ed1ff42cd Mon Sep 17 00:00:00 2001 From: Timber Date: Wed, 12 Nov 2025 12:26:31 +0100 Subject: [PATCH 12/14] fix: remove eslint ignore comment --- app/routes/api.boxes.$deviceId.locations.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/app/routes/api.boxes.$deviceId.locations.ts b/app/routes/api.boxes.$deviceId.locations.ts index 80ef38ba..1c351283 100644 --- a/app/routes/api.boxes.$deviceId.locations.ts +++ b/app/routes/api.boxes.$deviceId.locations.ts @@ -103,7 +103,6 @@ export const loader: LoaderFunction = async ({ const collected = collectParameters(request, params); if (collected instanceof Response) return collected; - // eslint-disable-next-line @typescript-eslint/no-unused-vars const {deviceId, fromDate, toDate, format} = collected; const locations = await getLocations({ id: deviceId}, fromDate, toDate); From aaf1eec61aec7e560c089ecf9fb50509785f352e Mon Sep 17 00:00:00 2001 From: Timber Date: Wed, 12 Nov 2025 12:29:31 +0100 Subject: [PATCH 13/14] fix: lint --- app/models/device.server.ts | 2 +- app/routes/api.boxes.$deviceId.locations.ts | 3 --- 2 files changed, 1 insertion(+), 4 deletions(-) diff --git a/app/models/device.server.ts b/app/models/device.server.ts index 814408d9..eb8b4c9e 100644 --- a/app/models/device.server.ts +++ b/app/models/device.server.ts @@ -1,5 +1,5 @@ import { point } from '@turf/helpers' -import { eq, sql, desc, ilike, arrayContains, and, gte, lte, between } from 'drizzle-orm' +import { eq, sql, desc, ilike, arrayContains, and, between } from 'drizzle-orm' import { type Point } from 'geojson' import { drizzleClient } from '~/db.server' import { device, deviceToLocation, location, sensor, type Device, type Sensor } from '~/schema' diff --git a/app/routes/api.boxes.$deviceId.locations.ts b/app/routes/api.boxes.$deviceId.locations.ts index 1c351283..4a6f6c90 100644 --- a/app/routes/api.boxes.$deviceId.locations.ts +++ b/app/routes/api.boxes.$deviceId.locations.ts @@ -1,8 +1,5 @@ import { type Params, type LoaderFunction, type LoaderFunctionArgs } from "react-router"; -import { type TransformedMeasurement } from "~/lib/outlier-transform"; import { getLocations } from "~/models/device.server"; -import { type Measurement } from "~/schema"; -import { convertToCsv } from "~/utils/csv"; import { parseDateParam, parseEnumParam } from "~/utils/param-utils"; import { badRequest, internalServerError, notFound } from "~/utils/response-utils"; From 110f6e1226b615d9de8ee1e7f1de3476b729ddee Mon Sep 17 00:00:00 2001 From: Timber Date: Wed, 12 Nov 2025 16:27:05 +0100 Subject: [PATCH 14/14] feat: remove TODO --- app/utils/response-utils.ts | 2 -- 1 file changed, 2 deletions(-) diff --git a/app/utils/response-utils.ts b/app/utils/response-utils.ts index 39263db6..8aeb121e 100644 --- a/app/utils/response-utils.ts +++ b/app/utils/response-utils.ts @@ -1,5 +1,3 @@ -// TODO: Proposal: Use these functions everywhere, to make the code shorter - /** * Creates a response object for a bad request * @param message The message for the response