From c5b9835d2f19eba47bd29f6f53f627bda776e171 Mon Sep 17 00:00:00 2001 From: jona159 Date: Wed, 25 Jun 2025 11:21:11 +0200 Subject: [PATCH 01/15] feat: add command for drizzle studio --- package.json | 1 + 1 file changed, 1 insertion(+) diff --git a/package.json b/package.json index bbe85a0d..0ce630b3 100644 --- a/package.json +++ b/package.json @@ -16,6 +16,7 @@ "db:push": "drizzle-kit push", "db:generate": "drizzle-kit generate", "db:migrate": "drizzle-kit migrate", + "db:studio": "drizzle-kit studio", "db:setup": "npm run db:generate && npm run db:migrate", "db:seed": "tsx ./db/seed.ts", "components": "npx shadcn-ui", From 43798ffafcf91a406195fccb32f2b0a45dade3bd Mon Sep 17 00:00:00 2001 From: jona159 Date: Wed, 25 Jun 2025 11:22:21 +0200 Subject: [PATCH 02/15] feat: devices loader --- app/routes/api.devices.ts | 298 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 298 insertions(+) create mode 100644 app/routes/api.devices.ts diff --git a/app/routes/api.devices.ts b/app/routes/api.devices.ts new file mode 100644 index 00000000..8c5cde1d --- /dev/null +++ b/app/routes/api.devices.ts @@ -0,0 +1,298 @@ +import { json, type LoaderFunctionArgs } from '@remix-run/node' +import { + createDevice, + // deleteDevice, + getDevice, + getDevices, +} from '~/models/device.server' +import { ActionFunctionArgs } from 'react-router' +import { getUserFromJwt } from '~/lib/jwt' +import { + deleteDevice, + type BoxesQueryParams, +} from '~/lib/devices-service.server' +import { Device, User } from '~/schema' + +export async function loader({ request }: LoaderFunctionArgs) { + const url = new URL(request.url) + const searchParams = Object.fromEntries(url.searchParams) as BoxesQueryParams + const validFormats: BoxesQueryParams['format'] = 'geojson' // TODO: support json + + if (searchParams.format) { + if (!validFormats.includes(searchParams.format)) { + console.error('Error in loader:', 'invalid format parameter') + throw json({ error: 'Failed to fetch devices' }, { status: 422 }) + } + if (searchParams.format == 'geojson') { + try { + let result: GeoJSON.FeatureCollection | any[] + + result = await getDevices() + + if (searchParams.bbox && result) { + const bboxCoords = searchParams.bbox + .split(',') + .map((coord) => Number(coord)) + const [west, south, east, north] = bboxCoords // [minLon, minLat, maxLon, maxLat] + + if ( + bboxCoords.length !== 4 || + bboxCoords.some((coord) => isNaN(Number(coord))) + ) { + throw json( + { + error: + "Invalid 'bbox' parameter format. Expected: 'west,south,east,north'", + }, + { status: 422 }, + ) + } + + if ( + result && + typeof result === 'object' && + 'type' in result && + result.type === 'FeatureCollection' + ) { + const filteredFeatures = result.features.filter((feature: any) => { + if (!feature.geometry || !feature.geometry.coordinates) { + return false + } + + const [longitude, latitude] = feature.geometry.coordinates + + const isInBounds = + longitude >= west && + longitude <= east && + latitude >= south && + latitude <= north + + return isInBounds + }) + + result = { + type: 'FeatureCollection', + features: filteredFeatures, + } as GeoJSON.FeatureCollection + } else if (Array.isArray(result)) { + result = result.filter((device: any) => { + if (device.geometry && device.geometry.coordinates) { + const [longitude, latitude] = device.geometry.coordinates + return ( + longitude >= west && + longitude <= east && + latitude >= south && + latitude <= north + ) + } + + if (device.longitude && device.latitude) { + return ( + device.longitude >= west && + device.longitude <= east && + device.latitude >= south && + device.latitude <= north + ) + } + + return false + }) + } + } + + return result + } catch (error) { + console.error('Error in loader:', error) + throw json({ error: 'Failed to fetch devices' }, { status: 500 }) + } + } + } + + if (searchParams.near) { + const nearCoords = searchParams.near.split(',') + if ( + nearCoords.length !== 2 || + isNaN(Number(nearCoords[0])) || + isNaN(Number(nearCoords[1])) + ) { + throw json( + { error: "Invalid 'near' parameter format. Expected: 'lng,lat'" }, + { status: 422 }, + ) + } + } +} + +export async function action({ request, params }: ActionFunctionArgs) { + try { + const jwtResponse = await getUserFromJwt(request) + + if (typeof jwtResponse === 'string') + return Response.json( + { + code: 'Forbidden', + message: + 'Invalid JWT authorization. Please sign in to obtain new JWT.', + }, + { + status: 403, + }, + ) + switch (request.method) { + case 'POST': + return await post(request, jwtResponse) + case 'DELETE': + return await del(request, jwtResponse, params) + default: + return Response.json({ msg: 'Method Not Allowed' }, { status: 405 }) + } + } 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, + }, + ) + } +} + +async function del(request: Request, user: User, params: any) { + const { deviceId } = params + + if (!deviceId) { + throw json({ message: 'Device ID is required' }, { status: 400 }) + } + + const device = (await getDevice({ id: deviceId })) as unknown as Device + + if (!device) { + throw json({ message: 'Device not found' }, { status: 404 }) + } + + const body = await request.json() + + if (!body.password) { + throw json( + { message: 'Password is required for device deletion' }, + { status: 400 }, + ) + } + + try { + const deleted = await deleteDevice(user, device, body.password) + + if (deleted === 'unauthorized') + return Response.json( + { message: 'Password incorrect' }, + { + status: 401, + headers: { 'Content-Type': 'application/json; charset=utf-8' }, + }, + ) + + return Response.json(null, { + status: 200, + headers: { 'Content-Type': 'application/json; charset=utf-8' }, + }) + } catch (err) { + console.warn(err) + return new Response('Internal Server Error', { status: 500 }) + } +} + +async function post(request: Request, user: User) { + try { + const body = await request.json() + + if (!body.location) { + throw json( + { message: 'missing required parameter location' }, + { status: 400 }, + ) + } + + let latitude: number, longitude: number, height: number | undefined + + if (Array.isArray(body.location)) { + // Handle array format [lat, lng, height?] + if (body.location.length < 2) { + throw json( + { + message: `Illegal value for parameter location. missing latitude or longitude in location [${body.location.join(',')}]`, + }, + { status: 422 }, + ) + } + latitude = Number(body.location[0]) + longitude = Number(body.location[1]) + height = body.location[2] ? Number(body.location[2]) : undefined + } else if (typeof body.location === 'object' && body.location !== null) { + // Handle object format { lat, lng, height? } + if (!('lat' in body.location) || !('lng' in body.location)) { + throw json( + { + message: + 'Illegal value for parameter location. missing latitude or longitude', + }, + { status: 422 }, + ) + } + latitude = Number(body.location.lat) + longitude = Number(body.location.lng) + height = body.location.height ? Number(body.location.height) : undefined + } else { + throw json( + { + message: + 'Illegal value for parameter location. Expected array or object', + }, + { status: 422 }, + ) + } + + if (isNaN(latitude) || isNaN(longitude)) { + throw json( + { message: 'Invalid latitude or longitude values' }, + { status: 422 }, + ) + } + + const rawAuthorizationHeader = request.headers.get('authorization') + if (!rawAuthorizationHeader) { + throw json({ message: 'Authorization header required' }, { status: 401 }) + } + const [, jwtString] = rawAuthorizationHeader.split(' ') + + const deviceData = { + ...body, + latitude, + longitude, + } + + const newDevice = await createDevice(deviceData, user.id) + + return json( + { + data: { + ...newDevice, + access_token: jwtString, + createdAt: newDevice.createdAt || new Date(), + }, + }, + { status: 201 }, + ) + } catch (error) { + console.error('Error creating device:', error) + + if (error instanceof Response) { + throw error + } + + throw json({ message: 'Internal server error' }, { status: 500 }) + } +} From a9262df6d1d4baecada21230d5360dd19ef59eba Mon Sep 17 00:00:00 2001 From: jona159 Date: Wed, 25 Jun 2025 11:22:57 +0200 Subject: [PATCH 03/15] feat: load single device --- app/routes/api.device.$deviceId.ts | 41 ++++++++++++++++++++++++++++++ 1 file changed, 41 insertions(+) create mode 100644 app/routes/api.device.$deviceId.ts diff --git a/app/routes/api.device.$deviceId.ts b/app/routes/api.device.$deviceId.ts new file mode 100644 index 00000000..92a90bbe --- /dev/null +++ b/app/routes/api.device.$deviceId.ts @@ -0,0 +1,41 @@ +import { json, type LoaderFunctionArgs } from '@remix-run/node' +import { getDevice } from '~/models/device.server' + +export async function loader({ params }: LoaderFunctionArgs) { + const { deviceId } = params + + if (!deviceId) { + return new Response(JSON.stringify({ message: 'Device ID is required.' }), { + status: 400, + headers: { + 'content-type': 'application/json; charset=utf-8', + }, + }) + } + + try { + const device = await getDevice({ id: deviceId }) + + if (!device) { + return new Response(JSON.stringify({ message: 'Device not found.' }), { + status: 404, + headers: { + 'content-type': 'application/json; charset=utf-8', + }, + }) + } + + return json(device) + } catch (error) { + console.error('Error fetching box:', error) + + if (error instanceof Response) { + throw error + } + + throw json( + { error: 'Internal server error while fetching box' }, + { status: 500 }, + ) + } +} From e5f0e9fe2394c82e067fd7b1ffc6ec91f5059d1a Mon Sep 17 00:00:00 2001 From: jona159 Date: Wed, 25 Jun 2025 11:23:58 +0200 Subject: [PATCH 04/15] feat: uncomment get boxes, delete box path --- app/routes/api.ts | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/app/routes/api.ts b/app/routes/api.ts index e084ce83..9af295d8 100644 --- a/app/routes/api.ts +++ b/app/routes/api.ts @@ -26,10 +26,10 @@ const routes: { noauth: RouteInfo[]; auth: RouteInfo[] } = { // method: "GET", // }, - // { - // path: `boxes`, - // method: "GET", - // }, + { + path: `boxes`, + method: "GET", + }, // { // path: `boxes/data`, // method: "GET", @@ -141,10 +141,10 @@ const routes: { noauth: RouteInfo[]; auth: RouteInfo[] } = { // path: `boxes/:boxId`, // method: "PUT", // }, - // { - // path: `boxes/:boxId`, - // method: "DELETE", - // }, + { + path: `boxes/:boxId`, + method: "DELETE", + }, // { // path: `boxes/:boxId/:sensorId/measurements`, // method: "DELETE", From 87c390dbbb0ceadd44ec52ab8b30b757642472b5 Mon Sep 17 00:00:00 2001 From: jona159 Date: Wed, 25 Jun 2025 11:24:41 +0200 Subject: [PATCH 05/15] feat(wip): add boxes test suite --- tests/routes/api.devices.spec.ts | 507 +++++++++++++++++++++++++++++++ 1 file changed, 507 insertions(+) create mode 100644 tests/routes/api.devices.spec.ts diff --git a/tests/routes/api.devices.spec.ts b/tests/routes/api.devices.spec.ts new file mode 100644 index 00000000..7e86f921 --- /dev/null +++ b/tests/routes/api.devices.spec.ts @@ -0,0 +1,507 @@ +import { + AppLoadContext, + LoaderFunctionArgs, + type ActionFunctionArgs, +} from 'react-router' +import { BASE_URL } from 'vitest.setup' +import { type User, type Device } from '~/schema' +import { loader as devicesLoader } from '~/routes/api.devices' +import { loader as deviceLoader } from '~/routes/api.device.$deviceId' +import { action as devicesAction } from '~/routes/api.devices' +import { registerUser } from '~/lib/user-service.server' +import { createToken } from '~/lib/jwt' +import { deleteUserByEmail } from '~/models/user.server' + +const ME_TEST_USER = { + name: 'meTest', + email: 'test@me.endpoint', + password: 'highlySecurePasswordForTesting', +} + +const minimalSensebox = function minimalSensebox( + location: number[] | {} = [123, 12, 34], + exposure = 'mobile', +) { + return { + exposure, + location, + name: 'senseBox', + model: 'homeV2Ethernet', + } +} + +describe('openSenseMap API Routes: /boxes', () => { + describe('GET /boxes', () => { + it('should reject filtering boxes near a location with wrong parameter values', async () => { + // Arrange + const request = new Request(`${BASE_URL}?near=test,60`, { + method: 'GET', + headers: { 'Content-Type': 'application/json' }, + }) + + // Act & Assert + await expect(async () => { + await devicesLoader({ + request: request, + } as LoaderFunctionArgs) + }).rejects.toThrow() + }) + + it('should return 422 error on wrong format parameter', async () => { + // Arrange + const request = new Request(`${BASE_URL}?format=potato`, { + method: 'GET', + headers: { 'Content-Type': 'application/json' }, + }) + + try { + await devicesLoader({ + request: request, + } as LoaderFunctionArgs) + expect(true).toBe(false) + } catch (error) { + expect(error).toBeInstanceOf(Response) + expect(error.status).toBe(422) + + const errorData = await error.json() + expect(errorData.error).toBe('Failed to fetch devices') + } + }) + + it('should return geojson format when requested', async () => { + // Arrange + const request = new Request(`${BASE_URL}?format=geojson`, { + method: 'GET', + headers: { 'Content-Type': 'application/json' }, + }) + + // Act + const geojsonData = await devicesLoader({ + request: request, + } as LoaderFunctionArgs) + + console.log("geojson features 0 geometry", geojsonData.features[0].geometry) + + // Assert - this should always be GeoJSON since that's what the loader returns + expect(geojsonData.type).toBe('FeatureCollection') + expect(Array.isArray(geojsonData.features)).toBe(true) + + if (geojsonData.features.length > 0) { + expect(geojsonData.features[0].type).toBe('Feature') + expect(geojsonData.features[0].geometry).toBeDefined() + expect(geojsonData.features[0].geometry.coordinates[0]).toBeDefined() + expect(geojsonData.features[0].geometry.coordinates[1]).toBeDefined() + expect(geojsonData.features[0].properties).toBeDefined() + } + }) + + it('should allow filtering boxes by bounding box', async () => { + // Arrange + const request = new Request(`${BASE_URL}?format=geojson&bbox=120,60,121,61`, { + method: 'GET', + headers: { 'Content-Type': 'application/json' }, + }) + + // Act + const response = await devicesLoader({ + request: request, + } as LoaderFunctionArgs) + + // Assert + expect(response.type).toBe('FeatureCollection') + expect(Array.isArray(response.features)).toBe(true) + + if (response.features.length > 0) { + response.features.forEach((feature: any) => { + expect(feature.type).toBe('Feature') + expect(feature.geometry).toBeDefined() + expect(feature.geometry.coordinates).toBeDefined() + + const [longitude, latitude] = feature.geometry.coordinates + + + // Verify coordinates are within the bounding box [120,60,121,61] + expect(longitude).toBeGreaterThanOrEqual(120) + expect(longitude).toBeLessThanOrEqual(121) + expect(latitude).toBeGreaterThanOrEqual(60) + expect(latitude).toBeLessThanOrEqual(61) + }) + } + }) + }) + + let jwt: string = '' + let device: Device | null = null + let user: User | null = null + + beforeAll(async () => { + const testUser = await registerUser( + ME_TEST_USER.name, + ME_TEST_USER.email, + ME_TEST_USER.password, + 'en_US', + ) + if ( + testUser !== null && + typeof testUser === 'object' && + 'id' in testUser && + 'username' in testUser + ) { + user = testUser + } + const { token: t } = await createToken(testUser as User) + jwt = t + }) + describe('POST /boxes', () => { + it('should allow to set the location for a new box as array', async () => { + // Arrange + const loc = [0, 0, 0] + const requestBody = minimalSensebox(loc) + + const request = new Request(BASE_URL, { + method: 'POST', + headers: { Authorization: `Bearer ${jwt}` }, + body: JSON.stringify(requestBody), + }) + + // Act + const response = await devicesAction({ + request: request, + } as ActionFunctionArgs) + + // Assert + expect(response.status).toBe(201) + + const responseData = await response.json() + device = responseData.data + + expect(responseData.data.latitude).toBeDefined() + expect(responseData.data.longitude).toBeDefined() + expect(responseData.data.latitude).toBe(loc[0]) + expect(responseData.data.longitude).toBe(loc[1]) + expect(responseData.data.createdAt).toBeDefined() + + // Check that createdAt is recent (within 5 minutes) + const now = new Date() + const createdAt = new Date(responseData.data.createdAt) + const diffInMs = now.getTime() - createdAt.getTime() + expect(diffInMs).toBeLessThan(300000) // 5 minutes in milliseconds + }) + + it('should allow to set the location for a new box as latLng object', async () => { + // Arrange + const loc = { lng: 120.123456, lat: 60.654321 } + const requestBody = minimalSensebox(loc) + + const request = new Request(BASE_URL, { + method: 'POST', + headers: { Authorization: `Bearer ${jwt}` }, + body: JSON.stringify(requestBody), + }) + + // Act + const response = await devicesAction({ + request: request, + } as ActionFunctionArgs) + + // Assert + expect(response.status).toBe(201) + + const responseData = await response.json() + expect(responseData.data.latitude).toBeDefined() + expect(responseData.data.latitude).toBe(loc.lat) + expect(responseData.data.longitude).toBeDefined() + expect(responseData.data.longitude).toBe(loc.lng) + expect(responseData.data.createdAt).toBeDefined() + + // Check that createdAt is recent (within 5 minutes) + const now = new Date() + const createdAt = new Date(responseData.data.createdAt) + const diffInMs = now.getTime() - createdAt.getTime() + expect(diffInMs).toBeLessThan(300000) // 5 minutes in milliseconds + }) + + // it('should reject a new box with invalid coords', async () => { + // // Arrange + // const requestBody = minimalSensebox([52]) // Invalid: missing longitude + + // const request = new Request(BASE_URL, { + // method: 'POST', + // headers: { Authorization: `Bearer ${jwt}` }, + // body: JSON.stringify(requestBody), + // }) + + // try { + // await devicesAction({ + // request: request, + // } as ActionFunctionArgs) + // fail('Expected action to throw an error') + // } catch (error) { + // if (error instanceof Response) { + // expect(error.status).toBe(422) + // const errorData = await error.json() + // expect(errorData.message).toBe( + // 'Illegal value for parameter location. missing latitude or longitude in location [52]', + // ) + // } else { + // throw error + // } + // } + // }) + + // it('should reject a new box without location field', async () => { + // // Arrange + // const requestBody = minimalSensebox() + // delete requestBody.location + + // const request = new Request(BASE_URL, { + // method: 'POST', + // headers: { Authorization: `Bearer ${jwt}` }, + + // body: JSON.stringify(requestBody), + // }) + + // // Act & Assert + // try { + // await devicesAction({ + // request: request, + // } as ActionFunctionArgs) + // fail('Expected action to throw an error') + // } catch (error) { + // if (error instanceof Response) { + // expect(error.status).toBe(400) + // const errorData = await error.json() + // expect(errorData.message).toBe('missing required parameter location') + // } else { + // throw error + // } + // } + // }) + }) + + describe('GET /boxes/:deviceId', () => { + let result: any + + beforeAll(async () => { + // Skip if no device was created in POST tests + if (!device) { + throw new Error( + 'No device was created in previous tests. Make sure POST tests run first.', + ) + } + + // Arrange + const request = new Request(`${BASE_URL}/${device.id}`, { + method: 'GET', + headers: { 'Content-Type': 'application/json' }, + }) + + // Act + const dataFunctionValue = await deviceLoader({ + request: request, + params: { deviceId: device.id }, + context: {} as AppLoadContext, + } as LoaderFunctionArgs) + + const response = dataFunctionValue as Response + + // Assert initial response + expect(dataFunctionValue).toBeInstanceOf(Response) + expect(response.status).toBe(200) + expect(response.headers.get('content-type')).toBe( + 'application/json; charset=utf-8', + ) + + // Get the body for subsequent tests + result = await response.json() + }) + + it('should return the device with correct location data', () => { + expect(result).toBeDefined() + expect(result._id || result.id).toBe(device?.id) + expect(result.latitude).toBeDefined() + expect(result.longitude).toBeDefined() + expect(result.latitude).toBe(device?.latitude) + expect(result.longitude).toBe(device?.longitude) + }) + + it('should return the device name and model', () => { + expect(result.name).toBe('senseBox') + expect(result.model).toBe('homeV2Ethernet') + expect(result.exposure).toBe('mobile') + }) + + it('should return the creation timestamp', () => { + expect(result.createdAt).toBeDefined() + expect(result.createdAt).toBe(device?.createdAt) + }) + + it('should NOT return sensitive data (if any)', () => { + // Add assertions for fields that shouldn't be returned + // For example, if there are internal fields that shouldn't be exposed: + // expect(result.internalField).toBeUndefined() + }) + }) + + describe('DELETE /boxes/:deviceId', () => { + let deleteResult: any + let getAfterDeleteResult: any + // let statsResult: any + + it('should deny deletion with incorrect password', async () => { + const badDeleteRequest = new Request(`${BASE_URL}/${device?.id}`, { + method: 'DELETE', + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${jwt}`, + }, + body: JSON.stringify({ password: 'wrong password' }), + }) + + const badDeleteResponse = await devicesAction({ + request: badDeleteRequest, + params: { deviceId: device?.id }, + context: {} as AppLoadContext, + } as ActionFunctionArgs) + + expect(badDeleteResponse).toBeInstanceOf(Response) + expect(badDeleteResponse.status).toBe(401) + expect(badDeleteResponse.headers.get('content-type')).toBe( + 'application/json; charset=utf-8', + ) + + const badResult = await badDeleteResponse.json() + expect(badResult).toEqual({ message: 'Password incorrect' }) + }) + + it('should successfully delete the device with correct password', async () => { + const validDeleteRequest = new Request(`${BASE_URL}/${device?.id}`, { + method: 'DELETE', + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${jwt}`, + }, + body: JSON.stringify({ password: ME_TEST_USER.password }), + }) + + const validDeleteResponse = await devicesAction({ + request: validDeleteRequest, + params: { deviceId: device?.id }, + context: {} as AppLoadContext, + } as ActionFunctionArgs) + + expect(validDeleteResponse).toBeInstanceOf(Response) + expect(validDeleteResponse.status).toBe(200) + expect(validDeleteResponse.headers.get('content-type')).toBe( + 'application/json; charset=utf-8', + ) + + deleteResult = await validDeleteResponse.json() + expect(deleteResult).toBeDefined() + }) + + it('should return 404 when trying to get the deleted device', async () => { + const getDeletedRequest = new Request(`${BASE_URL}/${device?.id}`, { + method: 'GET', + headers: { 'Content-Type': 'application/json' }, + }) + + const getDeletedResponse = await deviceLoader({ + request: getDeletedRequest, + params: { deviceId: device?.id }, + context: {} as AppLoadContext, + } as LoaderFunctionArgs) + + expect(getDeletedResponse).toBeInstanceOf(Response) + expect(getDeletedResponse.status).toBe(404) + expect(getDeletedResponse.headers.get('content-type')).toBe( + 'application/json; charset=utf-8', + ) + + getAfterDeleteResult = await getDeletedResponse.json() + expect(getAfterDeleteResult.message).toBe('Device not found.') + }) + + afterAll(async () => { + await deleteUserByEmail(ME_TEST_USER.email) + }) + }) +}) + +// describe('openSenseMap API Routes: /boxes', () => { +// describe('GET /boxes', () => { +// it('should reject filtering boxes near a location with wrong parameter values', async () => { +// // Arrange +// const request = new Request(`${BASE_URL}?near=test,60`, { +// method: 'GET', +// headers: { 'Content-Type': 'application/json' }, +// }) + +// // Act & Assert +// await expect(async () => { +// await devicesLoader({ +// request: request, +// } as LoaderFunctionArgs) +// }).rejects.toThrow() +// }) + +// it('should return geojson format when requested', async () => { +// // Arrange +// const request = new Request(`${BASE_URL}?format=geojson`, { +// method: 'GET', +// headers: { 'Content-Type': 'application/json' }, +// }) + +// // Act +// const geojsonData = await devicesLoader({ +// request: request, +// } as LoaderFunctionArgs) + +// // Assert - this should always be GeoJSON since that's what the loader returns +// expect(geojsonData.type).toBe('FeatureCollection') +// expect(Array.isArray(geojsonData.features)).toBe(true) + +// if (geojsonData.features.length > 0) { +// expect(geojsonData.features[0].type).toBe('Feature') +// expect(geojsonData.features[0].geometry).toBeDefined() +// expect(geojsonData.features[0].properties).toBeDefined() +// } +// }) + +// it('should return minimal data when minimal=true', async () => { +// // Arrange +// const request = new Request(`${BASE_URL}?minimal=true`, { +// method: 'GET', +// headers: { 'Content-Type': 'application/json' }, +// }) + +// // Act +// const geojsonData = await devicesLoader({ +// request: request, +// } as LoaderFunctionArgs) + +// // Assert - working with GeoJSON FeatureCollection +// expect(geojsonData.type).toBe('FeatureCollection') +// expect(Array.isArray(geojsonData.features)).toBe(true) + +// if (geojsonData.features.length > 0) { +// const feature = geojsonData.features[0] +// expect(feature.type).toBe('Feature') +// expect(feature.properties).toBeDefined() + +// // Should have minimal fields in properties +// expect(feature.properties?._id || feature.properties?.id).toBeDefined() +// expect(feature.properties?.name).toBeDefined() +// expect(feature.properties?.exposure).toBeDefined() +// expect( +// feature.properties?.currentLocation || +// feature.properties?.location || +// feature.geometry, +// ).toBeDefined() + +// // Should not have full sensor data +// expect(feature.properties?.sensors).toBeUndefined() +// } +// }) +// }) +// }) From 6301974de25b5492b6e197ac1314904bc3676a6e Mon Sep 17 00:00:00 2001 From: jona159 Date: Wed, 25 Jun 2025 11:25:00 +0200 Subject: [PATCH 06/15] feat: add devices service --- app/lib/devices-service.server.ts | 40 +++++++++++++++++++++++++++++++ 1 file changed, 40 insertions(+) create mode 100644 app/lib/devices-service.server.ts diff --git a/app/lib/devices-service.server.ts b/app/lib/devices-service.server.ts new file mode 100644 index 00000000..ef8f2986 --- /dev/null +++ b/app/lib/devices-service.server.ts @@ -0,0 +1,40 @@ +import { Device, User } from '~/schema' +import { + deleteDevice as deleteDeviceById, +} from '~/models/device.server' +import { verifyLogin } from '~/models/user.server' + +export interface BoxesQueryParams { + name?: string + limit?: string + date?: string + phenomenon?: string + format?: 'json' | 'geojson' + grouptag?: string + model?: string + classify?: 'true' | 'false' + minimal?: 'true' | 'false' + full?: 'true' | 'false' + near?: string + maxDistance?: string + bbox?: string + exposure?: string +} + +/** + * Deletes a device after verifiying that the user is entitled by checking + * the password. + * @param user The user deleting the device + * @param password The users password to verify + * @returns True if the device was deleted, otherwise false or "unauthorized" + * if the user is not entitled to delete the device with the given parameters + */ +export const deleteDevice = async ( + user: User, + device: Device, + password: string, +): Promise => { + const verifiedUser = await verifyLogin(user.email, password) + if (verifiedUser === null) return 'unauthorized' + return (await deleteDeviceById({ id: device.id })).count > 0 +} From e96766c6a1586c53df6f4f793731f2a6f1a5ba69 Mon Sep 17 00:00:00 2001 From: jona159 Date: Wed, 25 Jun 2025 12:29:50 +0200 Subject: [PATCH 07/15] fix: some types, formatting --- app/routes/api.devices.ts | 51 +++++++++--------- tests/routes/api.devices.spec.ts | 93 +++++++++++++++++--------------- 2 files changed, 77 insertions(+), 67 deletions(-) diff --git a/app/routes/api.devices.ts b/app/routes/api.devices.ts index 8c5cde1d..d4672256 100644 --- a/app/routes/api.devices.ts +++ b/app/routes/api.devices.ts @@ -25,7 +25,7 @@ export async function loader({ request }: LoaderFunctionArgs) { } if (searchParams.format == 'geojson') { try { - let result: GeoJSON.FeatureCollection | any[] + let result: GeoJSON.FeatureCollection result = await getDevices() @@ -74,30 +74,31 @@ export async function loader({ request }: LoaderFunctionArgs) { type: 'FeatureCollection', features: filteredFeatures, } as GeoJSON.FeatureCollection - } else if (Array.isArray(result)) { - result = result.filter((device: any) => { - if (device.geometry && device.geometry.coordinates) { - const [longitude, latitude] = device.geometry.coordinates - return ( - longitude >= west && - longitude <= east && - latitude >= south && - latitude <= north - ) - } - - if (device.longitude && device.latitude) { - return ( - device.longitude >= west && - device.longitude <= east && - device.latitude >= south && - device.latitude <= north - ) - } - - return false - }) - } + } + // else if (Array.isArray(result)) { + // result = result.filter((device: any) => { + // if (device.geometry && device.geometry.coordinates) { + // const [longitude, latitude] = device.geometry.coordinates + // return ( + // longitude >= west && + // longitude <= east && + // latitude >= south && + // latitude <= north + // ) + // } + + // if (device.longitude && device.latitude) { + // return ( + // device.longitude >= west && + // device.longitude <= east && + // device.latitude >= south && + // device.latitude <= north + // ) + // } + + // return false + // }) + // } } return result diff --git a/tests/routes/api.devices.spec.ts b/tests/routes/api.devices.spec.ts index 7e86f921..117c2d31 100644 --- a/tests/routes/api.devices.spec.ts +++ b/tests/routes/api.devices.spec.ts @@ -53,17 +53,17 @@ describe('openSenseMap API Routes: /boxes', () => { method: 'GET', headers: { 'Content-Type': 'application/json' }, }) - + try { await devicesLoader({ request: request, } as LoaderFunctionArgs) - expect(true).toBe(false) + expect(true).toBe(false) } catch (error) { expect(error).toBeInstanceOf(Response) - expect(error.status).toBe(422) - - const errorData = await error.json() + expect((error as Response).status).toBe(422) + + const errorData = await (error as Response).json() expect(errorData.error).toBe('Failed to fetch devices') } }) @@ -80,52 +80,61 @@ describe('openSenseMap API Routes: /boxes', () => { request: request, } as LoaderFunctionArgs) - console.log("geojson features 0 geometry", geojsonData.features[0].geometry) - - // Assert - this should always be GeoJSON since that's what the loader returns - expect(geojsonData.type).toBe('FeatureCollection') - expect(Array.isArray(geojsonData.features)).toBe(true) - - if (geojsonData.features.length > 0) { - expect(geojsonData.features[0].type).toBe('Feature') - expect(geojsonData.features[0].geometry).toBeDefined() - expect(geojsonData.features[0].geometry.coordinates[0]).toBeDefined() - expect(geojsonData.features[0].geometry.coordinates[1]).toBeDefined() - expect(geojsonData.features[0].properties).toBeDefined() + expect(geojsonData).toBeDefined() + if (geojsonData) { + // Assert - this should always be GeoJSON since that's what the loader returns + expect(geojsonData.type).toBe('FeatureCollection') + expect(Array.isArray(geojsonData.features)).toBe(true) + + if (geojsonData.features.length > 0) { + expect(geojsonData.features[0].type).toBe('Feature') + expect(geojsonData.features[0].geometry).toBeDefined() + // @ts-ignore + expect(geojsonData.features[0].geometry.coordinates[0]).toBeDefined() + // @ts-ignore + expect(geojsonData.features[0].geometry.coordinates[1]).toBeDefined() + expect(geojsonData.features[0].properties).toBeDefined() + } } }) it('should allow filtering boxes by bounding box', async () => { // Arrange - const request = new Request(`${BASE_URL}?format=geojson&bbox=120,60,121,61`, { - method: 'GET', - headers: { 'Content-Type': 'application/json' }, - }) - + const request = new Request( + `${BASE_URL}?format=geojson&bbox=120,60,121,61`, + { + method: 'GET', + headers: { 'Content-Type': 'application/json' }, + }, + ) + // Act const response = await devicesLoader({ request: request, } as LoaderFunctionArgs) - - // Assert - expect(response.type).toBe('FeatureCollection') - expect(Array.isArray(response.features)).toBe(true) - - if (response.features.length > 0) { - response.features.forEach((feature: any) => { - expect(feature.type).toBe('Feature') - expect(feature.geometry).toBeDefined() - expect(feature.geometry.coordinates).toBeDefined() - - const [longitude, latitude] = feature.geometry.coordinates - - - // Verify coordinates are within the bounding box [120,60,121,61] - expect(longitude).toBeGreaterThanOrEqual(120) - expect(longitude).toBeLessThanOrEqual(121) - expect(latitude).toBeGreaterThanOrEqual(60) - expect(latitude).toBeLessThanOrEqual(61) - }) + + expect(response).toBeDefined() + + if (response) { + // Assert + expect(response.type).toBe('FeatureCollection') + expect(Array.isArray(response.features)).toBe(true) + + if (response.features.length > 0) { + response.features.forEach((feature: any) => { + expect(feature.type).toBe('Feature') + expect(feature.geometry).toBeDefined() + expect(feature.geometry.coordinates).toBeDefined() + + const [longitude, latitude] = feature.geometry.coordinates + + // Verify coordinates are within the bounding box [120,60,121,61] + expect(longitude).toBeGreaterThanOrEqual(120) + expect(longitude).toBeLessThanOrEqual(121) + expect(latitude).toBeGreaterThanOrEqual(60) + expect(latitude).toBeLessThanOrEqual(61) + }) + } } }) }) From eeb604982e719129c90be314cce1e07f920fee45 Mon Sep 17 00:00:00 2001 From: jona159 Date: Wed, 16 Jul 2025 09:01:43 +0200 Subject: [PATCH 08/15] feat: wip devices api --- app/lib/devices-service.server.ts | 7 +- app/models/device.server.ts | 169 ++++++++- app/routes/api.device.$deviceId.ts | 60 ++++ app/routes/api.devices.ts | 553 ++++++++++++++++++++++++----- tests/routes/api.devices.spec.ts | 232 ++++++++++-- 5 files changed, 888 insertions(+), 133 deletions(-) diff --git a/app/lib/devices-service.server.ts b/app/lib/devices-service.server.ts index ef8f2986..053c2ccf 100644 --- a/app/lib/devices-service.server.ts +++ b/app/lib/devices-service.server.ts @@ -5,20 +5,21 @@ import { import { verifyLogin } from '~/models/user.server' export interface BoxesQueryParams { - name?: string + name?: string limit?: string - date?: string + date?: string[] phenomenon?: string format?: 'json' | 'geojson' grouptag?: string model?: string - classify?: 'true' | 'false' minimal?: 'true' | 'false' full?: 'true' | 'false' near?: string maxDistance?: string bbox?: string exposure?: string + fromDate: any + toDate: any } /** diff --git a/app/models/device.server.ts b/app/models/device.server.ts index a030a883..10195dba 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 } from 'drizzle-orm' +import { eq, sql, desc, ilike, inArray, arrayContains } from 'drizzle-orm' import { type Point } from 'geojson' import { drizzleClient } from '~/db.server' import { device, location, sensor, type Device, type Sensor } from '~/schema' @@ -117,7 +117,13 @@ export function getUserDevices(userId: Device['userId']) { }) } -export async function getDevices() { +type DevicesFormat = 'json' | 'geojson' + +export async function getDevices(format: 'json'): Promise +export async function getDevices(format: 'geojson'): Promise> +export async function getDevices(format?: DevicesFormat): Promise> + +export async function getDevices(format: DevicesFormat = 'json') { const devices = await drizzleClient.query.device.findMany({ columns: { id: true, @@ -130,18 +136,23 @@ export async function getDevices() { tags: true, }, }) - const geojson: GeoJSON.FeatureCollection = { - type: 'FeatureCollection', - features: [], - } - for (const device of devices) { - const coordinates = [device.longitude, device.latitude] - const feature = point(coordinates, device) - geojson.features.push(feature) + if (format === 'geojson') { + const geojson: GeoJSON.FeatureCollection = { + type: 'FeatureCollection', + features: [], + } + + for (const device of devices) { + const coordinates = [device.longitude, device.latitude] + const feature = point(coordinates, device) + geojson.features.push(feature) + } + + return geojson } - return geojson + return devices } export async function getDevicesWithSensors() { @@ -201,6 +212,142 @@ export async function getDevicesWithSensors() { return geojson } +interface BuildWhereClauseOptions { + name?: string; + phenomenon?: string; + fromDate?: string | Date; + toDate?: string | Date; + bbox?: { + coordinates: number[][][]; + }; + near?: [number, number]; // [lat, lng] + maxDistance?: number; + grouptag?: string[]; + exposure?: string[]; + model?: string[]; + } + + export interface FindDevicesOptions extends BuildWhereClauseOptions { + minimal?: string | boolean; + limit?: number; + } + + interface WhereClauseResult { + includeColumns: Record; + whereClause: any[]; + } + + const buildWhereClause = function buildWhereClause( + opts: BuildWhereClauseOptions = {} + ): WhereClauseResult { + const { name, phenomenon, fromDate, toDate, bbox, near, maxDistance, grouptag } = opts; + const clause = []; + const columns = {}; + + if (name) { + clause.push(ilike(device.name, `%${name}%`)); + } + + // if (phenomenon) { + // columns['sensors'] = { + // where: (sensor, { ilike }) => ilike(sensorTable['title'], `%${phenomenon}%`) + // }; + // } + + // simple string parameters + // for (const param of ['exposure', 'model'] as const) { + // if (opts[param]) { + // clause.push(inArray(device[param], opts[param]!)); + // } + // } + + if (grouptag) { + clause.push(arrayContains(device.tags, grouptag)); + } + + // https://orm.drizzle.team/learn/guides/postgis-geometry-point + if (bbox) { + const [latSW, lngSW] = bbox.coordinates[0][0]; + const [latNE, lngNE] = bbox.coordinates[0][2]; + clause.push( + sql`ST_Contains( + ST_MakeEnvelope(${lngSW}, ${latSW}, ${lngNE}, ${latNE}, 4326), + ST_SetSRID(ST_MakePoint(${device.longitude}, ${device.latitude}), 4326) + )` + ); + } + + if (near && maxDistance !== undefined) { + clause.push( + sql`ST_DWithin( + ST_SetSRID(ST_MakePoint(${device.longitude}, ${device.latitude}), 4326), + ST_SetSRID(ST_MakePoint(${near[1]}, ${near[0]}), 4326), + ${maxDistance} + )` + ); + } + + if (fromDate || toDate) { + if (phenomenon) { + // TODO: implement + } + } + + return { + includeColumns: columns, + whereClause: clause + }; + }; + + const MINIMAL_COLUMNS = { + id: true, + name: true, + exposure: true, + longitude: true, + latitude: true + }; + + const DEFAULT_COLUMNS = { + id: true, + name: true, + model: true, + exposure: true, + grouptag: true, + image: true, + description: true, + link: true, + createdAt: true, + updatedAt: true, + longitude: true, + latitude: true + }; + + export async function findDevices ( + opts: FindDevicesOptions = {}, + columns: Record = {}, + relations: Record = {} + ) { + const { minimal, limit } = opts; + const { includeColumns, whereClause } = buildWhereClause(opts); + + columns = (minimal === 'true') ? MINIMAL_COLUMNS : { ...DEFAULT_COLUMNS, ...columns }; + + relations = { + ...relations, + ...includeColumns + }; + const devices = await drizzleClient.query.device.findMany({ + ...(Object.keys(columns).length !== 0 && { columns }), + ...(Object.keys(relations).length !== 0 && { with: relations }), + ...(Object.keys(whereClause).length !== 0 && { + where: (_, { and }) => and(...whereClause) + }), + limit + }); + + return devices; + }; + export async function createDevice(deviceData: any, userId: string) { try { const newDevice = await drizzleClient.transaction(async (tx) => { diff --git a/app/routes/api.device.$deviceId.ts b/app/routes/api.device.$deviceId.ts index 92a90bbe..159aa0b0 100644 --- a/app/routes/api.device.$deviceId.ts +++ b/app/routes/api.device.$deviceId.ts @@ -1,6 +1,66 @@ import { json, type LoaderFunctionArgs } from '@remix-run/node' import { getDevice } from '~/models/device.server' +/** + * @openapi + * /api/device/{deviceId}: + * get: + * summary: Get device by ID + * description: Retrieve a single device by their unique identifier + * tags: + * - Device + * parameters: + * - in: path + * name: id + * required: true + * description: Unique identifier of the user + * schema: + * type: string + * example: "12345" + * responses: + * 200: + * description: Device retrieved successfully + * content: + * application/json: + * schema: + * type: object + * properties: + * id: + * type: string + * example: "12345" + * name: + * type: string + * example: "John Doe" + * email: + * type: string + * example: "john.doe@example.com" + * createdAt: + * type: string + * format: date-time + * example: "2023-01-15T10:30:00Z" + * 404: + * description: Device not found + * content: + * application/json: + * schema: + * type: object + * properties: + * error: + * type: string + * example: "Device not found" + * 400: + * description: Device ID is required + * content: + * application/json: + * schema: + * type: object + * properties: + * error: + * type: string + * example: "Device ID is required." + * 500: + * description: Internal server error + */ export async function loader({ params }: LoaderFunctionArgs) { const { deviceId } = params diff --git a/app/routes/api.devices.ts b/app/routes/api.devices.ts index d4672256..4cb2caa5 100644 --- a/app/routes/api.devices.ts +++ b/app/routes/api.devices.ts @@ -1,9 +1,12 @@ import { json, type LoaderFunctionArgs } from '@remix-run/node' import { createDevice, + findDevices, + FindDevicesOptions, // deleteDevice, getDevice, getDevices, + getDevicesWithSensors, } from '~/models/device.server' import { ActionFunctionArgs } from 'react-router' import { getUserFromJwt } from '~/lib/jwt' @@ -11,117 +14,483 @@ import { deleteDevice, type BoxesQueryParams, } from '~/lib/devices-service.server' -import { Device, User } from '~/schema' - +import { Device, DeviceExposureType, User } from '~/schema' +import { Point } from 'geojson' + +function fromToTimeParamsSanityCheck(fromDate: Date | null, toDate: Date | null) { + if ((fromDate && !toDate) || (toDate && !fromDate)) { + throw json( + { error: 'fromDate and toDate need to be specified simultaneously' }, + { status: 400 } + ); + } + + if (fromDate && toDate) { + if (fromDate.getTime() > toDate.getTime()) { + throw json( + { error: `Invalid time frame specified: fromDate (${fromDate.toISOString()}) is after toDate (${toDate.toISOString()})` }, + { status: 422 } + ); + } + } + } + + function parseAndValidateTimeParams(searchParams: URLSearchParams) { + const dateParams = searchParams.getAll('date'); + + if (dateParams.length === 0) { + return { fromDate: null, toDate: null }; + } + + if (dateParams.length > 2) { + throw json( + { error: 'invalid number of dates for date parameter supplied' }, + { status: 422 } + ); + } + + const [fromDateStr, toDateStr] = dateParams; + + const fromDate = new Date(fromDateStr); + if (isNaN(fromDate.getTime())) { + throw json( + { error: `Invalid date format: ${fromDateStr}` }, + { status: 422 } + ); + } + + let toDate: Date; + + if (!toDateStr) { + // If only one date provided, create a range of ±4 hours + toDate = new Date(fromDate.getTime() + (4 * 60 * 60 * 1000)); // +4 hours + const adjustedFromDate = new Date(fromDate.getTime() - (4 * 60 * 60 * 1000)); // -4 hours + + return { fromDate: adjustedFromDate, toDate }; + } else { + toDate = new Date(toDateStr); + if (isNaN(toDate.getTime())) { + throw json( + { error: `Invalid date format: ${toDateStr}` }, + { status: 422 } + ); + } + + fromToTimeParamsSanityCheck(fromDate, toDate); + + return { fromDate, toDate }; + } + } + +/** + * @openapi + * /api/devices: + * get: + * tags: + * - Devices + * summary: Get devices with filtering options + * description: Retrieves devices based on various filter criteria. Supports both JSON and GeoJSON formats. + * parameters: + * - name: format + * in: query + * required: false + * schema: + * type: string + * enum: [json, geojson] + * default: json + * description: Response format + * - name: minimal + * in: query + * required: false + * schema: + * type: string + * enum: [true, false] + * default: false + * description: Return minimal device information + * - name: full + * in: query + * required: false + * schema: + * type: string + * enum: [true, false] + * default: false + * description: Return full device information + * - name: limit + * in: query + * required: false + * schema: + * type: integer + * minimum: 1 + * maximum: 20 + * default: 5 + * description: Maximum number of devices to return + * - name: name + * in: query + * required: false + * schema: + * type: string + * description: Filter devices by name + * - name: phenomenon + * in: query + * required: false + * schema: + * type: string + * description: Filter devices by phenomenon type + * - name: fromDate + * in: query + * required: false + * schema: + * type: string + * format: date-time + * description: Filter devices from this date + * example: "2023-05-15T10:00:00Z" + * - name: toDate + * in: query + * required: false + * schema: + * type: string + * format: date-time + * description: Filter devices to this date + * example: "2023-05-15T12:00:00Z" + * - name: grouptag + * in: query + * required: false + * schema: + * type: string + * description: Filter devices by group tag + * - name: exposure + * in: query + * required: false + * schema: + * type: string + * description: Filter devices by exposure type + * - name: near + * in: query + * required: false + * schema: + * type: string + * pattern: '^-?\d+\.?\d*,-?\d+\.?\d*$' + * description: Find devices near coordinates (lat,lng) + * example: "52.5200,13.4050" + * - name: maxDistance + * in: query + * required: false + * schema: + * type: number + * default: 1000 + * description: Maximum distance in meters when using 'near' parameter + * - name: bbox + * in: query + * required: false + * schema: + * type: string + * pattern: '^-?\d+\.?\d*,-?\d+\.?\d*,-?\d+\.?\d*,-?\d+\.?\d*$' + * description: Bounding box coordinates (swLng,swLat,neLng,neLat) + * example: "13.2,52.4,13.6,52.6" + * - name: date + * in: query + * required: false + * schema: + * type: string + * format: date-time + * description: Specific date filter (TODO - not implemented) + * responses: + * 200: + * description: Successfully retrieved devices + * content: + * application/json: + * schema: + * oneOf: + * - type: array + * items: + * $ref: '#/components/schemas/Device' + * - $ref: '#/components/schemas/GeoJSONFeatureCollection' + * 400: + * description: Invalid request + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/ErrorResponse' + * 422: + * description: Invalid parameters + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/ErrorResponse' + * examples: + * invalidFormat: + * summary: Invalid format parameter + * value: + * error: "Failed to fetch devices" + * invalidLimit: + * summary: Invalid limit parameter + * value: + * error: "Limit must be at least 1" + * exceedsLimit: + * summary: Limit exceeds maximum + * value: + * error: "Limit should not exceed 20" + * invalidNear: + * summary: Invalid near parameter + * value: + * error: "Invalid 'near' parameter format. Expected: 'lat,lng'" + * 500: + * description: Internal server error + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/ErrorResponse' + * example: + * error: "Failed to fetch devices" + * + * components: + * schemas: + * Device: + * type: object + * required: + * - id + * - latitude + * - longitude + * properties: + * id: + * type: string + * description: Unique device identifier + * example: "device-123" + * name: + * type: string + * description: Device name + * example: "Temperature Sensor A1" + * latitude: + * type: number + * format: float + * description: Device latitude coordinate + * example: 52.5200 + * longitude: + * type: number + * format: float + * description: Device longitude coordinate + * example: 13.4050 + * phenomenon: + * type: string + * description: Type of phenomenon measured + * example: "temperature" + * grouptag: + * type: string + * description: Group tag for device categorization + * example: "outdoor-sensors" + * exposure: + * type: string + * description: Device exposure type + * example: "outdoor" + * createdAt: + * type: string + * format: date-time + * description: Device creation timestamp + * example: "2023-05-15T10:00:00Z" + * updatedAt: + * type: string + * format: date-time + * description: Device last update timestamp + * example: "2023-05-15T12:00:00Z" + * + * GeoJSONFeatureCollection: + * type: object + * required: + * - type + * - features + * properties: + * type: + * type: string + * enum: [FeatureCollection] + * example: "FeatureCollection" + * features: + * type: array + * items: + * $ref: '#/components/schemas/GeoJSONFeature' + * + * GeoJSONFeature: + * type: object + * required: + * - type + * - geometry + * - properties + * properties: + * type: + * type: string + * enum: [Feature] + * example: "Feature" + * geometry: + * $ref: '#/components/schemas/GeoJSONPoint' + * properties: + * $ref: '#/components/schemas/Device' + * + * GeoJSONPoint: + * type: object + * required: + * - type + * - coordinates + * properties: + * type: + * type: string + * enum: [Point] + * example: "Point" + * coordinates: + * type: array + * items: + * type: number + * minItems: 2 + * maxItems: 2 + * description: Longitude and latitude coordinates + * example: [13.4050, 52.5200] + * + * ErrorResponse: + * type: object + * required: + * - error + * properties: + * error: + * type: string + * description: Error message + * example: "Failed to fetch devices" + */ export async function loader({ request }: LoaderFunctionArgs) { - const url = new URL(request.url) - const searchParams = Object.fromEntries(url.searchParams) as BoxesQueryParams - const validFormats: BoxesQueryParams['format'] = 'geojson' // TODO: support json + const url = new URL(request.url); + const max_limit = 20; + const { fromDate, toDate } = parseAndValidateTimeParams(url.searchParams); + + const searchParams = { + format: 'json', + minimal: 'false', + full: 'false', + limit: '5', + ...Object.fromEntries(url.searchParams), + ...(fromDate && { fromDate: fromDate.toISOString() }), + ...(toDate && { toDate: toDate.toISOString() }) + } as BoxesQueryParams; + + const validFormats: BoxesQueryParams['format'][] = ['geojson', 'json']; if (searchParams.format) { if (!validFormats.includes(searchParams.format)) { - console.error('Error in loader:', 'invalid format parameter') - throw json({ error: 'Failed to fetch devices' }, { status: 422 }) + console.error('Error in loader:', 'invalid format parameter'); + throw json({ error: 'Failed to fetch devices' }, { status: 422 }); } - if (searchParams.format == 'geojson') { + + if (searchParams.format === 'json') { try { - let result: GeoJSON.FeatureCollection - - result = await getDevices() - - if (searchParams.bbox && result) { - const bboxCoords = searchParams.bbox - .split(',') - .map((coord) => Number(coord)) - const [west, south, east, north] = bboxCoords // [minLon, minLat, maxLon, maxLat] - - if ( - bboxCoords.length !== 4 || - bboxCoords.some((coord) => isNaN(Number(coord))) - ) { - throw json( - { - error: - "Invalid 'bbox' parameter format. Expected: 'west,south,east,north'", - }, - { status: 422 }, - ) + if (searchParams.date) { + // TODO: handle date param + // let result = await getDevicesWithSensorsJson() + } else { + const findDevicesOpts: FindDevicesOptions = { + minimal: searchParams.minimal, + limit: searchParams.limit ? parseInt(searchParams.limit) : 5, + name: searchParams.name, + phenomenon: searchParams.phenomenon, + fromDate: searchParams.fromDate ? new Date(searchParams.fromDate) : undefined, + toDate: searchParams.toDate ? new Date(searchParams.toDate) : undefined, + grouptag: searchParams.grouptag ? [searchParams.grouptag] : undefined, + exposure: searchParams.exposure ? [searchParams.exposure as DeviceExposureType] : undefined, + }; + + if (findDevicesOpts.limit) { + if(findDevicesOpts.limit < 1){ + throw json({ error: 'Limit must be at least 1' }, { status: 422 }); + } + else if (findDevicesOpts.limit > max_limit) { + throw json({ error: 'Limit should not exceed 20' }, { status: 422 }); + } + } + if (searchParams.near) { + const nearCoords = searchParams.near.split(','); + if ( + nearCoords.length !== 2 || + isNaN(Number(nearCoords[0])) || + isNaN(Number(nearCoords[1])) + ) { + throw json( + { error: "Invalid 'near' parameter format. Expected: 'lat,lng'" }, + { status: 422 } + ); + } + + const [nearLat, nearLng] = nearCoords.map(Number); + findDevicesOpts.near = [nearLat, nearLng]; + findDevicesOpts.maxDistance = searchParams.maxDistance + ? Number(searchParams.maxDistance) + : 1000; } - if ( - result && - typeof result === 'object' && - 'type' in result && - result.type === 'FeatureCollection' - ) { - const filteredFeatures = result.features.filter((feature: any) => { - if (!feature.geometry || !feature.geometry.coordinates) { - return false + if (searchParams.bbox) { + try { + const bboxCoords = searchParams.bbox.split(',').map(Number); + if (bboxCoords.length === 4) { + const [swLng, swLat, neLng, neLat] = bboxCoords; + findDevicesOpts.bbox = { + coordinates: [[[swLat, swLng], [neLat, swLng], [neLat, neLng], [swLat, neLng], [swLat, swLng]]] + }; } + } catch (error) { + console.warn('Invalid bbox parameter:', searchParams.bbox); + } + } - const [longitude, latitude] = feature.geometry.coordinates - - const isInBounds = - longitude >= west && - longitude <= east && - latitude >= south && - latitude <= north - - return isInBounds - }) + const result = await findDevices(findDevicesOpts); - result = { - type: 'FeatureCollection', - features: filteredFeatures, - } as GeoJSON.FeatureCollection + return result; + } + } catch (error) { + console.error('Error in loader:', error); + throw json({ error: 'Failed to fetch devices' }, { status: 500 }); + } + } + + if (searchParams.format === 'geojson') { + try { + const findDevicesOpts: FindDevicesOptions = { + minimal: searchParams.minimal, + limit: searchParams.limit ? parseInt(searchParams.limit) : 5, + name: searchParams.name, + }; + + if(!findDevicesOpts.limit){ + throw json({ error: 'Limit must be at least 1' }, { status: 422 }); + } + if (findDevicesOpts.limit) { + if(findDevicesOpts.limit < 1){ + throw json({ error: 'Limit must be at least 1' }, { status: 422 }); + } + else if (findDevicesOpts.limit > max_limit) { + throw json({ error: 'Limit should not exceed 20' }, { status: 422 }); } - // else if (Array.isArray(result)) { - // result = result.filter((device: any) => { - // if (device.geometry && device.geometry.coordinates) { - // const [longitude, latitude] = device.geometry.coordinates - // return ( - // longitude >= west && - // longitude <= east && - // latitude >= south && - // latitude <= north - // ) - // } - - // if (device.longitude && device.latitude) { - // return ( - // device.longitude >= west && - // device.longitude <= east && - // device.latitude >= south && - // device.latitude <= north - // ) - // } - - // return false - // }) - // } } - return result + const devices = await findDevices(findDevicesOpts); + + const geojson = { + type: 'FeatureCollection', + features: devices.map((device: Device) => ({ + type: 'Feature', + geometry: { + type: 'Point', + coordinates: [device.longitude, device.latitude] + }, + properties: { + ...device + } + })) + }; + + return geojson; } catch (error) { - console.error('Error in loader:', error) - throw json({ error: 'Failed to fetch devices' }, { status: 500 }) + console.error('Error in loader:', error); + throw json({ error: 'Failed to fetch devices' }, { status: 500 }); } } } - if (searchParams.near) { - const nearCoords = searchParams.near.split(',') - if ( - nearCoords.length !== 2 || - isNaN(Number(nearCoords[0])) || - isNaN(Number(nearCoords[1])) - ) { - throw json( - { error: "Invalid 'near' parameter format. Expected: 'lng,lat'" }, - { status: 422 }, - ) - } - } + // Default fallback + throw json({ error: 'Invalid request' }, { status: 400 }); } export async function action({ request, params }: ActionFunctionArgs) { diff --git a/tests/routes/api.devices.spec.ts b/tests/routes/api.devices.spec.ts index 117c2d31..67f05f68 100644 --- a/tests/routes/api.devices.spec.ts +++ b/tests/routes/api.devices.spec.ts @@ -14,7 +14,7 @@ import { deleteUserByEmail } from '~/models/user.server' const ME_TEST_USER = { name: 'meTest', - email: 'test@me.endpoint', + email: 'test@devices.endpoint', password: 'highlySecurePasswordForTesting', } @@ -32,6 +32,154 @@ const minimalSensebox = function minimalSensebox( describe('openSenseMap API Routes: /boxes', () => { describe('GET /boxes', () => { + + it('should search for boxes with a specific name', async () => { + // Arrange + const request = new Request( + `${BASE_URL}?format=geojson&name=sensebox`, + { + method: 'GET', + headers: { 'Content-Type': 'application/json' }, + }, + ) + + // Act + const response = await devicesLoader({ + request: request, + } as LoaderFunctionArgs) + + + expect(response).toBeDefined() + expect(Array.isArray(response?.features)).toBe(true) + expect(response?.features.length).to.be.equal(5) // 5 is default limit + }) + + it('should search for boxes with a specific name and limit the results', async () => { + // Arrange + const request = new Request( + `${BASE_URL}?format=geojson&name=sensebox&limit=2`, + { + method: 'GET', + headers: { 'Content-Type': 'application/json' }, + }, + ) + + // Act + const response = await devicesLoader({ + request: request, + } as LoaderFunctionArgs) + + expect(response).toBeDefined() + expect(Array.isArray(response?.features)).toBe(true) + expect(response?.features.length).to.be.equal(2) + }); + + it('should deny searching for a name if limit is greater than max value', async () => { + // Arrange + const request = new Request( + `${BASE_URL}?format=geojson&name=sensebox&limit=21`, + { + method: 'GET', + headers: { 'Content-Type': 'application/json' }, + }, + ) + + // Act + await expect(async () => { + await devicesLoader({ + request: request, + } as LoaderFunctionArgs) + }).rejects.toThrow() + }); + + it('should deny searching for a name if limit is lower than min value', async () => { + // Arrange + const request = new Request( + `${BASE_URL}?format=geojson&name=sensebox&limit=0`, + { + method: 'GET', + headers: { 'Content-Type': 'application/json' }, + }, + ) + + // Act + await expect(async () => { + await devicesLoader({ + request: request, + } as LoaderFunctionArgs) + }).rejects.toThrow() + }); + + // it('should allow to request minimal boxes', async () => { + // // Arrange + // const request = new Request( + // `${BASE_URL}?minimal=true`, + // { + // method: 'GET', + // headers: { 'Content-Type': 'application/json' }, + // }, + // ) + + // const response = await devicesLoader({ + // request: request, + // } as LoaderFunctionArgs) + + // expect(response).toBeDefined() + // expect(Array.isArray(response?.features)).toBe(true) + // // return chakram.get(`${BASE_URL}/boxes?minimal=true`) + // // .then(function (response) { + // // expect(response).to.have.status(200); + // // expect(response).to.have.header('content-type', 'application/json; charset=utf-8'); + // // expect(Array.isArray(response.body)).to.be.true; + // // expect(response.body.length).to.be.equal(boxCount); + // // for (const box of response.body) { + // // expect(Object.keys(box)) + // // .to.not.include('loc') + // // .and.to.not.include('locations') + // // .and.not.include('weblink') + // // .and.not.include('image') + // // .and.not.include('description') + // // .and.not.include('model') + // // .and.not.include('sensors'); + // // } + + // // return chakram.wait(); + // // }); + // }); + + // it('should return the correct count and correct schema of boxes for /boxes GET with date parameter', async () => { + // const tenDaysAgoIso = new Date(Date.now() - 10 * 24 * 60 * 60 * 1000).toISOString(); + + // // Arrange + // const request = new Request( + // `${BASE_URL}?format=geojson&date=${tenDaysAgoIso}`, + // { + // method: 'GET', + // headers: { 'Content-Type': 'application/json' }, + // }, + // ) + + // const response = await devicesLoader({ + // request: request, + // } as LoaderFunctionArgs) + + // expect(response).toBeDefined() + + // // return chakram.get(`${BASE_URL}/boxes?date=${ten_days_ago.toISOString()}`) + // // .then(function (response) { + // // expect(response).to.have.status(200); + // // expect(Array.isArray(response.body)).to.be.true; + // // expect(response.body.length).to.be.equal(1); + // // expect(response).to.have.header('content-type', 'application/json; charset=utf-8'); + // // expect(response).to.have.schema(findAllSchema); + // // expect(response.body[0].sensors.some(function (sensor) { + // // return moment.utc(sensor.lastMeasurement.createdAt).diff(ten_days_ago) < 10; + // // })).to.be.true; + + // // return chakram.wait(); + // // }); + // }); + it('should reject filtering boxes near a location with wrong parameter values', async () => { // Arrange const request = new Request(`${BASE_URL}?near=test,60`, { @@ -80,6 +228,7 @@ describe('openSenseMap API Routes: /boxes', () => { request: request, } as LoaderFunctionArgs) + expect(geojsonData).toBeDefined() if (geojsonData) { // Assert - this should always be GeoJSON since that's what the loader returns @@ -98,45 +247,74 @@ describe('openSenseMap API Routes: /boxes', () => { } }) - it('should allow filtering boxes by bounding box', async () => { + it('should allow to filter boxes by grouptag', async () => { // Arrange const request = new Request( - `${BASE_URL}?format=geojson&bbox=120,60,121,61`, + `${BASE_URL}?grouptag=newgroup`, { method: 'GET', headers: { 'Content-Type': 'application/json' }, }, ) - // Act const response = await devicesLoader({ request: request, } as LoaderFunctionArgs) - expect(response).toBeDefined() - if (response) { - // Assert - expect(response.type).toBe('FeatureCollection') - expect(Array.isArray(response.features)).toBe(true) - - if (response.features.length > 0) { - response.features.forEach((feature: any) => { - expect(feature.type).toBe('Feature') - expect(feature.geometry).toBeDefined() - expect(feature.geometry.coordinates).toBeDefined() - - const [longitude, latitude] = feature.geometry.coordinates - - // Verify coordinates are within the bounding box [120,60,121,61] - expect(longitude).toBeGreaterThanOrEqual(120) - expect(longitude).toBeLessThanOrEqual(121) - expect(latitude).toBeGreaterThanOrEqual(60) - expect(latitude).toBeLessThanOrEqual(61) - }) - } - } - }) + expect(response).toBeDefined() + expect(response?.length).to.be.equal(0) + + // return chakram.get(`${BASE_URL}/grouptag=newgroup`) + // .then(function (response) { + // expect(response).to.have.status(200); + // expect(response).to.have.header('content-type', 'application/json; charset=utf-8'); + // expect(Array.isArray(response.body)).to.be.true; + // expect(response.body.length).to.be.equal(2); + + // return chakram.wait(); + // }); + }); + + // it('should allow filtering boxes by bounding box', async () => { + // // Arrange + // const request = new Request( + // `${BASE_URL}?format=geojson&bbox=120,60,121,61`, + // { + // method: 'GET', + // headers: { 'Content-Type': 'application/json' }, + // }, + // ) + + // // Act + // const response = await devicesLoader({ + // request: request, + // } as LoaderFunctionArgs) + + // expect(response).toBeDefined() + + // if (response) { + // // Assert + // expect(response.type).toBe('FeatureCollection') + // expect(Array.isArray(response.features)).toBe(true) + + // if (response.features.length > 0) { + // response.features.forEach((feature: any) => { + // expect(feature.type).toBe('Feature') + // expect(feature.geometry).toBeDefined() + // expect(feature.geometry.coordinates).toBeDefined() + + // const [longitude, latitude] = feature.geometry.coordinates + + // // Verify coordinates are within the bounding box [120,60,121,61] + // expect(longitude).toBeGreaterThanOrEqual(120) + // expect(longitude).toBeLessThanOrEqual(121) + // expect(latitude).toBeGreaterThanOrEqual(60) + // expect(latitude).toBeLessThanOrEqual(61) + // }) + // } + // } + // }) }) let jwt: string = '' From 711ea657b99d375eaaf654e66c1e8b318f4d0ea1 Mon Sep 17 00:00:00 2001 From: David Scheidt Date: Wed, 13 Aug 2025 17:33:12 +0200 Subject: [PATCH 09/15] fix: tests --- tests/routes/api.devices.spec.ts | 388 +++++++++++++++---------------- 1 file changed, 192 insertions(+), 196 deletions(-) diff --git a/tests/routes/api.devices.spec.ts b/tests/routes/api.devices.spec.ts index 67f05f68..09d25d70 100644 --- a/tests/routes/api.devices.spec.ts +++ b/tests/routes/api.devices.spec.ts @@ -1,42 +1,64 @@ import { - AppLoadContext, - LoaderFunctionArgs, + type AppLoadContext, + type LoaderFunctionArgs, type ActionFunctionArgs, } from 'react-router' import { BASE_URL } from 'vitest.setup' -import { type User, type Device } from '~/schema' -import { loader as devicesLoader } from '~/routes/api.devices' -import { loader as deviceLoader } from '~/routes/api.device.$deviceId' -import { action as devicesAction } from '~/routes/api.devices' -import { registerUser } from '~/lib/user-service.server' import { createToken } from '~/lib/jwt' +import { registerUser } from '~/lib/user-service.server' +import { createDevice, deleteDevice } from '~/models/device.server' import { deleteUserByEmail } from '~/models/user.server' +import { loader as deviceLoader } from '~/routes/api.device.$deviceId' +import { + loader as devicesLoader, + action as devicesAction, +} from '~/routes/api.devices' +import { type User, type Device } from '~/schema' -const ME_TEST_USER = { - name: 'meTest', +const DEVICE_TEST_USER = { + name: 'deviceTest', email: 'test@devices.endpoint', password: 'highlySecurePasswordForTesting', } -const minimalSensebox = function minimalSensebox( +const generateMinimalDevice = ( location: number[] | {} = [123, 12, 34], exposure = 'mobile', -) { - return { - exposure, - location, - name: 'senseBox', - model: 'homeV2Ethernet', - } -} + name = 'senseBox' + new Date().getTime(), +) => ({ + exposure, + location, + name, + model: 'homeV2Ethernet', +}) describe('openSenseMap API Routes: /boxes', () => { - describe('GET /boxes', () => { + let user: User | null = null + let jwt: string = '' + let queryableDevice: Device | null = null + beforeAll(async () => { + const testUser = await registerUser( + DEVICE_TEST_USER.name, + DEVICE_TEST_USER.email, + DEVICE_TEST_USER.password, + 'en_US', + ) + user = testUser as User + const { token: t } = await createToken(testUser as User) + jwt = t + + queryableDevice = await createDevice( + { ...generateMinimalDevice(), latitude: 123, longitude: 12 }, + (testUser as User).id, + ) + }) + + describe('GET', () => { it('should search for boxes with a specific name', async () => { // Arrange const request = new Request( - `${BASE_URL}?format=geojson&name=sensebox`, + `${BASE_URL}?format=geojson&name=${queryableDevice?.name}`, { method: 'GET', headers: { 'Content-Type': 'application/json' }, @@ -48,16 +70,15 @@ describe('openSenseMap API Routes: /boxes', () => { request: request, } as LoaderFunctionArgs) - expect(response).toBeDefined() expect(Array.isArray(response?.features)).toBe(true) - expect(response?.features.length).to.be.equal(5) // 5 is default limit + expect(response?.features.length).lessThanOrEqual(5) // 5 is default limit }) - it('should search for boxes with a specific name and limit the results', async () => { + it('should search for boxes with a specific name and limit the results', async () => { // Arrange const request = new Request( - `${BASE_URL}?format=geojson&name=sensebox&limit=2`, + `${BASE_URL}?format=geojson&name=${queryableDevice?.name}&limit=2`, { method: 'GET', headers: { 'Content-Type': 'application/json' }, @@ -71,13 +92,13 @@ describe('openSenseMap API Routes: /boxes', () => { expect(response).toBeDefined() expect(Array.isArray(response?.features)).toBe(true) - expect(response?.features.length).to.be.equal(2) - }); + expect(response?.features.length).lessThanOrEqual(2) + }) - it('should deny searching for a name if limit is greater than max value', async () => { + it('should deny searching for a name if limit is greater than max value', async () => { // Arrange const request = new Request( - `${BASE_URL}?format=geojson&name=sensebox&limit=21`, + `${BASE_URL}?format=geojson&name=${queryableDevice?.name}&limit=21`, { method: 'GET', headers: { 'Content-Type': 'application/json' }, @@ -90,9 +111,9 @@ describe('openSenseMap API Routes: /boxes', () => { request: request, } as LoaderFunctionArgs) }).rejects.toThrow() - }); - - it('should deny searching for a name if limit is lower than min value', async () => { + }) + + it('should deny searching for a name if limit is lower than min value', async () => { // Arrange const request = new Request( `${BASE_URL}?format=geojson&name=sensebox&limit=0`, @@ -108,7 +129,7 @@ describe('openSenseMap API Routes: /boxes', () => { request: request, } as LoaderFunctionArgs) }).rejects.toThrow() - }); + }) // it('should allow to request minimal boxes', async () => { // // Arrange @@ -142,7 +163,7 @@ describe('openSenseMap API Routes: /boxes', () => { // // .and.not.include('model') // // .and.not.include('sensors'); // // } - + // // return chakram.wait(); // // }); // }); @@ -164,7 +185,7 @@ describe('openSenseMap API Routes: /boxes', () => { // } as LoaderFunctionArgs) // expect(response).toBeDefined() - + // // return chakram.get(`${BASE_URL}/boxes?date=${ten_days_ago.toISOString()}`) // // .then(function (response) { // // expect(response).to.have.status(200); @@ -175,7 +196,7 @@ describe('openSenseMap API Routes: /boxes', () => { // // expect(response.body[0].sensors.some(function (sensor) { // // return moment.utc(sensor.lastMeasurement.createdAt).diff(ten_days_ago) < 10; // // })).to.be.true; - + // // return chakram.wait(); // // }); // }); @@ -228,7 +249,6 @@ describe('openSenseMap API Routes: /boxes', () => { request: request, } as LoaderFunctionArgs) - expect(geojsonData).toBeDefined() if (geojsonData) { // Assert - this should always be GeoJSON since that's what the loader returns @@ -249,19 +269,15 @@ describe('openSenseMap API Routes: /boxes', () => { it('should allow to filter boxes by grouptag', async () => { // Arrange - const request = new Request( - `${BASE_URL}?grouptag=newgroup`, - { - method: 'GET', - headers: { 'Content-Type': 'application/json' }, - }, - ) + const request = new Request(`${BASE_URL}?grouptag=newgroup`, { + method: 'GET', + headers: { 'Content-Type': 'application/json' }, + }) const response = await devicesLoader({ request: request, } as LoaderFunctionArgs) - expect(response).toBeDefined() expect(response?.length).to.be.equal(0) @@ -271,10 +287,10 @@ describe('openSenseMap API Routes: /boxes', () => { // expect(response).to.have.header('content-type', 'application/json; charset=utf-8'); // expect(Array.isArray(response.body)).to.be.true; // expect(response.body.length).to.be.equal(2); - + // return chakram.wait(); // }); - }); + }) // it('should allow filtering boxes by bounding box', async () => { // // Arrange @@ -317,33 +333,11 @@ describe('openSenseMap API Routes: /boxes', () => { // }) }) - let jwt: string = '' - let device: Device | null = null - let user: User | null = null - - beforeAll(async () => { - const testUser = await registerUser( - ME_TEST_USER.name, - ME_TEST_USER.email, - ME_TEST_USER.password, - 'en_US', - ) - if ( - testUser !== null && - typeof testUser === 'object' && - 'id' in testUser && - 'username' in testUser - ) { - user = testUser - } - const { token: t } = await createToken(testUser as User) - jwt = t - }) - describe('POST /boxes', () => { + describe('POST', () => { it('should allow to set the location for a new box as array', async () => { // Arrange const loc = [0, 0, 0] - const requestBody = minimalSensebox(loc) + const requestBody = generateMinimalDevice(loc) const request = new Request(BASE_URL, { method: 'POST', @@ -355,13 +349,11 @@ describe('openSenseMap API Routes: /boxes', () => { const response = await devicesAction({ request: request, } as ActionFunctionArgs) + const responseData = await response.json() + await deleteDevice({ id: responseData.data!.id }) // Assert expect(response.status).toBe(201) - - const responseData = await response.json() - device = responseData.data - expect(responseData.data.latitude).toBeDefined() expect(responseData.data.longitude).toBeDefined() expect(responseData.data.latitude).toBe(loc[0]) @@ -378,7 +370,7 @@ describe('openSenseMap API Routes: /boxes', () => { it('should allow to set the location for a new box as latLng object', async () => { // Arrange const loc = { lng: 120.123456, lat: 60.654321 } - const requestBody = minimalSensebox(loc) + const requestBody = generateMinimalDevice(loc) const request = new Request(BASE_URL, { method: 'POST', @@ -390,11 +382,11 @@ describe('openSenseMap API Routes: /boxes', () => { const response = await devicesAction({ request: request, } as ActionFunctionArgs) + const responseData = await response.json() + await deleteDevice({ id: responseData.data!.id }) // Assert expect(response.status).toBe(201) - - const responseData = await response.json() expect(responseData.data.latitude).toBeDefined() expect(responseData.data.latitude).toBe(loc.lat) expect(responseData.data.longitude).toBeDefined() @@ -466,152 +458,156 @@ describe('openSenseMap API Routes: /boxes', () => { // }) }) - describe('GET /boxes/:deviceId', () => { - let result: any + describe('/:deviceId', () => { + describe('GET', () => { + let result: any - beforeAll(async () => { - // Skip if no device was created in POST tests - if (!device) { - throw new Error( - 'No device was created in previous tests. Make sure POST tests run first.', - ) - } + beforeAll(async () => { + // Arrange + const request = new Request(`${BASE_URL}/${queryableDevice!.id}`, { + method: 'GET', + headers: { 'Content-Type': 'application/json' }, + }) - // Arrange - const request = new Request(`${BASE_URL}/${device.id}`, { - method: 'GET', - headers: { 'Content-Type': 'application/json' }, - }) + // Act + const dataFunctionValue = await deviceLoader({ + request: request, + params: { deviceId: queryableDevice!.id }, + context: {} as AppLoadContext, + } as LoaderFunctionArgs) - // Act - const dataFunctionValue = await deviceLoader({ - request: request, - params: { deviceId: device.id }, - context: {} as AppLoadContext, - } as LoaderFunctionArgs) + const response = dataFunctionValue as Response - const response = dataFunctionValue as Response + // Assert initial response + expect(dataFunctionValue).toBeInstanceOf(Response) + expect(response.status).toBe(200) + expect(response.headers.get('content-type')).toBe( + 'application/json; charset=utf-8', + ) - // Assert initial response - expect(dataFunctionValue).toBeInstanceOf(Response) - expect(response.status).toBe(200) - expect(response.headers.get('content-type')).toBe( - 'application/json; charset=utf-8', - ) + // Get the body for subsequent tests + result = await response.json() + }) - // Get the body for subsequent tests - result = await response.json() - }) + it('should return the device with correct location data', () => { + expect(result).toBeDefined() + expect(result._id || result.id).toBe(queryableDevice?.id) + expect(result.latitude).toBeDefined() + expect(result.longitude).toBeDefined() + expect(result.latitude).toBe(queryableDevice?.latitude) + expect(result.longitude).toBe(queryableDevice?.longitude) + }) - it('should return the device with correct location data', () => { - expect(result).toBeDefined() - expect(result._id || result.id).toBe(device?.id) - expect(result.latitude).toBeDefined() - expect(result.longitude).toBeDefined() - expect(result.latitude).toBe(device?.latitude) - expect(result.longitude).toBe(device?.longitude) - }) + it('should return the device name and model', () => { + expect(result.name).toBe(queryableDevice?.name) + expect(result.model).toBe('homeV2Ethernet') + expect(result.exposure).toBe('mobile') + }) - it('should return the device name and model', () => { - expect(result.name).toBe('senseBox') - expect(result.model).toBe('homeV2Ethernet') - expect(result.exposure).toBe('mobile') - }) + it('should return the creation timestamp', () => { + expect(result.createdAt).toBeDefined() + expect(result.createdAt).toBe(queryableDevice?.createdAt.toISOString()) + }) - it('should return the creation timestamp', () => { - expect(result.createdAt).toBeDefined() - expect(result.createdAt).toBe(device?.createdAt) + it('should NOT return sensitive data (if any)', () => { + // Add assertions for fields that shouldn't be returned + // For example, if there are internal fields that shouldn't be exposed: + // expect(result.internalField).toBeUndefined() + }) }) - it('should NOT return sensitive data (if any)', () => { - // Add assertions for fields that shouldn't be returned - // For example, if there are internal fields that shouldn't be exposed: - // expect(result.internalField).toBeUndefined() - }) - }) + describe('DELETE', () => { + let deletableDevice: Device | null = null - describe('DELETE /boxes/:deviceId', () => { - let deleteResult: any - let getAfterDeleteResult: any - // let statsResult: any - - it('should deny deletion with incorrect password', async () => { - const badDeleteRequest = new Request(`${BASE_URL}/${device?.id}`, { - method: 'DELETE', - headers: { - 'Content-Type': 'application/json', - Authorization: `Bearer ${jwt}`, - }, - body: JSON.stringify({ password: 'wrong password' }), + beforeAll(async () => { + deletableDevice = await createDevice( + { ...generateMinimalDevice(), latitude: 123, longitude: 12 }, + user!.id, + ) }) - const badDeleteResponse = await devicesAction({ - request: badDeleteRequest, - params: { deviceId: device?.id }, - context: {} as AppLoadContext, - } as ActionFunctionArgs) + it('should deny deletion with incorrect password', async () => { + const badDeleteRequest = new Request( + `${BASE_URL}/${queryableDevice?.id}`, + { + method: 'DELETE', + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${jwt}`, + }, + body: JSON.stringify({ password: 'wrong password' }), + }, + ) - expect(badDeleteResponse).toBeInstanceOf(Response) - expect(badDeleteResponse.status).toBe(401) - expect(badDeleteResponse.headers.get('content-type')).toBe( - 'application/json; charset=utf-8', - ) + const badDeleteResponse = await devicesAction({ + request: badDeleteRequest, + params: { deviceId: queryableDevice?.id }, + context: {} as AppLoadContext, + } as ActionFunctionArgs) - const badResult = await badDeleteResponse.json() - expect(badResult).toEqual({ message: 'Password incorrect' }) - }) + expect(badDeleteResponse).toBeInstanceOf(Response) + expect(badDeleteResponse.status).toBe(401) + expect(badDeleteResponse.headers.get('content-type')).toBe( + 'application/json; charset=utf-8', + ) - it('should successfully delete the device with correct password', async () => { - const validDeleteRequest = new Request(`${BASE_URL}/${device?.id}`, { - method: 'DELETE', - headers: { - 'Content-Type': 'application/json', - Authorization: `Bearer ${jwt}`, - }, - body: JSON.stringify({ password: ME_TEST_USER.password }), + const badResult = await badDeleteResponse.json() + expect(badResult).toEqual({ message: 'Password incorrect' }) }) - const validDeleteResponse = await devicesAction({ - request: validDeleteRequest, - params: { deviceId: device?.id }, - context: {} as AppLoadContext, - } as ActionFunctionArgs) - - expect(validDeleteResponse).toBeInstanceOf(Response) - expect(validDeleteResponse.status).toBe(200) - expect(validDeleteResponse.headers.get('content-type')).toBe( - 'application/json; charset=utf-8', - ) + it('should successfully delete the device with correct password', async () => { + const validDeleteRequest = new Request( + `${BASE_URL}/${deletableDevice?.id}`, + { + method: 'DELETE', + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${jwt}`, + }, + body: JSON.stringify({ password: DEVICE_TEST_USER.password }), + }, + ) - deleteResult = await validDeleteResponse.json() - expect(deleteResult).toBeDefined() - }) + const validDeleteResponse = await devicesAction({ + request: validDeleteRequest, + params: { deviceId: deletableDevice?.id }, + context: {} as AppLoadContext, + } as ActionFunctionArgs) - it('should return 404 when trying to get the deleted device', async () => { - const getDeletedRequest = new Request(`${BASE_URL}/${device?.id}`, { - method: 'GET', - headers: { 'Content-Type': 'application/json' }, + expect(validDeleteResponse).toBeInstanceOf(Response) + expect(validDeleteResponse.status).toBe(200) + expect(validDeleteResponse.headers.get('content-type')).toBe( + 'application/json; charset=utf-8', + ) }) - const getDeletedResponse = await deviceLoader({ - request: getDeletedRequest, - params: { deviceId: device?.id }, - context: {} as AppLoadContext, - } as LoaderFunctionArgs) + it('should return 404 when trying to get the deleted device', async () => { + const getDeletedRequest = new Request( + `${BASE_URL}/${deletableDevice?.id}`, + { + method: 'GET', + headers: { 'Content-Type': 'application/json' }, + }, + ) - expect(getDeletedResponse).toBeInstanceOf(Response) - expect(getDeletedResponse.status).toBe(404) - expect(getDeletedResponse.headers.get('content-type')).toBe( - 'application/json; charset=utf-8', - ) + const getDeletedResponse = await deviceLoader({ + request: getDeletedRequest, + params: { deviceId: deletableDevice?.id }, + context: {} as AppLoadContext, + } as LoaderFunctionArgs) - getAfterDeleteResult = await getDeletedResponse.json() - expect(getAfterDeleteResult.message).toBe('Device not found.') + expect(getDeletedResponse).toBeInstanceOf(Response) + expect(getDeletedResponse.status).toBe(404) + expect(getDeletedResponse.headers.get('content-type')).toBe( + 'application/json; charset=utf-8', + ) + }) }) + }) - afterAll(async () => { - await deleteUserByEmail(ME_TEST_USER.email) - }) + afterAll(async () => { + await deleteDevice({ id: queryableDevice!.id }) + await deleteUserByEmail(DEVICE_TEST_USER.email) }) }) From c0713e9811beba76885f3d46190972752196a830 Mon Sep 17 00:00:00 2001 From: David Scheidt Date: Wed, 13 Aug 2025 17:44:54 +0200 Subject: [PATCH 10/15] refactor: use modern syntax for assertion --- tests/routes/api.devices.spec.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/routes/api.devices.spec.ts b/tests/routes/api.devices.spec.ts index 09d25d70..dd043bd3 100644 --- a/tests/routes/api.devices.spec.ts +++ b/tests/routes/api.devices.spec.ts @@ -279,7 +279,7 @@ describe('openSenseMap API Routes: /boxes', () => { } as LoaderFunctionArgs) expect(response).toBeDefined() - expect(response?.length).to.be.equal(0) + expect(response?.length).toBe(0) // return chakram.get(`${BASE_URL}/grouptag=newgroup`) // .then(function (response) { From 1bbf6048d60cbb11b36745b5f0dd25d7c9afe913 Mon Sep 17 00:00:00 2001 From: jona159 Date: Wed, 10 Sep 2025 08:42:26 +0200 Subject: [PATCH 11/15] feat: adjust for zod schema --- app/lib/devices-service.server.ts | 92 +++++++++++++---- app/routes/api.devices.ts | 161 +++++++----------------------- tests/routes/api.devices.spec.ts | 2 +- 3 files changed, 111 insertions(+), 144 deletions(-) diff --git a/app/lib/devices-service.server.ts b/app/lib/devices-service.server.ts index 053c2ccf..0871a4e9 100644 --- a/app/lib/devices-service.server.ts +++ b/app/lib/devices-service.server.ts @@ -3,24 +3,82 @@ import { deleteDevice as deleteDeviceById, } from '~/models/device.server' import { verifyLogin } from '~/models/user.server' +import { z } from 'zod' -export interface BoxesQueryParams { - name?: string - limit?: string - date?: string[] - phenomenon?: string - format?: 'json' | 'geojson' - grouptag?: string - model?: string - minimal?: 'true' | 'false' - full?: 'true' | 'false' - near?: string - maxDistance?: string - bbox?: string - exposure?: string - fromDate: any - toDate: any -} +export const BoxesQuerySchema = z.object({ + format: z.enum(["json", "geojson"] ,{ + errorMap: () => ({ message: "Format must be either 'json' or 'geojson'" }), + }).default("json"), + minimal: z.enum(["true", "false"]).default("false") + .transform((v) => v === "true"), + full: z.enum(["true", "false"]).default("false") + .transform((v) => v === "true"), + limit: z + .string() + .default("5") + .transform((val) => parseInt(val, 10)) + .refine((val) => !isNaN(val), { message: "Limit must be a number" }) + .refine((val) => val >= 1, { message: "Limit must be at least 1" }) + .refine((val) => val <= 20, { message: "Limit must not exceed 20" }), + + name: z.string().optional(), + date: z + .union([z.string().datetime(), z.array(z.string().datetime())]) + .transform((val) => (Array.isArray(val) ? val : [val])) + .refine((arr) => arr.length >= 1 && arr.length <= 2, { + message: "Date must contain 1 or 2 timestamps", + }) + .optional(), + phenomenon: z.string().optional(), + grouptag: z.string().transform((v) => [v]).optional(), + model: z.string().transform((v) => [v]).optional(), + exposure: z.string().transform((v) => [v]).optional(), + + near: z + .string() + .regex(/^[-+]?\d+(\.\d+)?,[-+]?\d+(\.\d+)?$/, { + message: "Invalid 'near' parameter format. Expected: 'lat,lng'", + }) + .transform((val) => val.split(",").map(Number) as [number, number]) + .optional(), + + maxDistance: z.string().transform((v) => Number(v)).optional(), + + bbox: z + .string() + .transform((val) => { + const coords = val.split(",").map(Number); + if (coords.length !== 4 || coords.some((n) => isNaN(n))) { + throw new Error("Invalid bbox parameter"); + } + const [swLng, swLat, neLng, neLat] = coords; + return { + coordinates: [ + [ + [swLat, swLng], + [neLat, swLng], + [neLat, neLng], + [swLat, neLng], + [swLat, swLng], + ], + ], + }; + }) + .optional(), + + fromDate: z.string().datetime().transform((v) => new Date(v)).optional(), + toDate: z.string().datetime().transform((v) => new Date(v)).optional(), + }).refine( + (data) => + !(data.date && !data.phenomenon) && !(data.phenomenon && !data.date), + { + message: "Date and phenomenon must be used together", + path: ["date"], + } + ); + + + export type BoxesQueryParams = z.infer; /** * Deletes a device after verifiying that the user is entitled by checking diff --git a/app/routes/api.devices.ts b/app/routes/api.devices.ts index 4cb2caa5..f9aae3b1 100644 --- a/app/routes/api.devices.ts +++ b/app/routes/api.devices.ts @@ -11,6 +11,7 @@ import { import { ActionFunctionArgs } from 'react-router' import { getUserFromJwt } from '~/lib/jwt' import { + BoxesQuerySchema, deleteDevice, type BoxesQueryParams, } from '~/lib/devices-service.server' @@ -355,142 +356,50 @@ function fromToTimeParamsSanityCheck(fromDate: Date | null, toDate: Date | null) */ export async function loader({ request }: LoaderFunctionArgs) { const url = new URL(request.url); + const queryObj = Object.fromEntries(url.searchParams); const max_limit = 20; const { fromDate, toDate } = parseAndValidateTimeParams(url.searchParams); - const searchParams = { - format: 'json', - minimal: 'false', - full: 'false', - limit: '5', - ...Object.fromEntries(url.searchParams), - ...(fromDate && { fromDate: fromDate.toISOString() }), - ...(toDate && { toDate: toDate.toISOString() }) - } as BoxesQueryParams; - - const validFormats: BoxesQueryParams['format'][] = ['geojson', 'json']; - - if (searchParams.format) { - if (!validFormats.includes(searchParams.format)) { - console.error('Error in loader:', 'invalid format parameter'); - throw json({ error: 'Failed to fetch devices' }, { status: 422 }); - } - - if (searchParams.format === 'json') { - try { - if (searchParams.date) { - // TODO: handle date param - // let result = await getDevicesWithSensorsJson() - } else { - const findDevicesOpts: FindDevicesOptions = { - minimal: searchParams.minimal, - limit: searchParams.limit ? parseInt(searchParams.limit) : 5, - name: searchParams.name, - phenomenon: searchParams.phenomenon, - fromDate: searchParams.fromDate ? new Date(searchParams.fromDate) : undefined, - toDate: searchParams.toDate ? new Date(searchParams.toDate) : undefined, - grouptag: searchParams.grouptag ? [searchParams.grouptag] : undefined, - exposure: searchParams.exposure ? [searchParams.exposure as DeviceExposureType] : undefined, - }; - - if (findDevicesOpts.limit) { - if(findDevicesOpts.limit < 1){ - throw json({ error: 'Limit must be at least 1' }, { status: 422 }); - } - else if (findDevicesOpts.limit > max_limit) { - throw json({ error: 'Limit should not exceed 20' }, { status: 422 }); - } - } - if (searchParams.near) { - const nearCoords = searchParams.near.split(','); - if ( - nearCoords.length !== 2 || - isNaN(Number(nearCoords[0])) || - isNaN(Number(nearCoords[1])) - ) { - throw json( - { error: "Invalid 'near' parameter format. Expected: 'lat,lng'" }, - { status: 422 } - ); - } + const parseResult = BoxesQuerySchema.safeParse(queryObj); + if (!parseResult.success) { + const { fieldErrors, formErrors } = parseResult.error.flatten(); + if (fieldErrors.format) { + throw json( + { error: "Invalid format parameter" }, + { status: 422 } + ); + } - const [nearLat, nearLng] = nearCoords.map(Number); - findDevicesOpts.near = [nearLat, nearLng]; - findDevicesOpts.maxDistance = searchParams.maxDistance - ? Number(searchParams.maxDistance) - : 1000; - } + throw json( + { error: parseResult.error.flatten() }, + { status: 422 } + ); + } - if (searchParams.bbox) { - try { - const bboxCoords = searchParams.bbox.split(',').map(Number); - if (bboxCoords.length === 4) { - const [swLng, swLat, neLng, neLat] = bboxCoords; - findDevicesOpts.bbox = { - coordinates: [[[swLat, swLng], [neLat, swLng], [neLat, neLng], [swLat, neLng], [swLat, swLng]]] - }; - } - } catch (error) { - console.warn('Invalid bbox parameter:', searchParams.bbox); - } - } + const params: FindDevicesOptions = parseResult.data; - const result = await findDevices(findDevicesOpts); + const devices = await findDevices(params) - return result; - } - } catch (error) { - console.error('Error in loader:', error); - throw json({ error: 'Failed to fetch devices' }, { status: 500 }); - } - } - - if (searchParams.format === 'geojson') { - try { - const findDevicesOpts: FindDevicesOptions = { - minimal: searchParams.minimal, - limit: searchParams.limit ? parseInt(searchParams.limit) : 5, - name: searchParams.name, - }; - - if(!findDevicesOpts.limit){ - throw json({ error: 'Limit must be at least 1' }, { status: 422 }); - } - if (findDevicesOpts.limit) { - if(findDevicesOpts.limit < 1){ - throw json({ error: 'Limit must be at least 1' }, { status: 422 }); - } - else if (findDevicesOpts.limit > max_limit) { - throw json({ error: 'Limit should not exceed 20' }, { status: 422 }); - } + if (params.format === "geojson"){ + const geojson = { + type: 'FeatureCollection', + features: devices.map((device: Device) => ({ + type: 'Feature', + geometry: { + type: 'Point', + coordinates: [device.longitude, device.latitude] + }, + properties: { + ...device } + })) + }; - const devices = await findDevices(findDevicesOpts); - - const geojson = { - type: 'FeatureCollection', - features: devices.map((device: Device) => ({ - type: 'Feature', - geometry: { - type: 'Point', - coordinates: [device.longitude, device.latitude] - }, - properties: { - ...device - } - })) - }; - - return geojson; - } catch (error) { - console.error('Error in loader:', error); - throw json({ error: 'Failed to fetch devices' }, { status: 500 }); - } - } + return geojson; + } + else { + return devices } - - // Default fallback - throw json({ error: 'Invalid request' }, { status: 400 }); } export async function action({ request, params }: ActionFunctionArgs) { diff --git a/tests/routes/api.devices.spec.ts b/tests/routes/api.devices.spec.ts index dd043bd3..76179882 100644 --- a/tests/routes/api.devices.spec.ts +++ b/tests/routes/api.devices.spec.ts @@ -233,7 +233,7 @@ describe('openSenseMap API Routes: /boxes', () => { expect((error as Response).status).toBe(422) const errorData = await (error as Response).json() - expect(errorData.error).toBe('Failed to fetch devices') + expect(errorData.error).toBe('Invalid format parameter') } }) From 38c763fec4bf71a51c96482002f24292a2bd6231 Mon Sep 17 00:00:00 2001 From: jona159 Date: Wed, 10 Sep 2025 08:43:00 +0200 Subject: [PATCH 12/15] feat: add drizzle check --- package.json | 1 + 1 file changed, 1 insertion(+) diff --git a/package.json b/package.json index 7bc872c4..ab01b57a 100644 --- a/package.json +++ b/package.json @@ -13,6 +13,7 @@ "test": "vitest", "test:ui": "vitest --ui", "test:coverage": "vitest run --coverage", + "db:check": "drizzle-kit check", "db:push": "drizzle-kit push", "db:generate": "drizzle-kit generate", "db:migrate": "drizzle-kit migrate", From 0e60abbceca87bbdbce4c2d8a8b792f55a47b39f Mon Sep 17 00:00:00 2001 From: jona159 Date: Wed, 10 Sep 2025 14:58:45 +0200 Subject: [PATCH 13/15] feat: add phenomenon and dates to where clause --- app/models/device.server.ts | 42 ++++++++++++++++++++++++++++--------- 1 file changed, 32 insertions(+), 10 deletions(-) diff --git a/app/models/device.server.ts b/app/models/device.server.ts index b42539e6..ea80a55b 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, inArray, arrayContains } from 'drizzle-orm' +import { eq, sql, desc, ilike, inArray, arrayContains, and } from 'drizzle-orm' import { type Point } from 'geojson' import { drizzleClient } from '~/db.server' import { device, location, sensor, type Device, type Sensor } from '~/schema' @@ -235,6 +235,7 @@ interface BuildWhereClauseOptions { export interface FindDevicesOptions extends BuildWhereClauseOptions { minimal?: string | boolean; limit?: number; + format?: "json" | "geojson" } interface WhereClauseResult { @@ -253,11 +254,13 @@ interface BuildWhereClauseOptions { clause.push(ilike(device.name, `%${name}%`)); } - // if (phenomenon) { - // columns['sensors'] = { - // where: (sensor, { ilike }) => ilike(sensorTable['title'], `%${phenomenon}%`) - // }; - // } + if (phenomenon) { + // @ts-ignore + columns['sensors'] = { + // @ts-ignore + where: (sensor, { ilike }) => ilike(sensorTable['title'], `%${phenomenon}%`) + }; + } // simple string parameters // for (const param of ['exposure', 'model'] as const) { @@ -292,11 +295,30 @@ interface BuildWhereClauseOptions { ); } - if (fromDate || toDate) { - if (phenomenon) { - // TODO: implement + if (phenomenon && (fromDate || toDate)) { + // @ts-ignore + columns["sensors"] = { + include: { + measurements: { + where: (measurement: any) => { + const conditions = []; + + if (fromDate && toDate) { + conditions.push( + sql`${measurement.createdAt} BETWEEN ${fromDate} AND ${toDate}` + ); + } else if (fromDate) { + conditions.push(sql`${measurement.createdAt} >= ${fromDate}`); + } else if (toDate) { + conditions.push(sql`${measurement.createdAt} <= ${toDate}`); + } + + return and(...conditions); + }, + }, + }, + }; } - } return { includeColumns: columns, From c3d849b7a95820976111052f475764efe048f52d Mon Sep 17 00:00:00 2001 From: jona159 Date: Wed, 1 Oct 2025 14:00:13 +0200 Subject: [PATCH 14/15] fix: tests and validation schema --- app/lib/devices-service.server.ts | 50 ++- app/models/device.server.ts | 2 +- app/routes/api.devices.ts | 48 --- tests/routes/api.devices.spec.ts | 488 ++++++++++++++---------------- 4 files changed, 263 insertions(+), 325 deletions(-) diff --git a/app/lib/devices-service.server.ts b/app/lib/devices-service.server.ts index 0871a4e9..dd7120e3 100644 --- a/app/lib/devices-service.server.ts +++ b/app/lib/devices-service.server.ts @@ -22,13 +22,32 @@ export const BoxesQuerySchema = z.object({ .refine((val) => val <= 20, { message: "Limit must not exceed 20" }), name: z.string().optional(), - date: z - .union([z.string().datetime(), z.array(z.string().datetime())]) - .transform((val) => (Array.isArray(val) ? val : [val])) - .refine((arr) => arr.length >= 1 && arr.length <= 2, { - message: "Date must contain 1 or 2 timestamps", - }) - .optional(), + date: z.preprocess( + (val) => { + if (typeof val === "string") return [val]; + if (Array.isArray(val)) return val; + return val; + }, + z.array(z.string()) + .min(1, "At least one date required") + .max(2, "At most two dates allowed") + .transform((arr) => { + const [fromDateStr, toDateStr] = arr; + const fromDate = new Date(fromDateStr); + if (isNaN(fromDate.getTime())) throw new Error(`Invalid date: ${fromDateStr}`); + + if (!toDateStr) { + return { + fromDate: new Date(fromDate.getTime() - 4 * 60 * 60 * 1000), + toDate: new Date(fromDate.getTime() + 4 * 60 * 60 * 1000), + }; + } + + const toDate = new Date(toDateStr); + if (isNaN(toDate.getTime())) throw new Error(`Invalid date: ${toDateStr}`); + return { fromDate, toDate }; + }) + ).optional(), phenomenon: z.string().optional(), grouptag: z.string().transform((v) => [v]).optional(), model: z.string().transform((v) => [v]).optional(), @@ -68,14 +87,15 @@ export const BoxesQuerySchema = z.object({ fromDate: z.string().datetime().transform((v) => new Date(v)).optional(), toDate: z.string().datetime().transform((v) => new Date(v)).optional(), - }).refine( - (data) => - !(data.date && !data.phenomenon) && !(data.phenomenon && !data.date), - { - message: "Date and phenomenon must be used together", - path: ["date"], - } - ); + }) +// .refine( +// (data) => +// !(data.date && !data.phenomenon) && !(data.phenomenon && !data.date), +// { +// message: "Date and phenomenon must be used together", +// path: ["date"], +// } +// ); export type BoxesQueryParams = z.infer; diff --git a/app/models/device.server.ts b/app/models/device.server.ts index ea80a55b..f2c66c10 100644 --- a/app/models/device.server.ts +++ b/app/models/device.server.ts @@ -357,7 +357,7 @@ interface BuildWhereClauseOptions { const { minimal, limit } = opts; const { includeColumns, whereClause } = buildWhereClause(opts); - columns = (minimal === 'true') ? MINIMAL_COLUMNS : { ...DEFAULT_COLUMNS, ...columns }; + columns = minimal ? MINIMAL_COLUMNS : { ...DEFAULT_COLUMNS, ...columns }; relations = { ...relations, diff --git a/app/routes/api.devices.ts b/app/routes/api.devices.ts index f9aae3b1..74151627 100644 --- a/app/routes/api.devices.ts +++ b/app/routes/api.devices.ts @@ -36,53 +36,6 @@ function fromToTimeParamsSanityCheck(fromDate: Date | null, toDate: Date | null) } } - function parseAndValidateTimeParams(searchParams: URLSearchParams) { - const dateParams = searchParams.getAll('date'); - - if (dateParams.length === 0) { - return { fromDate: null, toDate: null }; - } - - if (dateParams.length > 2) { - throw json( - { error: 'invalid number of dates for date parameter supplied' }, - { status: 422 } - ); - } - - const [fromDateStr, toDateStr] = dateParams; - - const fromDate = new Date(fromDateStr); - if (isNaN(fromDate.getTime())) { - throw json( - { error: `Invalid date format: ${fromDateStr}` }, - { status: 422 } - ); - } - - let toDate: Date; - - if (!toDateStr) { - // If only one date provided, create a range of ±4 hours - toDate = new Date(fromDate.getTime() + (4 * 60 * 60 * 1000)); // +4 hours - const adjustedFromDate = new Date(fromDate.getTime() - (4 * 60 * 60 * 1000)); // -4 hours - - return { fromDate: adjustedFromDate, toDate }; - } else { - toDate = new Date(toDateStr); - if (isNaN(toDate.getTime())) { - throw json( - { error: `Invalid date format: ${toDateStr}` }, - { status: 422 } - ); - } - - fromToTimeParamsSanityCheck(fromDate, toDate); - - return { fromDate, toDate }; - } - } - /** * @openapi * /api/devices: @@ -358,7 +311,6 @@ export async function loader({ request }: LoaderFunctionArgs) { const url = new URL(request.url); const queryObj = Object.fromEntries(url.searchParams); const max_limit = 20; - const { fromDate, toDate } = parseAndValidateTimeParams(url.searchParams); const parseResult = BoxesQuerySchema.safeParse(queryObj); if (!parseResult.success) { diff --git a/tests/routes/api.devices.spec.ts b/tests/routes/api.devices.spec.ts index 76179882..0ffbd09d 100644 --- a/tests/routes/api.devices.spec.ts +++ b/tests/routes/api.devices.spec.ts @@ -49,7 +49,7 @@ describe('openSenseMap API Routes: /boxes', () => { jwt = t queryableDevice = await createDevice( - { ...generateMinimalDevice(), latitude: 123, longitude: 12 }, + { ...generateMinimalDevice(), latitude: 123, longitude: 12, tags: ["newgroup"] }, (testUser as User).id, ) }) @@ -66,7 +66,7 @@ describe('openSenseMap API Routes: /boxes', () => { ) // Act - const response = await devicesLoader({ + const response: any = await devicesLoader({ request: request, } as LoaderFunctionArgs) @@ -86,7 +86,7 @@ describe('openSenseMap API Routes: /boxes', () => { ) // Act - const response = await devicesLoader({ + const response: any = await devicesLoader({ request: request, } as LoaderFunctionArgs) @@ -131,75 +131,104 @@ describe('openSenseMap API Routes: /boxes', () => { }).rejects.toThrow() }) - // it('should allow to request minimal boxes', async () => { - // // Arrange - // const request = new Request( - // `${BASE_URL}?minimal=true`, - // { - // method: 'GET', - // headers: { 'Content-Type': 'application/json' }, - // }, - // ) - - // const response = await devicesLoader({ - // request: request, - // } as LoaderFunctionArgs) - - // expect(response).toBeDefined() - // expect(Array.isArray(response?.features)).toBe(true) - // // return chakram.get(`${BASE_URL}/boxes?minimal=true`) - // // .then(function (response) { - // // expect(response).to.have.status(200); - // // expect(response).to.have.header('content-type', 'application/json; charset=utf-8'); - // // expect(Array.isArray(response.body)).to.be.true; - // // expect(response.body.length).to.be.equal(boxCount); - // // for (const box of response.body) { - // // expect(Object.keys(box)) - // // .to.not.include('loc') - // // .and.to.not.include('locations') - // // .and.not.include('weblink') - // // .and.not.include('image') - // // .and.not.include('description') - // // .and.not.include('model') - // // .and.not.include('sensors'); - // // } - - // // return chakram.wait(); - // // }); - // }); - - // it('should return the correct count and correct schema of boxes for /boxes GET with date parameter', async () => { - // const tenDaysAgoIso = new Date(Date.now() - 10 * 24 * 60 * 60 * 1000).toISOString(); - - // // Arrange - // const request = new Request( - // `${BASE_URL}?format=geojson&date=${tenDaysAgoIso}`, - // { - // method: 'GET', - // headers: { 'Content-Type': 'application/json' }, - // }, - // ) - - // const response = await devicesLoader({ - // request: request, - // } as LoaderFunctionArgs) - - // expect(response).toBeDefined() - - // // return chakram.get(`${BASE_URL}/boxes?date=${ten_days_ago.toISOString()}`) - // // .then(function (response) { - // // expect(response).to.have.status(200); - // // expect(Array.isArray(response.body)).to.be.true; - // // expect(response.body.length).to.be.equal(1); - // // expect(response).to.have.header('content-type', 'application/json; charset=utf-8'); - // // expect(response).to.have.schema(findAllSchema); - // // expect(response.body[0].sensors.some(function (sensor) { - // // return moment.utc(sensor.lastMeasurement.createdAt).diff(ten_days_ago) < 10; - // // })).to.be.true; - - // // return chakram.wait(); - // // }); - // }); + it('should allow to request minimal boxes', async () => { + // Arrange + const request = new Request(`${BASE_URL}?minimal=true&format=geojson`, { + method: 'GET', + headers: { 'Content-Type': 'application/json' }, + }) + + // Act + const response: any = await devicesLoader({ + request: request, + } as LoaderFunctionArgs) + + // Assert + expect(response).toBeDefined() + expect(response.type).toBe('FeatureCollection') + expect(Array.isArray(response?.features)).toBe(true) + + if (response.features.length > 0) { + const feature = response.features[0] + expect(feature.type).toBe('Feature') + expect(feature.properties).toBeDefined() + + // Should have minimal fields + const props = feature.properties + expect(props?._id || props?.id).toBeDefined() + expect(props?.name).toBeDefined() + + // Should NOT include these fields in minimal mode + expect(props?.loc).toBeUndefined() + expect(props?.locations).toBeUndefined() + expect(props?.weblink).toBeUndefined() + expect(props?.image).toBeUndefined() + expect(props?.description).toBeUndefined() + expect(props?.model).toBeUndefined() + expect(props?.sensors).toBeUndefined() + } + }) + + it('should return the correct count and correct schema of boxes for /boxes GET with date parameter', async () => { + const tenDaysAgoIso = new Date( + Date.now() - 10 * 24 * 60 * 60 * 1000, + ).toISOString() + + // Arrange + const request = new Request( + `${BASE_URL}?format=geojson&date=${tenDaysAgoIso}`, + { + method: 'GET', + headers: { 'Content-Type': 'application/json' }, + }, + ) + + // Act + const response: any = await devicesLoader({ + request: request, + } as LoaderFunctionArgs) + + // Assert + expect(response).toBeDefined() + expect(response.type).toBe('FeatureCollection') + expect(Array.isArray(response?.features)).toBe(true) + + // Verify that returned boxes have sensor measurements after the specified date + if (response.features.length > 0) { + response.features.forEach((feature: any) => { + expect(feature.type).toBe('Feature') + expect(feature.properties).toBeDefined() + + // If the box has sensors with measurements, they should be after the date + if ( + feature.properties?.sensors && + Array.isArray(feature.properties.sensors) + ) { + const hasRecentMeasurement = feature.properties.sensors.some( + (sensor: any) => { + if (sensor.lastMeasurement?.createdAt) { + const measurementDate = new Date( + sensor.lastMeasurement.createdAt, + ) + const filterDate = new Date(tenDaysAgoIso) + return measurementDate >= filterDate + } + return false + }, + ) + + // If there are sensors with lastMeasurement, at least one should be recent + if ( + feature.properties.sensors.some( + (s: any) => s.lastMeasurement?.createdAt, + ) + ) { + expect(hasRecentMeasurement).toBe(true) + } + } + }) + } + }) it('should reject filtering boxes near a location with wrong parameter values', async () => { // Arrange @@ -245,7 +274,7 @@ describe('openSenseMap API Routes: /boxes', () => { }) // Act - const geojsonData = await devicesLoader({ + const geojsonData: any = await devicesLoader({ request: request, } as LoaderFunctionArgs) @@ -270,67 +299,67 @@ describe('openSenseMap API Routes: /boxes', () => { it('should allow to filter boxes by grouptag', async () => { // Arrange const request = new Request(`${BASE_URL}?grouptag=newgroup`, { - method: 'GET', - headers: { 'Content-Type': 'application/json' }, - }) + method: 'GET', + headers: { 'Content-Type': 'application/json' }, + }); + + // Act + const response = await devicesLoader({ request } as LoaderFunctionArgs); + + // Handle case where loader returned a Response (e.g. validation error) + const data = response instanceof Response ? await response.json() : response; + + expect(data).toBeDefined(); + expect(Array.isArray(data)).toBe(true); + + expect(data.length).toBe(1); + + if (response instanceof Response) { + expect(response.status).toBe(200); + expect(response.headers.get('content-type')).toMatch(/application\/json/); + } + }); + + + it('should allow filtering boxes by bounding box', async () => { + // Arrange + const request = new Request( + `${BASE_URL}?format=geojson&bbox=120,60,121,61`, + { + method: 'GET', + headers: { 'Content-Type': 'application/json' }, + }, + ) - const response = await devicesLoader({ + // Act + const response: any = await devicesLoader({ request: request, } as LoaderFunctionArgs) expect(response).toBeDefined() - expect(response?.length).toBe(0) - - // return chakram.get(`${BASE_URL}/grouptag=newgroup`) - // .then(function (response) { - // expect(response).to.have.status(200); - // expect(response).to.have.header('content-type', 'application/json; charset=utf-8'); - // expect(Array.isArray(response.body)).to.be.true; - // expect(response.body.length).to.be.equal(2); - // return chakram.wait(); - // }); + if (response) { + // Assert + expect(response.type).toBe('FeatureCollection') + expect(Array.isArray(response.features)).toBe(true) + + if (response.features.length > 0) { + response.features.forEach((feature: any) => { + expect(feature.type).toBe('Feature') + expect(feature.geometry).toBeDefined() + expect(feature.geometry.coordinates).toBeDefined() + + const [longitude, latitude] = feature.geometry.coordinates + + // Verify coordinates are within the bounding box [120,60,121,61] + expect(longitude).toBeGreaterThanOrEqual(120) + expect(longitude).toBeLessThanOrEqual(121) + expect(latitude).toBeGreaterThanOrEqual(60) + expect(latitude).toBeLessThanOrEqual(61) + }) + } + } }) - - // it('should allow filtering boxes by bounding box', async () => { - // // Arrange - // const request = new Request( - // `${BASE_URL}?format=geojson&bbox=120,60,121,61`, - // { - // method: 'GET', - // headers: { 'Content-Type': 'application/json' }, - // }, - // ) - - // // Act - // const response = await devicesLoader({ - // request: request, - // } as LoaderFunctionArgs) - - // expect(response).toBeDefined() - - // if (response) { - // // Assert - // expect(response.type).toBe('FeatureCollection') - // expect(Array.isArray(response.features)).toBe(true) - - // if (response.features.length > 0) { - // response.features.forEach((feature: any) => { - // expect(feature.type).toBe('Feature') - // expect(feature.geometry).toBeDefined() - // expect(feature.geometry.coordinates).toBeDefined() - - // const [longitude, latitude] = feature.geometry.coordinates - - // // Verify coordinates are within the bounding box [120,60,121,61] - // expect(longitude).toBeGreaterThanOrEqual(120) - // expect(longitude).toBeLessThanOrEqual(121) - // expect(latitude).toBeGreaterThanOrEqual(60) - // expect(latitude).toBeLessThanOrEqual(61) - // }) - // } - // } - // }) }) describe('POST', () => { @@ -400,62 +429,77 @@ describe('openSenseMap API Routes: /boxes', () => { expect(diffInMs).toBeLessThan(300000) // 5 minutes in milliseconds }) - // it('should reject a new box with invalid coords', async () => { - // // Arrange - // const requestBody = minimalSensebox([52]) // Invalid: missing longitude - - // const request = new Request(BASE_URL, { - // method: 'POST', - // headers: { Authorization: `Bearer ${jwt}` }, - // body: JSON.stringify(requestBody), - // }) - - // try { - // await devicesAction({ - // request: request, - // } as ActionFunctionArgs) - // fail('Expected action to throw an error') - // } catch (error) { - // if (error instanceof Response) { - // expect(error.status).toBe(422) - // const errorData = await error.json() - // expect(errorData.message).toBe( - // 'Illegal value for parameter location. missing latitude or longitude in location [52]', - // ) - // } else { - // throw error - // } - // } - // }) - - // it('should reject a new box without location field', async () => { - // // Arrange - // const requestBody = minimalSensebox() - // delete requestBody.location - - // const request = new Request(BASE_URL, { - // method: 'POST', - // headers: { Authorization: `Bearer ${jwt}` }, - - // body: JSON.stringify(requestBody), - // }) - - // // Act & Assert - // try { - // await devicesAction({ - // request: request, - // } as ActionFunctionArgs) - // fail('Expected action to throw an error') - // } catch (error) { - // if (error instanceof Response) { - // expect(error.status).toBe(400) - // const errorData = await error.json() - // expect(errorData.message).toBe('missing required parameter location') - // } else { - // throw error - // } - // } - // }) + it('should reject a new box with invalid coords', async () => { + function minimalSensebox(coords: number[]) { + return { + name: "Test Box", + location: coords, + sensors: [], + }; + } + + const requestBody = minimalSensebox([52]); + + const request = new Request(BASE_URL, { + method: 'POST', + headers: { + Authorization: `Bearer ${jwt}`, + }, + body: JSON.stringify(requestBody), + }); + + try { + await devicesAction({ request } as ActionFunctionArgs); + } catch (error) { + if (error instanceof Response) { + expect(error.status).toBe(422); + + const errorData = await error.json(); + expect(errorData.message).toBe( + 'Illegal value for parameter location. missing latitude or longitude in location [52]' + ); + } else { + throw error; + } + } + }); + + + it('should reject a new box without location field', async () => { + // Arrange + function minimalSensebox(coords: number[]): {name: string, location?: number[], sensors: any[]} { + return { + name: "Test Box", + location: coords, + sensors: [], + }; + } + + const requestBody = minimalSensebox([52]); + delete requestBody.location + + const request = new Request(BASE_URL, { + method: 'POST', + headers: { Authorization: `Bearer ${jwt}` }, + + body: JSON.stringify(requestBody), + }) + + // Act & Assert + try { + await devicesAction({ + request: request, + } as ActionFunctionArgs) + } catch (error) { + if (error instanceof Response) { + expect(error.status).toBe(400) + const errorData = await error.json() + expect(errorData.message).toBe('missing required parameter location') + } else { + throw error + } + } + }) }) describe('/:deviceId', () => { @@ -610,81 +654,3 @@ describe('openSenseMap API Routes: /boxes', () => { await deleteUserByEmail(DEVICE_TEST_USER.email) }) }) - -// describe('openSenseMap API Routes: /boxes', () => { -// describe('GET /boxes', () => { -// it('should reject filtering boxes near a location with wrong parameter values', async () => { -// // Arrange -// const request = new Request(`${BASE_URL}?near=test,60`, { -// method: 'GET', -// headers: { 'Content-Type': 'application/json' }, -// }) - -// // Act & Assert -// await expect(async () => { -// await devicesLoader({ -// request: request, -// } as LoaderFunctionArgs) -// }).rejects.toThrow() -// }) - -// it('should return geojson format when requested', async () => { -// // Arrange -// const request = new Request(`${BASE_URL}?format=geojson`, { -// method: 'GET', -// headers: { 'Content-Type': 'application/json' }, -// }) - -// // Act -// const geojsonData = await devicesLoader({ -// request: request, -// } as LoaderFunctionArgs) - -// // Assert - this should always be GeoJSON since that's what the loader returns -// expect(geojsonData.type).toBe('FeatureCollection') -// expect(Array.isArray(geojsonData.features)).toBe(true) - -// if (geojsonData.features.length > 0) { -// expect(geojsonData.features[0].type).toBe('Feature') -// expect(geojsonData.features[0].geometry).toBeDefined() -// expect(geojsonData.features[0].properties).toBeDefined() -// } -// }) - -// it('should return minimal data when minimal=true', async () => { -// // Arrange -// const request = new Request(`${BASE_URL}?minimal=true`, { -// method: 'GET', -// headers: { 'Content-Type': 'application/json' }, -// }) - -// // Act -// const geojsonData = await devicesLoader({ -// request: request, -// } as LoaderFunctionArgs) - -// // Assert - working with GeoJSON FeatureCollection -// expect(geojsonData.type).toBe('FeatureCollection') -// expect(Array.isArray(geojsonData.features)).toBe(true) - -// if (geojsonData.features.length > 0) { -// const feature = geojsonData.features[0] -// expect(feature.type).toBe('Feature') -// expect(feature.properties).toBeDefined() - -// // Should have minimal fields in properties -// expect(feature.properties?._id || feature.properties?.id).toBeDefined() -// expect(feature.properties?.name).toBeDefined() -// expect(feature.properties?.exposure).toBeDefined() -// expect( -// feature.properties?.currentLocation || -// feature.properties?.location || -// feature.geometry, -// ).toBeDefined() - -// // Should not have full sensor data -// expect(feature.properties?.sensors).toBeUndefined() -// } -// }) -// }) -// }) From 5194fadc4602d58fc0a9c34e8b9e465c80f55c4d Mon Sep 17 00:00:00 2001 From: jona159 Date: Wed, 1 Oct 2025 16:15:32 +0200 Subject: [PATCH 15/15] fix: cast types as any temporarily --- app/components/device-detail/device-detail-box.tsx | 2 +- app/models/sensor.server.ts | 12 ++++++------ app/routes/explore.tsx | 2 +- 3 files changed, 8 insertions(+), 8 deletions(-) diff --git a/app/components/device-detail/device-detail-box.tsx b/app/components/device-detail/device-detail-box.tsx index c15df07f..f093c65d 100644 --- a/app/components/device-detail/device-detail-box.tsx +++ b/app/components/device-detail/device-detail-box.tsx @@ -107,7 +107,7 @@ export default function DeviceDetailBox() { const [sensors, setSensors] = useState(); useEffect(() => { - const sortedSensors = [...data.sensors].sort( + const sortedSensors = [...data.sensors as any].sort( (a, b) => (a.id as unknown as number) - (b.id as unknown as number), ); setSensors(sortedSensors); diff --git a/app/models/sensor.server.ts b/app/models/sensor.server.ts index 9e5f99ee..5d262f4d 100644 --- a/app/models/sensor.server.ts +++ b/app/models/sensor.server.ts @@ -71,16 +71,16 @@ export function getSensorsFromDevice(deviceId: Sensor["deviceId"]) { export async function getSensorsWithLastMeasurement( deviceId: Sensor["deviceId"], -): Promise; -export async function getSensorsWithLastMeasurement( - deviceId: Sensor["deviceId"], - sensorId: Sensor["id"], -): Promise; + sensorId?: Sensor["id"], + count?: number, +): Promise; + + export async function getSensorsWithLastMeasurement( deviceId: Sensor["deviceId"], sensorId: Sensor["id"] | undefined = undefined, count: number = 1, -) { +): Promise { const result = await drizzleClient.execute( sql`SELECT s.id, diff --git a/app/routes/explore.tsx b/app/routes/explore.tsx index b5035740..a315760e 100644 --- a/app/routes/explore.tsx +++ b/app/routes/explore.tsx @@ -330,7 +330,7 @@ export default function Explore() { var deviceLoc: any; let selectedDevice: any; if (deviceId) { - selectedDevice = devices.features.find( + selectedDevice = (devices as any).features.find( (device: any) => device.properties.id === deviceId, ); deviceLoc = [