From 99a4c15d0da6745c828526d96099fe1b2699a937 Mon Sep 17 00:00:00 2001 From: David Scheidt Date: Wed, 4 Mar 2026 11:30:41 +0100 Subject: [PATCH 1/7] refactor: rename api.device.$deviceId route --- app/routes/{api.device.$deviceId.ts => api.boxes.$deviceId.ts} | 0 tests/routes/api.device.feinstaub.spec.ts | 2 +- tests/routes/api.device.sensors.spec.ts | 2 +- tests/routes/api.devices.spec.ts | 2 +- 4 files changed, 3 insertions(+), 3 deletions(-) rename app/routes/{api.device.$deviceId.ts => api.boxes.$deviceId.ts} (100%) diff --git a/app/routes/api.device.$deviceId.ts b/app/routes/api.boxes.$deviceId.ts similarity index 100% rename from app/routes/api.device.$deviceId.ts rename to app/routes/api.boxes.$deviceId.ts diff --git a/tests/routes/api.device.feinstaub.spec.ts b/tests/routes/api.device.feinstaub.spec.ts index 376a638a..cc531c83 100644 --- a/tests/routes/api.device.feinstaub.spec.ts +++ b/tests/routes/api.device.feinstaub.spec.ts @@ -4,7 +4,7 @@ import { createToken } from '~/lib/jwt' import { registerUser } from '~/lib/user-service.server' import { createDevice, deleteDevice, getDevice } from '~/models/device.server' import { deleteUserByEmail } from '~/models/user.server' -import { action as deviceUpdateAction } from '~/routes/api.device.$deviceId' +import { action as deviceUpdateAction } from '~/routes/api.boxes.$deviceId' import { type User, type Device } from '~/schema' const TEST_USER = { diff --git a/tests/routes/api.device.sensors.spec.ts b/tests/routes/api.device.sensors.spec.ts index 93772720..16d028f0 100644 --- a/tests/routes/api.device.sensors.spec.ts +++ b/tests/routes/api.device.sensors.spec.ts @@ -4,7 +4,7 @@ import { createToken } from '~/lib/jwt' import { registerUser } from '~/lib/user-service.server' import { createDevice, deleteDevice, getDevice } from '~/models/device.server' import { deleteUserByEmail } from '~/models/user.server' -import { action as deviceUpdateAction } from '~/routes/api.device.$deviceId' +import { action as deviceUpdateAction } from '~/routes/api.boxes.$deviceId' import { type User, type Device } from '~/schema' const DEVICE_TEST_USER = { diff --git a/tests/routes/api.devices.spec.ts b/tests/routes/api.devices.spec.ts index e80439d6..bdd45d9e 100644 --- a/tests/routes/api.devices.spec.ts +++ b/tests/routes/api.devices.spec.ts @@ -12,7 +12,7 @@ import { deleteUserByEmail } from '~/models/user.server' import { loader as deviceLoader, action as deviceUpdateAction, -} from '~/routes/api.device.$deviceId' +} from '~/routes/api.boxes.$deviceId' import { loader as devicesLoader, action as devicesAction, From a0a2da8bc8264cb3e56f84062281a1a001d88083 Mon Sep 17 00:00:00 2001 From: David Scheidt Date: Wed, 4 Mar 2026 13:35:03 +0100 Subject: [PATCH 2/7] docs: enable boxes/boxid route --- app/routes/api.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/app/routes/api.ts b/app/routes/api.ts index 00ddc17f..d9916336 100644 --- a/app/routes/api.ts +++ b/app/routes/api.ts @@ -38,10 +38,10 @@ const routes: { noauth: RouteInfo[]; auth: RouteInfo[] } = { path: `boxes/data`, method: 'GET', }, - // { - // path: `boxes/:boxId`, - // method: "GET", - // }, + { + path: `boxes/:boxId`, + method: 'GET', + }, { path: `boxes/:boxId/sensors`, method: 'GET', From 195a8731e487ab11cb197db5534cc1a0ba5e0d20 Mon Sep 17 00:00:00 2001 From: David Scheidt Date: Wed, 4 Mar 2026 14:21:19 +0100 Subject: [PATCH 3/7] refactor: move GET /boxes to correct route file --- app/routes/api.boxes.ts | 55 ++++++++++++++++++++++++++++++++++--- app/routes/api.ts | 60 ----------------------------------------- 2 files changed, 51 insertions(+), 64 deletions(-) diff --git a/app/routes/api.boxes.ts b/app/routes/api.boxes.ts index 7a1533a0..781222fa 100644 --- a/app/routes/api.boxes.ts +++ b/app/routes/api.boxes.ts @@ -1,9 +1,17 @@ -import { type ActionFunction, type ActionFunctionArgs } from 'react-router' +import { + LoaderFunctionArgs, + type ActionFunction, + type ActionFunctionArgs, +} from 'react-router' import { transformDeviceToApiFormat } from '~/lib/device-transform' -import { CreateBoxSchema } from '~/lib/devices-service.server' +import { BoxesQuerySchema, CreateBoxSchema } from '~/lib/devices-service.server' import { getUserFromJwt } from '~/lib/jwt' -import { createDevice } from '~/models/device.server' -import { type User } from '~/schema' +import { + createDevice, + findDevices, + FindDevicesOptions, +} from '~/models/device.server' +import { Device, type User } from '~/schema' import { StandardResponse } from '~/utils/response-utils' /** @@ -323,6 +331,45 @@ import { StandardResponse } from '~/utils/response-utils' * type: string * example: "25.13" */ +export async function loader({ request }: LoaderFunctionArgs) { + const url = new URL(request.url) + const queryObj = Object.fromEntries(url.searchParams) + const parseResult = BoxesQuerySchema.safeParse(queryObj) + + if (!parseResult.success) { + const { fieldErrors } = parseResult.error.flatten() + if (fieldErrors.format) + throw StandardResponse.unprocessableContent('Invalid format parameter') + + throw StandardResponse.unprocessableContent( + `${parseResult.error.flatten()}`, + ) + } + + const params: FindDevicesOptions = parseResult.data + + const devices = await findDevices(params) + + 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, + }, + })), + } + + return geojson + } else { + return devices + } +} export const action: ActionFunction = async ({ request, diff --git a/app/routes/api.ts b/app/routes/api.ts index d9916336..802bff0d 100644 --- a/app/routes/api.ts +++ b/app/routes/api.ts @@ -20,16 +20,6 @@ const routes: { noauth: RouteInfo[]; auth: RouteInfo[] } = { path: '/tags', method: 'GET', }, - // { - // path: `statistics/idw`, - // method: "GET", - - // }, - // { - // path: `statistics/descriptive`, - // method: "GET", - - // }, { path: `boxes`, method: 'GET', @@ -50,18 +40,6 @@ const routes: { noauth: RouteInfo[]; auth: RouteInfo[] } = { path: `boxes/:boxId/sensors/:sensorId`, method: 'GET', }, - // { - // path: `boxes/:boxId/data/:sensorId`, - // method: "GET", - // }, - // { - // path: `boxes/:boxId/locations`, - // method: "GET", - // }, - // { - // path: `boxes/data`, - // method: "POST", - // }, { path: `boxes/:boxId/data`, method: 'POST', @@ -165,44 +143,6 @@ const routes: { noauth: RouteInfo[]; auth: RouteInfo[] } = { method: 'POST', }, ], - // management: [ - // { - // path: `${managementPath}/boxes`, - // method: "GET", - // }, - // { - // path: `${managementPath}/boxes/:boxId`, - // method: "GET", - // }, - // { - // path: `${managementPath}/boxes/:boxId`, - // method: "PUT", - // }, - // { - // path: `${managementPath}/boxes/delete`, - // method: "POST", - // }, - // { - // path: `${managementPath}/users`, - // method: "GET", - // }, - // { - // path: `${managementPath}/users/:userId`, - // method: "GET", - // }, - // { - // path: `${managementPath}/users/:userId`, - // method: "PUT", - // }, - // { - // path: `${managementPath}/users/delete`, - // method: "POST", - // }, - // { - // path: `${managementPath}/users/:userId/exec`, - // method: "POST", - // }, - // ], } export async function loader({}: LoaderFunctionArgs) { From e50f424f80dcdd6bfb37da5876c99cf910bd561e Mon Sep 17 00:00:00 2001 From: David Scheidt Date: Wed, 4 Mar 2026 14:56:28 +0100 Subject: [PATCH 4/7] refactor: move endpoints to the corrected positions --- app/routes/api.boxes.$deviceId.ts | 29 ++ app/routes/api.devices.ts | 442 ------------------------------ 2 files changed, 29 insertions(+), 442 deletions(-) delete mode 100644 app/routes/api.devices.ts diff --git a/app/routes/api.boxes.$deviceId.ts b/app/routes/api.boxes.$deviceId.ts index 663c68f6..83037642 100644 --- a/app/routes/api.boxes.$deviceId.ts +++ b/app/routes/api.boxes.$deviceId.ts @@ -1,5 +1,6 @@ import { type ActionFunctionArgs, type LoaderFunctionArgs } from 'react-router' import { transformDeviceToApiFormat } from '~/lib/device-transform' +import { deleteDevice } from '~/lib/devices-service.server' import { getUserFromJwt } from '~/lib/jwt' import { DeviceUpdateError, @@ -7,6 +8,7 @@ import { updateDevice, type UpdateDeviceArgs, } from '~/models/device.server' +import { type Device, type User } from '~/schema' import { StandardResponse } from '~/utils/response-utils' /** @@ -122,6 +124,8 @@ export async function action({ request, params }: ActionFunctionArgs) { switch (request.method) { case 'PUT': return await put(request, jwtResponse, deviceId) + case 'DELETE': + return await del(request, jwtResponse, deviceId) default: return Response.json({ message: 'Method Not Allowed' }, { status: 405 }) } @@ -252,3 +256,28 @@ async function put(request: Request, user: any, deviceId: string) { ) } } + +async function del(request: Request, user: User, deviceId: string) { + const device = (await getDevice({ id: deviceId })) as unknown as Device + + if (!device) throw StandardResponse.notFound('Device not found') + + const body = await request.json() + + if (!body.password) + throw StandardResponse.badRequest( + 'Password is required for device deletion', + ) + + try { + const deleted = await deleteDevice(user, device, body.password) + + if (deleted === 'unauthorized') + return StandardResponse.unauthorized('Password incorrect') + + return StandardResponse.ok(null) + } catch (err) { + console.warn(err) + return StandardResponse.internalServerError() + } +} diff --git a/app/routes/api.devices.ts b/app/routes/api.devices.ts deleted file mode 100644 index 5db6bf7d..00000000 --- a/app/routes/api.devices.ts +++ /dev/null @@ -1,442 +0,0 @@ -import { type LoaderFunctionArgs, type ActionFunctionArgs } from 'react-router' -import { BoxesQuerySchema, deleteDevice } from '~/lib/devices-service.server' -import { getUserFromJwt } from '~/lib/jwt' -import { - createDevice, - findDevices, - getDevice, - type FindDevicesOptions, -} from '~/models/device.server' -import { type Device, type User } from '~/schema' -import { StandardResponse } from '~/utils/response-utils' - -/** - * @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 queryObj = Object.fromEntries(url.searchParams) - const max_limit = 20 - const parseResult = BoxesQuerySchema.safeParse(queryObj) - - if (!parseResult.success) { - const { fieldErrors, formErrors } = parseResult.error.flatten() - if (fieldErrors.format) - throw StandardResponse.unprocessableContent('Invalid format parameter') - - throw StandardResponse.unprocessableContent( - `${parseResult.error.flatten()}`, - ) - } - - const params: FindDevicesOptions = parseResult.data - - const devices = await findDevices(params) - - 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, - }, - })), - } - - return geojson - } else { - return devices - } -} - -export async function action({ request, params }: ActionFunctionArgs) { - try { - const jwtResponse = await getUserFromJwt(request) - - if (typeof jwtResponse === 'string') - return StandardResponse.forbidden( - 'Invalid JWT authorization. Please sign in to obtain new JWT.', - ) - switch (request.method) { - case 'POST': - return await post(request, jwtResponse) - case 'DELETE': - return await del(request, jwtResponse, params) - default: - return StandardResponse.methodNotAllowed('Method Not Allowed') - } - } catch (err) { - console.warn(err) - return StandardResponse.internalServerError() - } -} - -async function del(request: Request, user: User, params: any) { - const { deviceId } = params - - if (!deviceId) throw StandardResponse.badRequest('Device ID is required') - - const device = (await getDevice({ id: deviceId })) as unknown as Device - - if (!device) throw StandardResponse.notFound('Device not found') - - const body = await request.json() - - if (!body.password) - throw StandardResponse.badRequest( - 'Password is required for device deletion', - ) - - try { - const deleted = await deleteDevice(user, device, body.password) - - if (deleted === 'unauthorized') - return StandardResponse.unauthorized('Password incorrect') - - return StandardResponse.ok(null) - } catch (err) { - console.warn(err) - return StandardResponse.internalServerError() - } -} - -async function post(request: Request, user: User) { - try { - const body = await request.json() - - if (!body.location) - throw StandardResponse.badRequest('missing required parameter location') - - 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 StandardResponse.unprocessableContent( - `Illegal value for parameter location. missing latitude or longitude in location [${body.location.join(',')}]`, - ) - - 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 StandardResponse.unprocessableContent( - 'Illegal value for parameter location. missing latitude or longitude', - ) - - latitude = Number(body.location.lat) - longitude = Number(body.location.lng) - height = body.location.height ? Number(body.location.height) : undefined - } else - throw StandardResponse.unprocessableContent( - 'Illegal value for parameter location. Expected array or object', - ) - - if (isNaN(latitude) || isNaN(longitude)) - throw StandardResponse.unprocessableContent( - 'Invalid latitude or longitude values', - ) - - const rawAuthorizationHeader = request.headers.get('authorization') - if (!rawAuthorizationHeader) - throw StandardResponse.unauthorized('Authorization header required') - - const deviceData = { - ...body, - latitude, - longitude, - } - - const newDevice = await createDevice(deviceData, user.id) - - return StandardResponse.created({ - data: { - ...newDevice, - createdAt: newDevice.createdAt || new Date(), - }, - }) - } catch (error) { - console.error('Error creating device:', error) - - if (error instanceof Response) { - throw error - } - - throw Response.json({ message: 'Internal server error' }, { status: 500 }) - } -} From 94e6f571eecbb4ad5fed3c0cdc35f72ef7f02e8e Mon Sep 17 00:00:00 2001 From: David Scheidt Date: Wed, 4 Mar 2026 15:42:26 +0100 Subject: [PATCH 5/7] fix: tests and rename api.devices.spec to api.boxes.$deviceId.spec --- app/lib/devices-service.server.ts | 23 +- tests/routes/api.boxes.$deviceId.spec.ts | 456 +++++++++++ tests/routes/api.boxes.spec.ts | 544 ++++++++++++- tests/routes/api.devices.spec.ts | 958 ----------------------- 4 files changed, 1011 insertions(+), 970 deletions(-) create mode 100644 tests/routes/api.boxes.$deviceId.spec.ts delete mode 100644 tests/routes/api.devices.spec.ts diff --git a/app/lib/devices-service.server.ts b/app/lib/devices-service.server.ts index 2dc4ff71..47fa024b 100644 --- a/app/lib/devices-service.server.ts +++ b/app/lib/devices-service.server.ts @@ -10,8 +10,27 @@ export const CreateBoxSchema = z.object({ .optional() .default('unknown'), location: z - .array(z.number()) - .length(2, 'Location must be [longitude, latitude]'), + .union([ + z + .array(z.number()) + .min( + 2, + 'Location must be [longitude, latitude, (height)] (height is optional)', + ) + .max( + 3, + 'Location must be [longitude, latitude, (height)] (height is optional)', + ), + z.object({ + lng: z.number(), + lat: z.number(), + height: z.number().optional(), + }), + ]) + .transform((loc) => { + if (Array.isArray(loc)) return loc + return [loc.lng, loc.lat, ...(loc.height ? [loc.height] : [])] + }), grouptag: z.array(z.string()).optional().default([]), model: z .enum([ diff --git a/tests/routes/api.boxes.$deviceId.spec.ts b/tests/routes/api.boxes.$deviceId.spec.ts new file mode 100644 index 00000000..18263382 --- /dev/null +++ b/tests/routes/api.boxes.$deviceId.spec.ts @@ -0,0 +1,456 @@ +import { + type AppLoadContext, + type LoaderFunctionArgs, + type ActionFunctionArgs, +} from 'react-router' +import { generateTestUserCredentials } from 'tests/data/generate_test_user' +import { BASE_URL } from 'vitest.setup' +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 { action as devicesAction } from '~/routes/api.boxes' +import { + loader as deviceLoader, + action as deviceUpdateAction, +} from '~/routes/api.boxes.$deviceId' +import { type User, type Device } from '~/schema' + +const DEVICE_TEST_USER = generateTestUserCredentials() + +const generateMinimalDevice = ( + location: number[] | {} = [123, 12, 34], + exposure = 'mobile', + name = 'senseBox' + new Date().getTime(), +) => ({ + exposure, + location, + name, + model: 'homeV2Ethernet', +}) + +describe('openSenseMap API Routes: /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, + tags: ['testgroup'], + useAuth: false, + }, + (testUser as User).id, + ) + }) + + describe('/:deviceId', () => { + describe('GET', () => { + let result: any + + beforeAll(async () => { + // Arrange + const request = new Request(`${BASE_URL}/${queryableDevice!.id}`, { + method: 'GET', + headers: { 'Content-Type': 'application/json' }, + }) + + // Act + const dataFunctionValue = await deviceLoader({ + request: request, + params: { deviceId: queryableDevice!.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(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 name and model', () => { + expect(result.name).toBe(queryableDevice?.name) + 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 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('PUT', () => { + it('should allow to update the device via PUT', async () => { + const update_payload = { + name: 'neuername', + exposure: 'indoor', + grouptag: 'testgroup', + description: 'total neue beschreibung', + location: { lat: 54.2, lng: 21.1 }, + weblink: 'http://www.google.de', + useAuth: true, + image: + 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVQYV2NgYAAAAAMAAWgmWQ0AAAAASUVORK5CYII=', + } + + const request = new Request(`${BASE_URL}/${queryableDevice?.id}`, { + method: 'PUT', + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${jwt}`, + }, + body: JSON.stringify(update_payload), + }) + + const response = await deviceUpdateAction({ + request, + params: { deviceId: queryableDevice?.id }, + context: {} as AppLoadContext, + } as ActionFunctionArgs) + const data = await response.json() + + expect(response.status).toBe(200) + expect(data.name).toBe(update_payload.name) + expect(data.exposure).toBe(update_payload.exposure) + expect(Array.isArray(data.grouptag)).toBe(true) + expect(data.grouptag).toContain(update_payload.grouptag) + expect(data.description).toBe(update_payload.description) + expect(data.access_token).not.toBeNull() + expect(data.currentLocation).toEqual({ + type: 'Point', + coordinates: [ + update_payload.location.lng, + update_payload.location.lat, + ], + timestamp: expect.any(String), + }) + + expect(data.loc).toEqual([ + { + type: 'Feature', + geometry: { + type: 'Point', + coordinates: [ + update_payload.location.lng, + update_payload.location.lat, + ], + timestamp: expect.any(String), + }, + }, + ]) + }) + + it('should allow to update the device via PUT with array as grouptags', async () => { + const update_payload = { + name: 'neuername', + exposure: 'outdoor', + grouptag: ['testgroup'], + description: 'total neue beschreibung', + location: { lat: 54.2, lng: 21.1 }, + weblink: 'http://www.google.de', + image: + 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVQYV2NgYAAAAAMAAWgmWQ0AAAAASUVORK5CYII=', + } + + const request = new Request(`${BASE_URL}/${queryableDevice?.id}`, { + method: 'PUT', + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${jwt}`, + }, + body: JSON.stringify(update_payload), + }) + + const response: any = await deviceUpdateAction({ + request, + params: { deviceId: queryableDevice?.id }, + context: {} as AppLoadContext, + } as ActionFunctionArgs) + + expect(response.status).toBe(200) + + const data = await response.json() + expect(data.name).toBe(update_payload.name) + expect(data.exposure).toBe(update_payload.exposure) + + expect(Array.isArray(data.grouptag)).toBe(true) + expect(data.grouptag).toEqual(update_payload.grouptag) + + expect(data.description).toBe(update_payload.description) + expect(data.currentLocation.coordinates).toEqual([ + update_payload.location.lng, + update_payload.location.lat, + ]) + expect(data.loc[0].geometry.coordinates).toEqual([ + update_payload.location.lng, + update_payload.location.lat, + ]) + + //TODO: this fails, check if we actually need timestamps in images + // const parts = data.image.split('_') + // const ts36 = parts[1].replace('.png', '') + // const tsMs = parseInt(ts36, 36) * 1000 + // expect(Date.now() - tsMs).toBeLessThan(1000) + }) + it('should remove image when deleteImage=true', async () => { + const update_payload = { + deleteImage: true, + } + + const request = new Request(`${BASE_URL}/${queryableDevice?.id}`, { + method: 'PUT', + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${jwt}`, + }, + body: JSON.stringify(update_payload), + }) + + const response = await deviceUpdateAction({ + request, + params: { deviceId: queryableDevice?.id }, + context: {} as AppLoadContext, + } as ActionFunctionArgs) + + expect(response.status).toBe(200) + const data = await response.json() + expect(data.image).toBeNull() + }) + + it('should nullify description when set to empty string', async () => { + const update_payload = { + description: '', + } + + const request = new Request(`${BASE_URL}/${queryableDevice?.id}`, { + method: 'PUT', + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${jwt}`, + }, + body: JSON.stringify(update_payload), + }) + + const response = await deviceUpdateAction({ + request, + params: { deviceId: queryableDevice?.id }, + context: {} as AppLoadContext, + } as ActionFunctionArgs) + + expect(response.status).toBe(200) + const data = await response.json() + expect(data.description).toBeNull() + }) + + it('should clear group tags when empty array provided', async () => { + const update_payload = { + grouptag: [], + } + + const request = new Request(`${BASE_URL}/${queryableDevice?.id}`, { + method: 'PUT', + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${jwt}`, + }, + body: JSON.stringify(update_payload), + }) + + const response = await deviceUpdateAction({ + request, + params: { deviceId: queryableDevice?.id }, + context: {} as AppLoadContext, + } as ActionFunctionArgs) + + expect(response.status).toBe(200) + const data = await response.json() + expect(data.grouptag).toHaveLength(0) + }) + + it('should merge addons.add into grouptags', async () => { + const update_payload = { + addons: { add: 'feinstaub' }, + grouptag: ['existinggroup'], + } + + const request = new Request(`${BASE_URL}/${queryableDevice?.id}`, { + method: 'PUT', + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${jwt}`, + }, + body: JSON.stringify(update_payload), + }) + + const response = await deviceUpdateAction({ + request, + params: { deviceId: queryableDevice?.id }, + context: {} as AppLoadContext, + } as ActionFunctionArgs) + + expect(response.status).toBe(200) + const data = await response.json() + + expect(Array.isArray(data.grouptag)).toBe(true) + expect(data.grouptag).toContain('existinggroup') + expect(data.grouptag).toContain('feinstaub') + }) + + it('should accept multi-valued grouptag array', async () => { + const update_payload = { + grouptag: ['tag1', 'tag2', 'tag3'], + } + + const request = new Request(`${BASE_URL}/${queryableDevice?.id}`, { + method: 'PUT', + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${jwt}`, + }, + body: JSON.stringify(update_payload), + }) + + const response = await deviceUpdateAction({ + request, + params: { deviceId: queryableDevice?.id }, + context: {} as AppLoadContext, + } as ActionFunctionArgs) + + expect(response.status).toBe(200) + const data = await response.json() + expect(data.grouptag).toEqual( + expect.arrayContaining(['tag1', 'tag2', 'tag3']), + ) + }) + }) + + describe('DELETE', () => { + let deletableDevice: Device | null = null + + beforeAll(async () => { + deletableDevice = await createDevice( + { ...generateMinimalDevice(), latitude: 123, longitude: 12 }, + user!.id, + ) + }) + + 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' }), + }, + ) + + const badDeleteResponse = await devicesAction({ + request: badDeleteRequest, + params: { deviceId: queryableDevice?.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.message).toBe('Password incorrect') + }) + + 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 }), + }, + ) + + const validDeleteResponse = await devicesAction({ + request: validDeleteRequest, + params: { deviceId: deletableDevice?.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 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' }, + }, + ) + + const getDeletedResponse = await deviceLoader({ + request: getDeletedRequest, + params: { deviceId: deletableDevice?.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', + ) + }) + }) + }) + + afterAll(async () => { + await deleteDevice({ id: queryableDevice!.id }) + await deleteUserByEmail(DEVICE_TEST_USER.email) + }) +}) diff --git a/tests/routes/api.boxes.spec.ts b/tests/routes/api.boxes.spec.ts index 2a8f764d..2b26ac0c 100644 --- a/tests/routes/api.boxes.spec.ts +++ b/tests/routes/api.boxes.spec.ts @@ -1,30 +1,54 @@ -import { type ActionFunctionArgs } from 'react-router' +import { type LoaderFunctionArgs, type ActionFunctionArgs } from 'react-router' import { generateTestUserCredentials } from 'tests/data/generate_test_user' import { BASE_URL } from 'vitest.setup' import { createToken } from '~/lib/jwt' import { registerUser } from '~/lib/user-service.server' -import { deleteDevice } from '~/models/device.server' +import { createDevice, deleteDevice } from '~/models/device.server' import { deleteUserByEmail } from '~/models/user.server' -import { action } from '~/routes/api.boxes' -import { type User } from '~/schema' - -const BOXES_POST_TEST_USER = generateTestUserCredentials() +import { loader, action } from '~/routes/api.boxes' +import { type Device, type User } from '~/schema' + +const BOXES_TEST_USER = generateTestUserCredentials() +const generateMinimalDevice = ( + location: number[] | {} = [123, 12, 34], + exposure = 'mobile', + name = 'senseBox' + new Date().getTime(), +) => ({ + exposure, + location, + name, + model: 'homeV2Ethernet', +}) describe('openSenseMap API Routes: /boxes', () => { let user: User | null = null let jwt: string = '' let createdDeviceIds: string[] = [] + let queryableDevice: Device | null = null + const grouptag = 'testgroup' + Math.random() beforeAll(async () => { const testUser = await registerUser( - BOXES_POST_TEST_USER.name, - BOXES_POST_TEST_USER.email, - BOXES_POST_TEST_USER.password, + BOXES_TEST_USER.name, + BOXES_TEST_USER.email, + BOXES_TEST_USER.password, 'en_US', ) user = testUser as User const { token } = await createToken(testUser as User) jwt = token + + queryableDevice = await createDevice( + { + ...generateMinimalDevice(), + latitude: 123, + longitude: 12, + tags: [grouptag], + useAuth: false, + }, + (testUser as User).id, + ) + createdDeviceIds.push(queryableDevice.id) }) afterAll(async () => { @@ -36,10 +60,366 @@ describe('openSenseMap API Routes: /boxes', () => { } } if (user) { - await deleteUserByEmail(BOXES_POST_TEST_USER.email) + await deleteUserByEmail(BOXES_TEST_USER.email) } }) + describe('GET', () => { + it('should search for boxes with a specific name and limit the results', async () => { + // Arrange + const request = new Request( + `${BASE_URL}?format=geojson&name=${queryableDevice?.name}&limit=2`, + { + method: 'GET', + headers: { 'Content-Type': 'application/json' }, + }, + ) + + // Act + const response: any = await loader({ + request: request, + } as LoaderFunctionArgs) + + expect(response).toBeDefined() + expect(Array.isArray(response?.features)).toBe(true) + expect(response?.features.length).lessThanOrEqual(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=${queryableDevice?.name}&limit=21`, + { + method: 'GET', + headers: { 'Content-Type': 'application/json' }, + }, + ) + + // Act + await expect(async () => { + await loader({ + 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 loader({ + request: request, + } as LoaderFunctionArgs) + }).rejects.toThrow() + }) + + 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 loader({ + 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 loader({ + 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 + const request = new Request(`${BASE_URL}?near=test,60`, { + method: 'GET', + headers: { 'Content-Type': 'application/json' }, + }) + + // Act & Assert + await expect(async () => { + await loader({ + 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 loader({ + request: request, + } as LoaderFunctionArgs) + expect(true).toBe(false) + } catch (error) { + expect(error).toBeInstanceOf(Response) + expect((error as Response).status).toBe(422) + + const errorData = await (error as Response).json() + expect(errorData.error).toBe('Invalid format parameter') + } + }) + + 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: any = await loader({ + request: request, + } as LoaderFunctionArgs) + + 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 to filter boxes by grouptag', async () => { + // Arrange + const request = new Request(`${BASE_URL}?grouptag=${grouptag}`, { + method: 'GET', + headers: { 'Content-Type': 'application/json' }, + }) + + // Act + const response = await loader({ 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).toHaveLength(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' }, + }, + ) + + // Act + const response: any = await loader({ + 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) + }) + } + } + }) + + 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 loader({ + 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 loader({ + request: request, + } as LoaderFunctionArgs) + expect(true).toBe(false) + } catch (error) { + expect(error).toBeInstanceOf(Response) + expect((error as Response).status).toBe(422) + + const errorData = await (error as Response).json() + expect(errorData.error).toBe('Invalid format parameter') + } + }) + + 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: any = await loader({ + request: request, + } as LoaderFunctionArgs) + + 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() + } + } + }) + }) + describe('POST', () => { it('should create a new box with sensors', async () => { const requestBody = { @@ -273,6 +653,150 @@ describe('openSenseMap API Routes: /boxes', () => { expect(body).toHaveProperty('grouptag') expect(body.grouptag).toEqual([]) }) + + it('should allow to set the location for a new box as array', async () => { + // Arrange + const loc = [0, 0, 0] + const requestBody = generateMinimalDevice(loc) + + const request = new Request(`${BASE_URL}/boxes`, { + method: 'POST', + headers: { + Authorization: `Bearer ${jwt}`, + 'Content-Type': 'application/json', + }, + body: JSON.stringify(requestBody), + }) + + // Act + const response = await action({ + request: request, + } as ActionFunctionArgs) + const responseData = await response.json() + await deleteDevice({ id: responseData._id }) + + // Assert + expect(response.status).toBe(201) + expect(responseData.latitude).toBeDefined() + expect(responseData.longitude).toBeDefined() + expect(responseData.latitude).toBe(loc[0]) + expect(responseData.longitude).toBe(loc[1]) + expect(responseData.createdAt).toBeDefined() + + // Check that createdAt is recent (within 5 minutes) + const now = new Date() + const createdAt = new Date(responseData.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 = generateMinimalDevice(loc) + + const request = new Request(BASE_URL, { + method: 'POST', + headers: { Authorization: `Bearer ${jwt}` }, + body: JSON.stringify(requestBody), + }) + + // Act + const response = await action({ + request: request, + } as ActionFunctionArgs) + const responseData = await response.json() + await deleteDevice({ id: responseData._id }) + + // Assert + expect(response.status).toBe(201) + expect(responseData.latitude).toBeDefined() + expect(responseData.latitude).toBe(loc.lat) + expect(responseData.longitude).toBeDefined() + expect(responseData.longitude).toBe(loc.lng) + expect(responseData.createdAt).toBeDefined() + + // Check that createdAt is recent (within 5 minutes) + const now = new Date() + const createdAt = new Date(responseData.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 () => { + 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 action({ 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 action({ + 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('Method Not Allowed', () => { diff --git a/tests/routes/api.devices.spec.ts b/tests/routes/api.devices.spec.ts deleted file mode 100644 index bdd45d9e..00000000 --- a/tests/routes/api.devices.spec.ts +++ /dev/null @@ -1,958 +0,0 @@ -import { - type AppLoadContext, - type LoaderFunctionArgs, - type ActionFunctionArgs, -} from 'react-router' -import { generateTestUserCredentials } from 'tests/data/generate_test_user' -import { BASE_URL } from 'vitest.setup' -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, - action as deviceUpdateAction, -} from '~/routes/api.boxes.$deviceId' -import { - loader as devicesLoader, - action as devicesAction, -} from '~/routes/api.devices' -import { type User, type Device } from '~/schema' - -const DEVICE_TEST_USER = generateTestUserCredentials() - -const generateMinimalDevice = ( - location: number[] | {} = [123, 12, 34], - exposure = 'mobile', - name = 'senseBox' + new Date().getTime(), -) => ({ - exposure, - location, - name, - model: 'homeV2Ethernet', -}) - -describe('openSenseMap API Routes: /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, - tags: ['testgroup'], - useAuth: false, - }, - (testUser as User).id, - ) - }) - - describe('GET', () => { - it('should search for boxes with a specific name and limit the results', async () => { - // Arrange - const request = new Request( - `${BASE_URL}?format=geojson&name=${queryableDevice?.name}&limit=2`, - { - method: 'GET', - headers: { 'Content-Type': 'application/json' }, - }, - ) - - // Act - const response: any = await devicesLoader({ - request: request, - } as LoaderFunctionArgs) - - expect(response).toBeDefined() - expect(Array.isArray(response?.features)).toBe(true) - expect(response?.features.length).lessThanOrEqual(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=${queryableDevice?.name}&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&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 - 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 as Response).status).toBe(422) - - const errorData = await (error as Response).json() - expect(errorData.error).toBe('Invalid format parameter') - } - }) - - 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: any = await devicesLoader({ - request: request, - } as LoaderFunctionArgs) - - 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 to filter boxes by grouptag', async () => { - // Arrange - const request = new Request(`${BASE_URL}?grouptag=testgroup`, { - 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).toHaveLength(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' }, - }, - ) - - // Act - const response: any = 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) - }) - } - } - }) - - 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 as Response).status).toBe(422) - - const errorData = await (error as Response).json() - expect(errorData.error).toBe('Invalid format parameter') - } - }) - - 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: any = await devicesLoader({ - request: request, - } as LoaderFunctionArgs) - - 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() - } - } - }) - }) - - describe('POST', () => { - it('should allow to set the location for a new box as array', async () => { - // Arrange - const loc = [0, 0, 0] - const requestBody = generateMinimalDevice(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) - const responseData = await response.json() - await deleteDevice({ id: responseData.data!.id }) - - // Assert - expect(response.status).toBe(201) - 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 = generateMinimalDevice(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) - const responseData = await response.json() - await deleteDevice({ id: responseData.data!.id }) - - // Assert - expect(response.status).toBe(201) - 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 () => { - 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', () => { - describe('GET', () => { - let result: any - - beforeAll(async () => { - // Arrange - const request = new Request(`${BASE_URL}/${queryableDevice!.id}`, { - method: 'GET', - headers: { 'Content-Type': 'application/json' }, - }) - - // Act - const dataFunctionValue = await deviceLoader({ - request: request, - params: { deviceId: queryableDevice!.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(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 name and model', () => { - expect(result.name).toBe(queryableDevice?.name) - 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 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('PUT', () => { - it('should allow to update the device via PUT', async () => { - const update_payload = { - name: 'neuername', - exposure: 'indoor', - grouptag: 'testgroup', - description: 'total neue beschreibung', - location: { lat: 54.2, lng: 21.1 }, - weblink: 'http://www.google.de', - useAuth: true, - image: - 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVQYV2NgYAAAAAMAAWgmWQ0AAAAASUVORK5CYII=', - } - - const request = new Request(`${BASE_URL}/${queryableDevice?.id}`, { - method: 'PUT', - headers: { - 'Content-Type': 'application/json', - Authorization: `Bearer ${jwt}`, - }, - body: JSON.stringify(update_payload), - }) - - const response = await deviceUpdateAction({ - request, - params: { deviceId: queryableDevice?.id }, - context: {} as AppLoadContext, - } as ActionFunctionArgs) - const data = await response.json() - - expect(response.status).toBe(200) - expect(data.name).toBe(update_payload.name) - expect(data.exposure).toBe(update_payload.exposure) - expect(Array.isArray(data.grouptag)).toBe(true) - expect(data.grouptag).toContain(update_payload.grouptag) - expect(data.description).toBe(update_payload.description) - expect(data.access_token).not.toBeNull() - expect(data.currentLocation).toEqual({ - type: 'Point', - coordinates: [ - update_payload.location.lng, - update_payload.location.lat, - ], - timestamp: expect.any(String), - }) - - expect(data.loc).toEqual([ - { - type: 'Feature', - geometry: { - type: 'Point', - coordinates: [ - update_payload.location.lng, - update_payload.location.lat, - ], - timestamp: expect.any(String), - }, - }, - ]) - }) - - it('should allow to update the device via PUT with array as grouptags', async () => { - const update_payload = { - name: 'neuername', - exposure: 'outdoor', - grouptag: ['testgroup'], - description: 'total neue beschreibung', - location: { lat: 54.2, lng: 21.1 }, - weblink: 'http://www.google.de', - image: - 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVQYV2NgYAAAAAMAAWgmWQ0AAAAASUVORK5CYII=', - } - - const request = new Request(`${BASE_URL}/${queryableDevice?.id}`, { - method: 'PUT', - headers: { - 'Content-Type': 'application/json', - Authorization: `Bearer ${jwt}`, - }, - body: JSON.stringify(update_payload), - }) - - const response: any = await deviceUpdateAction({ - request, - params: { deviceId: queryableDevice?.id }, - context: {} as AppLoadContext, - } as ActionFunctionArgs) - - expect(response.status).toBe(200) - - const data = await response.json() - expect(data.name).toBe(update_payload.name) - expect(data.exposure).toBe(update_payload.exposure) - - expect(Array.isArray(data.grouptag)).toBe(true) - expect(data.grouptag).toEqual(update_payload.grouptag) - - expect(data.description).toBe(update_payload.description) - expect(data.currentLocation.coordinates).toEqual([ - update_payload.location.lng, - update_payload.location.lat, - ]) - expect(data.loc[0].geometry.coordinates).toEqual([ - update_payload.location.lng, - update_payload.location.lat, - ]) - - //TODO: this fails, check if we actually need timestamps in images - // const parts = data.image.split('_') - // const ts36 = parts[1].replace('.png', '') - // const tsMs = parseInt(ts36, 36) * 1000 - // expect(Date.now() - tsMs).toBeLessThan(1000) - }) - it('should remove image when deleteImage=true', async () => { - const update_payload = { - deleteImage: true, - } - - const request = new Request(`${BASE_URL}/${queryableDevice?.id}`, { - method: 'PUT', - headers: { - 'Content-Type': 'application/json', - Authorization: `Bearer ${jwt}`, - }, - body: JSON.stringify(update_payload), - }) - - const response = await deviceUpdateAction({ - request, - params: { deviceId: queryableDevice?.id }, - context: {} as AppLoadContext, - } as ActionFunctionArgs) - - expect(response.status).toBe(200) - const data = await response.json() - expect(data.image).toBeNull() - }) - - it('should nullify description when set to empty string', async () => { - const update_payload = { - description: '', - } - - const request = new Request(`${BASE_URL}/${queryableDevice?.id}`, { - method: 'PUT', - headers: { - 'Content-Type': 'application/json', - Authorization: `Bearer ${jwt}`, - }, - body: JSON.stringify(update_payload), - }) - - const response = await deviceUpdateAction({ - request, - params: { deviceId: queryableDevice?.id }, - context: {} as AppLoadContext, - } as ActionFunctionArgs) - - expect(response.status).toBe(200) - const data = await response.json() - expect(data.description).toBeNull() - }) - - it('should clear group tags when empty array provided', async () => { - const update_payload = { - grouptag: [], - } - - const request = new Request(`${BASE_URL}/${queryableDevice?.id}`, { - method: 'PUT', - headers: { - 'Content-Type': 'application/json', - Authorization: `Bearer ${jwt}`, - }, - body: JSON.stringify(update_payload), - }) - - const response = await deviceUpdateAction({ - request, - params: { deviceId: queryableDevice?.id }, - context: {} as AppLoadContext, - } as ActionFunctionArgs) - - expect(response.status).toBe(200) - const data = await response.json() - expect(data.grouptag).toHaveLength(0) - }) - - it('should merge addons.add into grouptags', async () => { - const update_payload = { - addons: { add: 'feinstaub' }, - grouptag: ['existinggroup'], - } - - const request = new Request(`${BASE_URL}/${queryableDevice?.id}`, { - method: 'PUT', - headers: { - 'Content-Type': 'application/json', - Authorization: `Bearer ${jwt}`, - }, - body: JSON.stringify(update_payload), - }) - - const response = await deviceUpdateAction({ - request, - params: { deviceId: queryableDevice?.id }, - context: {} as AppLoadContext, - } as ActionFunctionArgs) - - expect(response.status).toBe(200) - const data = await response.json() - - expect(Array.isArray(data.grouptag)).toBe(true) - expect(data.grouptag).toContain('existinggroup') - expect(data.grouptag).toContain('feinstaub') - }) - - it('should accept multi-valued grouptag array', async () => { - const update_payload = { - grouptag: ['tag1', 'tag2', 'tag3'], - } - - const request = new Request(`${BASE_URL}/${queryableDevice?.id}`, { - method: 'PUT', - headers: { - 'Content-Type': 'application/json', - Authorization: `Bearer ${jwt}`, - }, - body: JSON.stringify(update_payload), - }) - - const response = await deviceUpdateAction({ - request, - params: { deviceId: queryableDevice?.id }, - context: {} as AppLoadContext, - } as ActionFunctionArgs) - - expect(response.status).toBe(200) - const data = await response.json() - expect(data.grouptag).toEqual( - expect.arrayContaining(['tag1', 'tag2', 'tag3']), - ) - }) - }) - - describe('DELETE', () => { - let deletableDevice: Device | null = null - - beforeAll(async () => { - deletableDevice = await createDevice( - { ...generateMinimalDevice(), latitude: 123, longitude: 12 }, - user!.id, - ) - }) - - 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' }), - }, - ) - - const badDeleteResponse = await devicesAction({ - request: badDeleteRequest, - params: { deviceId: queryableDevice?.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.message).toBe('Password incorrect') - }) - - 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 }), - }, - ) - - const validDeleteResponse = await devicesAction({ - request: validDeleteRequest, - params: { deviceId: deletableDevice?.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 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' }, - }, - ) - - const getDeletedResponse = await deviceLoader({ - request: getDeletedRequest, - params: { deviceId: deletableDevice?.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', - ) - }) - }) - }) - - afterAll(async () => { - await deleteDevice({ id: queryableDevice!.id }) - await deleteUserByEmail(DEVICE_TEST_USER.email) - }) -}) From cfacef1e5bf24752f89a9370b9c89e31408d24d2 Mon Sep 17 00:00:00 2001 From: David Scheidt Date: Wed, 4 Mar 2026 15:45:59 +0100 Subject: [PATCH 6/7] fix: make tests use correct action --- tests/routes/api.boxes.$deviceId.spec.ts | 706 +++++++++++------------ 1 file changed, 350 insertions(+), 356 deletions(-) diff --git a/tests/routes/api.boxes.$deviceId.spec.ts b/tests/routes/api.boxes.$deviceId.spec.ts index 18263382..4201df66 100644 --- a/tests/routes/api.boxes.$deviceId.spec.ts +++ b/tests/routes/api.boxes.$deviceId.spec.ts @@ -9,10 +9,9 @@ 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 { action as devicesAction } from '~/routes/api.boxes' import { loader as deviceLoader, - action as deviceUpdateAction, + action as deviceAction, } from '~/routes/api.boxes.$deviceId' import { type User, type Device } from '~/schema' @@ -29,7 +28,7 @@ const generateMinimalDevice = ( model: 'homeV2Ethernet', }) -describe('openSenseMap API Routes: /boxes', () => { +describe('openSenseMap API Routes: /boxes/:deviceId', () => { let user: User | null = null let jwt: string = '' let queryableDevice: Device | null = null @@ -57,395 +56,390 @@ describe('openSenseMap API Routes: /boxes', () => { ) }) - describe('/:deviceId', () => { - describe('GET', () => { - let result: any + describe('GET', () => { + let result: any - beforeAll(async () => { - // Arrange - const request = new Request(`${BASE_URL}/${queryableDevice!.id}`, { - method: 'GET', - headers: { 'Content-Type': 'application/json' }, - }) - - // Act - const dataFunctionValue = await deviceLoader({ - request: request, - params: { deviceId: queryableDevice!.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() + beforeAll(async () => { + // Arrange + const request = new Request(`${BASE_URL}/${queryableDevice!.id}`, { + method: 'GET', + headers: { 'Content-Type': 'application/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) - }) + // Act + const dataFunctionValue = await deviceLoader({ + request: request, + params: { deviceId: queryableDevice!.id }, + context: {} as AppLoadContext, + } as LoaderFunctionArgs) - it('should return the device name and model', () => { - expect(result.name).toBe(queryableDevice?.name) - expect(result.model).toBe('homeV2Ethernet') - expect(result.exposure).toBe('mobile') - }) + const response = dataFunctionValue as Response - it('should return the creation timestamp', () => { - expect(result.createdAt).toBeDefined() - expect(result.createdAt).toBe(queryableDevice?.createdAt.toISOString()) - }) + // Assert initial response + expect(dataFunctionValue).toBeInstanceOf(Response) + expect(response.status).toBe(200) + expect(response.headers.get('content-type')).toBe( + 'application/json; charset=utf-8', + ) - 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() - }) + // Get the body for subsequent tests + result = await response.json() }) - describe('PUT', () => { - it('should allow to update the device via PUT', async () => { - const update_payload = { - name: 'neuername', - exposure: 'indoor', - grouptag: 'testgroup', - description: 'total neue beschreibung', - location: { lat: 54.2, lng: 21.1 }, - weblink: 'http://www.google.de', - useAuth: true, - image: - 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVQYV2NgYAAAAAMAAWgmWQ0AAAAASUVORK5CYII=', - } - - const request = new Request(`${BASE_URL}/${queryableDevice?.id}`, { - method: 'PUT', - headers: { - 'Content-Type': 'application/json', - Authorization: `Bearer ${jwt}`, - }, - body: JSON.stringify(update_payload), - }) - - const response = await deviceUpdateAction({ - request, - params: { deviceId: queryableDevice?.id }, - context: {} as AppLoadContext, - } as ActionFunctionArgs) - const data = await response.json() - - expect(response.status).toBe(200) - expect(data.name).toBe(update_payload.name) - expect(data.exposure).toBe(update_payload.exposure) - expect(Array.isArray(data.grouptag)).toBe(true) - expect(data.grouptag).toContain(update_payload.grouptag) - expect(data.description).toBe(update_payload.description) - expect(data.access_token).not.toBeNull() - expect(data.currentLocation).toEqual({ - type: 'Point', - coordinates: [ - update_payload.location.lng, - update_payload.location.lat, - ], - timestamp: expect.any(String), - }) - - expect(data.loc).toEqual([ - { - type: 'Feature', - geometry: { - type: 'Point', - coordinates: [ - update_payload.location.lng, - update_payload.location.lat, - ], - timestamp: expect.any(String), - }, - }, - ]) + 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 name and model', () => { + expect(result.name).toBe(queryableDevice?.name) + 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 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('PUT', () => { + it('should allow to update the device via PUT', async () => { + const update_payload = { + name: 'neuername', + exposure: 'indoor', + grouptag: 'testgroup', + description: 'total neue beschreibung', + location: { lat: 54.2, lng: 21.1 }, + weblink: 'http://www.google.de', + useAuth: true, + image: + 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVQYV2NgYAAAAAMAAWgmWQ0AAAAASUVORK5CYII=', + } + + const request = new Request(`${BASE_URL}/${queryableDevice?.id}`, { + method: 'PUT', + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${jwt}`, + }, + body: JSON.stringify(update_payload), }) - it('should allow to update the device via PUT with array as grouptags', async () => { - const update_payload = { - name: 'neuername', - exposure: 'outdoor', - grouptag: ['testgroup'], - description: 'total neue beschreibung', - location: { lat: 54.2, lng: 21.1 }, - weblink: 'http://www.google.de', - image: - 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVQYV2NgYAAAAAMAAWgmWQ0AAAAASUVORK5CYII=', - } - - const request = new Request(`${BASE_URL}/${queryableDevice?.id}`, { - method: 'PUT', - headers: { - 'Content-Type': 'application/json', - Authorization: `Bearer ${jwt}`, - }, - body: JSON.stringify(update_payload), - }) - - const response: any = await deviceUpdateAction({ - request, - params: { deviceId: queryableDevice?.id }, - context: {} as AppLoadContext, - } as ActionFunctionArgs) - - expect(response.status).toBe(200) - - const data = await response.json() - expect(data.name).toBe(update_payload.name) - expect(data.exposure).toBe(update_payload.exposure) - - expect(Array.isArray(data.grouptag)).toBe(true) - expect(data.grouptag).toEqual(update_payload.grouptag) - - expect(data.description).toBe(update_payload.description) - expect(data.currentLocation.coordinates).toEqual([ - update_payload.location.lng, - update_payload.location.lat, - ]) - expect(data.loc[0].geometry.coordinates).toEqual([ - update_payload.location.lng, - update_payload.location.lat, - ]) - - //TODO: this fails, check if we actually need timestamps in images - // const parts = data.image.split('_') - // const ts36 = parts[1].replace('.png', '') - // const tsMs = parseInt(ts36, 36) * 1000 - // expect(Date.now() - tsMs).toBeLessThan(1000) + const response = await deviceAction({ + request, + params: { deviceId: queryableDevice?.id }, + context: {} as AppLoadContext, + } as ActionFunctionArgs) + const data = await response.json() + + expect(response.status).toBe(200) + expect(data.name).toBe(update_payload.name) + expect(data.exposure).toBe(update_payload.exposure) + expect(Array.isArray(data.grouptag)).toBe(true) + expect(data.grouptag).toContain(update_payload.grouptag) + expect(data.description).toBe(update_payload.description) + expect(data.access_token).not.toBeNull() + expect(data.currentLocation).toEqual({ + type: 'Point', + coordinates: [update_payload.location.lng, update_payload.location.lat], + timestamp: expect.any(String), }) - it('should remove image when deleteImage=true', async () => { - const update_payload = { - deleteImage: true, - } - const request = new Request(`${BASE_URL}/${queryableDevice?.id}`, { - method: 'PUT', - headers: { - 'Content-Type': 'application/json', - Authorization: `Bearer ${jwt}`, + expect(data.loc).toEqual([ + { + type: 'Feature', + geometry: { + type: 'Point', + coordinates: [ + update_payload.location.lng, + update_payload.location.lat, + ], + timestamp: expect.any(String), }, - body: JSON.stringify(update_payload), - }) - - const response = await deviceUpdateAction({ - request, - params: { deviceId: queryableDevice?.id }, - context: {} as AppLoadContext, - } as ActionFunctionArgs) - - expect(response.status).toBe(200) - const data = await response.json() - expect(data.image).toBeNull() - }) + }, + ]) + }) - it('should nullify description when set to empty string', async () => { - const update_payload = { - description: '', - } + it('should allow to update the device via PUT with array as grouptags', async () => { + const update_payload = { + name: 'neuername', + exposure: 'outdoor', + grouptag: ['testgroup'], + description: 'total neue beschreibung', + location: { lat: 54.2, lng: 21.1 }, + weblink: 'http://www.google.de', + image: + 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVQYV2NgYAAAAAMAAWgmWQ0AAAAASUVORK5CYII=', + } + + const request = new Request(`${BASE_URL}/${queryableDevice?.id}`, { + method: 'PUT', + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${jwt}`, + }, + body: JSON.stringify(update_payload), + }) - const request = new Request(`${BASE_URL}/${queryableDevice?.id}`, { - method: 'PUT', - headers: { - 'Content-Type': 'application/json', - Authorization: `Bearer ${jwt}`, - }, - body: JSON.stringify(update_payload), - }) - - const response = await deviceUpdateAction({ - request, - params: { deviceId: queryableDevice?.id }, - context: {} as AppLoadContext, - } as ActionFunctionArgs) - - expect(response.status).toBe(200) - const data = await response.json() - expect(data.description).toBeNull() + const response: any = await deviceAction({ + request, + params: { deviceId: queryableDevice?.id }, + context: {} as AppLoadContext, + } as ActionFunctionArgs) + + expect(response.status).toBe(200) + + const data = await response.json() + expect(data.name).toBe(update_payload.name) + expect(data.exposure).toBe(update_payload.exposure) + + expect(Array.isArray(data.grouptag)).toBe(true) + expect(data.grouptag).toEqual(update_payload.grouptag) + + expect(data.description).toBe(update_payload.description) + expect(data.currentLocation.coordinates).toEqual([ + update_payload.location.lng, + update_payload.location.lat, + ]) + expect(data.loc[0].geometry.coordinates).toEqual([ + update_payload.location.lng, + update_payload.location.lat, + ]) + + //TODO: this fails, check if we actually need timestamps in images + // const parts = data.image.split('_') + // const ts36 = parts[1].replace('.png', '') + // const tsMs = parseInt(ts36, 36) * 1000 + // expect(Date.now() - tsMs).toBeLessThan(1000) + }) + it('should remove image when deleteImage=true', async () => { + const update_payload = { + deleteImage: true, + } + + const request = new Request(`${BASE_URL}/${queryableDevice?.id}`, { + method: 'PUT', + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${jwt}`, + }, + body: JSON.stringify(update_payload), }) - it('should clear group tags when empty array provided', async () => { - const update_payload = { - grouptag: [], - } + const response = await deviceAction({ + request, + params: { deviceId: queryableDevice?.id }, + context: {} as AppLoadContext, + } as ActionFunctionArgs) - const request = new Request(`${BASE_URL}/${queryableDevice?.id}`, { - method: 'PUT', - headers: { - 'Content-Type': 'application/json', - Authorization: `Bearer ${jwt}`, - }, - body: JSON.stringify(update_payload), - }) - - const response = await deviceUpdateAction({ - request, - params: { deviceId: queryableDevice?.id }, - context: {} as AppLoadContext, - } as ActionFunctionArgs) - - expect(response.status).toBe(200) - const data = await response.json() - expect(data.grouptag).toHaveLength(0) + expect(response.status).toBe(200) + const data = await response.json() + expect(data.image).toBeNull() + }) + + it('should nullify description when set to empty string', async () => { + const update_payload = { + description: '', + } + + const request = new Request(`${BASE_URL}/${queryableDevice?.id}`, { + method: 'PUT', + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${jwt}`, + }, + body: JSON.stringify(update_payload), }) - it('should merge addons.add into grouptags', async () => { - const update_payload = { - addons: { add: 'feinstaub' }, - grouptag: ['existinggroup'], - } + const response = await deviceAction({ + request, + params: { deviceId: queryableDevice?.id }, + context: {} as AppLoadContext, + } as ActionFunctionArgs) - const request = new Request(`${BASE_URL}/${queryableDevice?.id}`, { - method: 'PUT', - headers: { - 'Content-Type': 'application/json', - Authorization: `Bearer ${jwt}`, - }, - body: JSON.stringify(update_payload), - }) + expect(response.status).toBe(200) + const data = await response.json() + expect(data.description).toBeNull() + }) - const response = await deviceUpdateAction({ - request, - params: { deviceId: queryableDevice?.id }, - context: {} as AppLoadContext, - } as ActionFunctionArgs) + it('should clear group tags when empty array provided', async () => { + const update_payload = { + grouptag: [], + } + + const request = new Request(`${BASE_URL}/${queryableDevice?.id}`, { + method: 'PUT', + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${jwt}`, + }, + body: JSON.stringify(update_payload), + }) - expect(response.status).toBe(200) - const data = await response.json() + const response = await deviceAction({ + request, + params: { deviceId: queryableDevice?.id }, + context: {} as AppLoadContext, + } as ActionFunctionArgs) - expect(Array.isArray(data.grouptag)).toBe(true) - expect(data.grouptag).toContain('existinggroup') - expect(data.grouptag).toContain('feinstaub') + expect(response.status).toBe(200) + const data = await response.json() + expect(data.grouptag).toHaveLength(0) + }) + + it('should merge addons.add into grouptags', async () => { + const update_payload = { + addons: { add: 'feinstaub' }, + grouptag: ['existinggroup'], + } + + const request = new Request(`${BASE_URL}/${queryableDevice?.id}`, { + method: 'PUT', + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${jwt}`, + }, + body: JSON.stringify(update_payload), }) - it('should accept multi-valued grouptag array', async () => { - const update_payload = { - grouptag: ['tag1', 'tag2', 'tag3'], - } + const response = await deviceAction({ + request, + params: { deviceId: queryableDevice?.id }, + context: {} as AppLoadContext, + } as ActionFunctionArgs) - const request = new Request(`${BASE_URL}/${queryableDevice?.id}`, { - method: 'PUT', - headers: { - 'Content-Type': 'application/json', - Authorization: `Bearer ${jwt}`, - }, - body: JSON.stringify(update_payload), - }) - - const response = await deviceUpdateAction({ - request, - params: { deviceId: queryableDevice?.id }, - context: {} as AppLoadContext, - } as ActionFunctionArgs) - - expect(response.status).toBe(200) - const data = await response.json() - expect(data.grouptag).toEqual( - expect.arrayContaining(['tag1', 'tag2', 'tag3']), - ) + expect(response.status).toBe(200) + const data = await response.json() + + expect(Array.isArray(data.grouptag)).toBe(true) + expect(data.grouptag).toContain('existinggroup') + expect(data.grouptag).toContain('feinstaub') + }) + + it('should accept multi-valued grouptag array', async () => { + const update_payload = { + grouptag: ['tag1', 'tag2', 'tag3'], + } + + const request = new Request(`${BASE_URL}/${queryableDevice?.id}`, { + method: 'PUT', + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${jwt}`, + }, + body: JSON.stringify(update_payload), }) + + const response = await deviceAction({ + request, + params: { deviceId: queryableDevice?.id }, + context: {} as AppLoadContext, + } as ActionFunctionArgs) + + expect(response.status).toBe(200) + const data = await response.json() + expect(data.grouptag).toEqual( + expect.arrayContaining(['tag1', 'tag2', 'tag3']), + ) }) + }) - describe('DELETE', () => { - let deletableDevice: Device | null = null + describe('DELETE', () => { + let deletableDevice: Device | null = null - beforeAll(async () => { - deletableDevice = await createDevice( - { ...generateMinimalDevice(), latitude: 123, longitude: 12 }, - user!.id, - ) - }) + beforeAll(async () => { + deletableDevice = await createDevice( + { ...generateMinimalDevice(), latitude: 123, longitude: 12 }, + user!.id, + ) + }) - 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' }), + 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}`, }, - ) - - const badDeleteResponse = await devicesAction({ - request: badDeleteRequest, - params: { deviceId: queryableDevice?.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.message).toBe('Password incorrect') - }) + body: JSON.stringify({ password: 'wrong password' }), + }, + ) + + const badDeleteResponse = await deviceAction({ + request: badDeleteRequest, + params: { deviceId: queryableDevice?.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.message).toBe('Password incorrect') + }) - 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 }), + 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}`, }, - ) - - const validDeleteResponse = await devicesAction({ - request: validDeleteRequest, - params: { deviceId: deletableDevice?.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', - ) - }) + body: JSON.stringify({ password: DEVICE_TEST_USER.password }), + }, + ) + + const validDeleteResponse = await deviceAction({ + request: validDeleteRequest, + params: { deviceId: deletableDevice?.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 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' }, - }, - ) - - const getDeletedResponse = await deviceLoader({ - request: getDeletedRequest, - params: { deviceId: deletableDevice?.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', - ) - }) + 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' }, + }, + ) + + const getDeletedResponse = await deviceLoader({ + request: getDeletedRequest, + params: { deviceId: deletableDevice?.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', + ) }) }) From 0a0c1758a657593d3ba7a6f8164bda087683d43b Mon Sep 17 00:00:00 2001 From: David Scheidt Date: Wed, 4 Mar 2026 15:55:58 +0100 Subject: [PATCH 7/7] fix: resolve typescript warnings --- tests/routes/api.boxes.spec.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/tests/routes/api.boxes.spec.ts b/tests/routes/api.boxes.spec.ts index 2b26ac0c..2d2785f9 100644 --- a/tests/routes/api.boxes.spec.ts +++ b/tests/routes/api.boxes.spec.ts @@ -669,9 +669,9 @@ describe('openSenseMap API Routes: /boxes', () => { }) // Act - const response = await action({ + const response = (await action({ request: request, - } as ActionFunctionArgs) + } as ActionFunctionArgs)) as Response const responseData = await response.json() await deleteDevice({ id: responseData._id }) @@ -702,9 +702,9 @@ describe('openSenseMap API Routes: /boxes', () => { }) // Act - const response = await action({ + const response = (await action({ request: request, - } as ActionFunctionArgs) + } as ActionFunctionArgs)) as Response const responseData = await response.json() await deleteDevice({ id: responseData._id })