diff --git a/app/lib/device-transform.ts b/app/lib/device-transform.ts new file mode 100644 index 00000000..0937c223 --- /dev/null +++ b/app/lib/device-transform.ts @@ -0,0 +1,102 @@ +import { type Device, type Sensor } from '~/schema'; + +export type DeviceWithSensors = Device & { + sensors: Sensor[]; +}; + +export type TransformedDevice = { + _id: string; + name: string; + description: string | null; + image: string | null; + link: string | null; + grouptag: string[]; + exposure: string | null; + model: string | null; + latitude: number; + longitude: number; + useAuth: boolean | null; + public: boolean | null; + status: string | null; + createdAt: Date; + updatedAt: Date; + expiresAt: Date | null; + userId: string; + sensorWikiModel?: string | null; + currentLocation: { + type: "Point"; + coordinates: number[]; + timestamp: string; + }; + lastMeasurementAt: string; + loc: Array<{ + type: "Feature"; + geometry: { + type: "Point"; + coordinates: number[]; + timestamp: string; + }; + }>; + integrations: { + mqtt: { + enabled: boolean; + }; + }; + sensors: Array<{ + _id: string; + title: string | null; + unit: string | null; + sensorType: string | null; + lastMeasurement: { + value: string; + createdAt: string; + } | null; + }>; +}; + +/** + * Transforms a device with sensors from database format to openSenseMap API format + * @param box - Device object with sensors from database + * @returns Transformed device in openSenseMap API format + * + * Note: Converts lastMeasurement.value from number to string to match API specification + */ +export function transformDeviceToApiFormat( + box: DeviceWithSensors +): TransformedDevice { + const { id, tags, sensors, ...rest } = box; + const timestamp = box.updatedAt.toISOString(); + const coordinates = [box.longitude, box.latitude]; + + return { + _id: id, + grouptag: tags || [], + ...rest, + currentLocation: { + type: "Point", + coordinates, + timestamp + }, + lastMeasurementAt: timestamp, + loc: [{ + geometry: { type: "Point", coordinates, timestamp }, + type: "Feature" + }], + integrations: { mqtt: { enabled: false } }, + sensors: sensors?.map((sensor) => ({ + _id: sensor.id, + title: sensor.title, + unit: sensor.unit, + sensorType: sensor.sensorType, + lastMeasurement: sensor.lastMeasurement + ? { + createdAt: sensor.lastMeasurement.createdAt, + // Convert numeric values to string to match API specification + value: typeof sensor.lastMeasurement.value === 'number' + ? String(sensor.lastMeasurement.value) + : sensor.lastMeasurement.value, + } + : null, + })) || [], + }; +} diff --git a/app/lib/devices-service.server.ts b/app/lib/devices-service.server.ts index dd7120e3..854a48f3 100644 --- a/app/lib/devices-service.server.ts +++ b/app/lib/devices-service.server.ts @@ -1,9 +1,24 @@ -import { Device, User } from '~/schema' +import { z } from 'zod' import { deleteDevice as deleteDeviceById, } from '~/models/device.server' import { verifyLogin } from '~/models/user.server' -import { z } from 'zod' +import { type Device, type User } from '~/schema' + +export const CreateBoxSchema = z.object({ + name: z.string().min(1, "Name is required").max(100, "Name too long"), + exposure: z.enum(["indoor", "outdoor", "mobile", "unknown"]).optional().default("unknown"), + location: z.array(z.number()).length(2, "Location must be [longitude, latitude]"), + grouptag: z.array(z.string()).optional().default([]), + model: z.enum(["homeV2Lora", "homeV2Ethernet", "homeV2Wifi", "senseBox:Edu", "luftdaten.info", "Custom"]).optional().default("Custom"), + sensors: z.array(z.object({ + id: z.string(), + icon: z.string().optional(), + title: z.string().min(1, "Sensor title is required"), + unit: z.string().min(1, "Sensor unit is required"), + sensorType: z.string().min(1, "Sensor type is required"), + })).optional().default([]), +}); export const BoxesQuerySchema = z.object({ format: z.enum(["json", "geojson"] ,{ diff --git a/app/models/device.server.ts b/app/models/device.server.ts index f2c66c10..d01db3f3 100644 --- a/app/models/device.server.ts +++ b/app/models/device.server.ts @@ -1,29 +1,38 @@ import { point } from '@turf/helpers' -import { eq, sql, desc, ilike, inArray, arrayContains, and } from 'drizzle-orm' +import { eq, sql, desc, ilike, arrayContains, and } from 'drizzle-orm' import { type Point } from 'geojson' import { drizzleClient } from '~/db.server' import { device, location, sensor, type Device, type Sensor } from '~/schema' +const BASE_DEVICE_COLUMNS = { + id: true, + name: true, + description: true, + image: true, + link: true, + tags: true, + exposure: true, + model: true, + latitude: true, + longitude: true, + status: true, + createdAt: true, + updatedAt: true, + expiresAt: true, + sensorWikiModel: true, +} as const; + +const DEVICE_COLUMNS_WITH_SENSORS = { + ...BASE_DEVICE_COLUMNS, + useAuth: true, + public: true, + userId: true, +} as const; + export function getDevice({ id }: Pick) { return drizzleClient.query.device.findFirst({ where: (device, { eq }) => eq(device.id, id), - columns: { - createdAt: true, - description: true, - exposure: true, - id: true, - image: true, - latitude: true, - longitude: true, - link: true, - model: true, - name: true, - sensorWikiModel: true, - status: true, - updatedAt: true, - tags: true, - expiresAt: true, - }, + columns: BASE_DEVICE_COLUMNS, with: { user: { columns: { @@ -109,15 +118,9 @@ export function deleteDevice({ id }: Pick) { export function getUserDevices(userId: Device['userId']) { return drizzleClient.query.device.findMany({ where: (device, { eq }) => eq(device.userId, userId), - columns: { - id: true, - name: true, - latitude: true, - longitude: true, - exposure: true, - model: true, - createdAt: true, - updatedAt: true, + columns: DEVICE_COLUMNS_WITH_SENSORS, + with: { + sensors: true, }, }) } @@ -356,9 +359,7 @@ interface BuildWhereClauseOptions { ) { const { minimal, limit } = opts; const { includeColumns, whereClause } = buildWhereClause(opts); - columns = minimal ? MINIMAL_COLUMNS : { ...DEFAULT_COLUMNS, ...columns }; - relations = { ...relations, ...includeColumns @@ -388,7 +389,11 @@ export async function createDevice(deviceData: any, userId: string) { tags: deviceData.tags, userId: userId, name: deviceData.name, + description: deviceData.description, + image: deviceData.image, + link: deviceData.link, exposure: deviceData.exposure, + public: deviceData.public ?? false, expiresAt: deviceData.expiresAt ? new Date(deviceData.expiresAt) : null, @@ -400,18 +405,31 @@ export async function createDevice(deviceData: any, userId: string) { if (!createdDevice) { throw new Error('Failed to create device.') } - // Add sensors in the same transaction + + // Add sensors in the same transaction and collect them + const createdSensors = []; if (deviceData.sensors && Array.isArray(deviceData.sensors)) { for (const sensorData of deviceData.sensors) { - await tx.insert(sensor).values({ - title: sensorData.title, - unit: sensorData.unit, - sensorType: sensorData.sensorType, - deviceId: createdDevice.id, // Reference the created device ID - }) + const [newSensor] = await tx.insert(sensor) + .values({ + title: sensorData.title, + unit: sensorData.unit, + sensorType: sensorData.sensorType, + deviceId: createdDevice.id, // Reference the created device ID + }) + .returning(); + + if (newSensor) { + createdSensors.push(newSensor); + } } } - return createdDevice + + // Return device with sensors + return { + ...createdDevice, + sensors: createdSensors + }; }) return newDevice } catch (error) { diff --git a/app/models/sensor.server.ts b/app/models/sensor.server.ts index 5d262f4d..54615c69 100644 --- a/app/models/sensor.server.ts +++ b/app/models/sensor.server.ts @@ -75,7 +75,6 @@ export async function getSensorsWithLastMeasurement( count?: number, ): Promise; - export async function getSensorsWithLastMeasurement( deviceId: Sensor["deviceId"], sensorId: Sensor["id"] | undefined = undefined, diff --git a/app/routes/api.boxes.ts b/app/routes/api.boxes.ts new file mode 100644 index 00000000..8aec5aaf --- /dev/null +++ b/app/routes/api.boxes.ts @@ -0,0 +1,410 @@ +import { type ActionFunction, type ActionFunctionArgs } from "react-router"; +import { transformDeviceToApiFormat } from "~/lib/device-transform"; +import { CreateBoxSchema } from "~/lib/devices-service.server"; +import { getUserFromJwt } from "~/lib/jwt"; +import { createDevice } from "~/models/device.server"; +import { type User } from "~/schema"; + +/** + * @openapi + * /api/boxes: + * post: + * tags: + * - Boxes + * summary: Create a new box + * description: Creates a new box/device with sensors + * operationId: createBox + * security: + * - bearerAuth: [] + * requestBody: + * required: true + * content: + * application/json: + * schema: + * type: object + * required: + * - name + * - location + * properties: + * name: + * type: string + * description: Box name + * example: "trala" + * exposure: + * type: string + * enum: ["indoor", "outdoor", "mobile", "unknown"] + * description: Box exposure type + * example: "mobile" + * location: + * type: array + * items: + * type: number + * minItems: 2 + * maxItems: 2 + * description: Box location as [longitude, latitude] + * example: [-122.406417, 37.785834] + * grouptag: + * type: array + * items: + * type: string + * description: Box group tags + * example: ["bike", "atrai", "arnsberg"] + * model: + * type: string + * enum: ["homeV2Lora", "homeV2Ethernet", "homeV2Wifi", "senseBox:Edu", "luftdaten.info", "Custom"] + * description: Box model type + * example: "Custom" + * sensors: + * type: array + * items: + * type: object + * required: + * - id + * - title + * - sensorType + * - unit + * properties: + * id: + * type: string + * description: Sensor ID + * example: "0" + * icon: + * type: string + * description: Sensor icon + * example: "osem-thermometer" + * title: + * type: string + * description: Sensor title + * example: "Temperature" + * sensorType: + * type: string + * description: Sensor type + * example: "HDC1080" + * unit: + * type: string + * description: Sensor unit + * example: "°C" + * responses: + * 201: + * description: Box created successfully + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/Box' + * 400: + * description: Bad request - validation error + * content: + * application/json: + * schema: + * type: object + * properties: + * code: + * type: string + * example: "Bad Request" + * message: + * type: string + * example: "Invalid request data" + * errors: + * type: array + * items: + * type: string + * 403: + * description: Forbidden - invalid or missing JWT token + * content: + * application/json: + * schema: + * type: object + * properties: + * code: + * type: string + * example: "Forbidden" + * message: + * type: string + * example: "Invalid JWT authorization. Please sign in to obtain new JWT." + * 405: + * description: Method not allowed - only POST is supported + * content: + * application/json: + * schema: + * type: object + * properties: + * message: + * type: string + * example: "Method Not Allowed" + * 500: + * description: Internal server error + * content: + * application/json: + * schema: + * type: object + * properties: + * code: + * type: string + * example: "Internal Server Error" + * message: + * type: string + * example: "The server was unable to create the box. Please try again later." + * components: + * schemas: + * Box: + * type: object + * description: Box/Device object + * properties: + * _id: + * type: string + * description: Unique box identifier + * example: "clx1234567890abcdef" + * name: + * type: string + * description: Box name + * example: "My Weather Station" + * description: + * type: string + * description: Box description + * example: "A weather monitoring station" + * image: + * type: string + * format: uri + * description: Box image URL + * example: "https://example.com/image.jpg" + * link: + * type: string + * format: uri + * description: Box website link + * example: "https://example.com" + * grouptag: + * type: array + * items: + * type: string + * description: Box group tags + * example: ["weather", "outdoor"] + * exposure: + * type: string + * enum: ["indoor", "outdoor", "mobile", "unknown"] + * description: Box exposure type + * example: "outdoor" + * model: + * type: string + * enum: ["homeV2Lora", "homeV2Ethernet", "homeV2Wifi", "senseBox:Edu", "luftdaten.info", "Custom"] + * description: Box model + * example: "homeV2Wifi" + * latitude: + * type: number + * description: Box latitude + * example: 52.520008 + * longitude: + * type: number + * description: Box longitude + * example: 13.404954 + * useAuth: + * type: boolean + * description: Whether box requires authentication + * example: true + * public: + * type: boolean + * description: Whether box is public + * example: false + * status: + * type: string + * enum: ["active", "inactive", "old"] + * description: Box status + * example: "inactive" + * createdAt: + * type: string + * format: date-time + * description: Box creation timestamp + * example: "2024-01-15T10:30:00Z" + * updatedAt: + * type: string + * format: date-time + * description: Box last update timestamp + * example: "2024-01-15T10:30:00Z" + * expiresAt: + * type: string + * format: date-time + * nullable: true + * description: Box expiration date + * example: "2024-12-31T23:59:59Z" + * userId: + * type: string + * description: Owner user ID + * example: "user_123456" + * sensorWikiModel: + * type: string + * nullable: true + * description: Sensor Wiki model identifier + * example: "homeV2Wifi" + * currentLocation: + * type: object + * description: Current location as GeoJSON Point + * properties: + * type: + * type: string + * example: "Point" + * coordinates: + * type: array + * items: + * type: number + * example: [13.404954, 52.520008] + * timestamp: + * type: string + * format: date-time + * example: "2023-01-01T00:00:00.000Z" + * lastMeasurementAt: + * type: string + * format: date-time + * description: Last measurement timestamp + * example: "2023-01-01T00:00:00.000Z" + * loc: + * type: array + * description: Location history as GeoJSON features + * items: + * type: object + * properties: + * type: + * type: string + * example: "Feature" + * geometry: + * type: object + * properties: + * type: + * type: string + * example: "Point" + * coordinates: + * type: array + * items: + * type: number + * example: [13.404954, 52.520008] + * timestamp: + * type: string + * format: date-time + * example: "2023-01-01T00:00:00.000Z" + * integrations: + * type: object + * description: Box integrations + * properties: + * mqtt: + * type: object + * properties: + * enabled: + * type: boolean + * example: false + * sensors: + * type: array + * items: + * type: object + * properties: + * _id: + * type: string + * description: Sensor ID + * example: "sensor123" + * title: + * type: string + * description: Sensor title + * example: "Temperature" + * unit: + * type: string + * description: Sensor unit + * example: "°C" + * sensorType: + * type: string + * description: Sensor type + * example: "HDC1080" + * lastMeasurement: + * type: object + * description: Last measurement data + * properties: + * createdAt: + * type: string + * format: date-time + * example: "2023-01-01T00:00:00.000Z" + * value: + * type: string + * example: "25.13" + */ + +export const action: ActionFunction = async ({ + request, +}: ActionFunctionArgs) => { + try { + // Check authentication + const jwtResponse = await getUserFromJwt(request); + + if (typeof jwtResponse === "string") { + return Response.json({ + code: "Forbidden", + message: "Invalid JWT authorization. Please sign in to obtain new JWT.", + }, { status: 403 }); + } + + switch (request.method) { + case "POST": + return await post(request, jwtResponse); + default: + return Response.json({ message: "Method Not Allowed" }, { status: 405 }); + } + } catch (err) { + console.error("Error in action:", err); + return Response.json({ + code: "Internal Server Error", + message: "The server was unable to complete your request. Please try again later.", + }, { status: 500 }); + } +}; + +async function post(request: Request, user: User) { + try { + // Parse and validate request body + let requestData; + try { + requestData = await request.json(); + } catch { + return Response.json({ + code: "Bad Request", + message: "Invalid JSON in request body", + }, { status: 400 }); + } + + // Validate request data + const validationResult = CreateBoxSchema.safeParse(requestData); + if (!validationResult.success) { + return Response.json({ + code: "Bad Request", + message: "Invalid request data", + errors: validationResult.error.errors.map(err => `${err.path.join('.')}: ${err.message}`), + }, { status: 400 }); + } + + const validatedData = validationResult.data; + + // Extract longitude and latitude from location array [longitude, latitude] + const [longitude, latitude] = validatedData.location; + const newBox = await createDevice({ + name: validatedData.name, + exposure: validatedData.exposure, + model: validatedData.model, + latitude: latitude, + longitude: longitude, + tags: validatedData.grouptag, + sensors: validatedData.sensors.map(sensor => ({ + title: sensor.title, + sensorType: sensor.sensorType, + unit: sensor.unit, + })), + }, user.id); + + // Build response object using helper function + const responseData = transformDeviceToApiFormat(newBox); + + return Response.json(responseData, { + status: 201, + headers: { "Content-Type": "application/json" }, + }); + } catch (err) { + console.error("Error creating box:", err); + return Response.json({ + code: "Internal Server Error", + message: "The server was unable to create the box. Please try again later.", + }, { status: 500 }); + } +} diff --git a/app/routes/api.devices.ts b/app/routes/api.devices.ts index 74151627..41aa010f 100644 --- a/app/routes/api.devices.ts +++ b/app/routes/api.devices.ts @@ -1,41 +1,15 @@ -import { json, type LoaderFunctionArgs } from '@remix-run/node' +import { json, type LoaderFunctionArgs } from "@remix-run/node"; +import { type ActionFunctionArgs } from "react-router"; +import { BoxesQuerySchema, deleteDevice } from "~/lib/devices-service.server"; +import { getUserFromJwt } from "~/lib/jwt"; import { - createDevice, - findDevices, - FindDevicesOptions, - // deleteDevice, - getDevice, - getDevices, - getDevicesWithSensors, -} from '~/models/device.server' -import { ActionFunctionArgs } from 'react-router' -import { getUserFromJwt } from '~/lib/jwt' -import { - BoxesQuerySchema, - deleteDevice, - type BoxesQueryParams, -} from '~/lib/devices-service.server' -import { Device, DeviceExposureType, User } from '~/schema' -import { Point } from 'geojson' + createDevice, + findDevices, + getDevice, + type FindDevicesOptions, +} from "~/models/device.server"; +import { type Device, type User } from "~/schema"; -function fromToTimeParamsSanityCheck(fromDate: Date | null, toDate: Date | null) { - if ((fromDate && !toDate) || (toDate && !fromDate)) { - throw json( - { error: 'fromDate and toDate need to be specified simultaneously' }, - { status: 400 } - ); - } - - if (fromDate && toDate) { - if (fromDate.getTime() > toDate.getTime()) { - throw json( - { error: `Invalid time frame specified: fromDate (${fromDate.toISOString()}) is after toDate (${toDate.toISOString()})` }, - { status: 422 } - ); - } - } - } - /** * @openapi * /api/devices: @@ -246,7 +220,7 @@ function fromToTimeParamsSanityCheck(fromDate: Date | null, toDate: Date | null) * format: date-time * description: Device last update timestamp * example: "2023-05-15T12:00:00Z" - * + * * GeoJSONFeatureCollection: * type: object * required: @@ -261,7 +235,7 @@ function fromToTimeParamsSanityCheck(fromDate: Date | null, toDate: Date | null) * type: array * items: * $ref: '#/components/schemas/GeoJSONFeature' - * + * * GeoJSONFeature: * type: object * required: @@ -277,7 +251,7 @@ function fromToTimeParamsSanityCheck(fromDate: Date | null, toDate: Date | null) * $ref: '#/components/schemas/GeoJSONPoint' * properties: * $ref: '#/components/schemas/Device' - * + * * GeoJSONPoint: * type: object * required: @@ -296,7 +270,7 @@ function fromToTimeParamsSanityCheck(fromDate: Date | null, toDate: Date | null) * maxItems: 2 * description: Longitude and latitude coordinates * example: [13.4050, 52.5200] - * + * * ErrorResponse: * type: object * required: @@ -308,222 +282,214 @@ function fromToTimeParamsSanityCheck(fromDate: Date | null, toDate: Date | null) * 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 url = new URL(request.url); + const queryObj = Object.fromEntries(url.searchParams); + const max_limit = 20; + const parseResult = BoxesQuerySchema.safeParse(queryObj); - const parseResult = BoxesQuerySchema.safeParse(queryObj); - if (!parseResult.success) { - const { fieldErrors, formErrors } = parseResult.error.flatten(); - if (fieldErrors.format) { - throw json( - { error: "Invalid format parameter" }, - { status: 422 } - ); - } + if (!parseResult.success) { + const { fieldErrors, formErrors } = parseResult.error.flatten(); + if (fieldErrors.format) { + throw json({ error: "Invalid format parameter" }, { status: 422 }); + } - throw json( - { error: parseResult.error.flatten() }, - { status: 422 } - ); - } + throw json({ error: parseResult.error.flatten() }, { status: 422 }); + } - const params: FindDevicesOptions = parseResult.data; + const params: FindDevicesOptions = parseResult.data; - const devices = await findDevices(params) + 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 - } - })) - }; + 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 - } + return geojson; + } else { + return devices; + } } export async function action({ request, params }: ActionFunctionArgs) { - try { - const jwtResponse = await getUserFromJwt(request) + try { + const jwtResponse = await getUserFromJwt(request); - if (typeof jwtResponse === 'string') - return Response.json( - { - code: 'Forbidden', - message: - 'Invalid JWT authorization. Please sign in to obtain new JWT.', - }, - { - status: 403, - }, - ) - switch (request.method) { - case 'POST': - return await post(request, jwtResponse) - case 'DELETE': - return await del(request, jwtResponse, params) - default: - return Response.json({ msg: 'Method Not Allowed' }, { status: 405 }) - } - } catch (err) { - console.warn(err) - return Response.json( - { - error: 'Internal Server Error', - message: - 'The server was unable to complete your request. Please try again later.', - }, - { - status: 500, - }, - ) - } + if (typeof jwtResponse === "string") + return Response.json( + { + code: "Forbidden", + message: + "Invalid JWT authorization. Please sign in to obtain new JWT.", + }, + { + status: 403, + }, + ); + switch (request.method) { + case "POST": + return await post(request, jwtResponse); + case "DELETE": + return await del(request, jwtResponse, params); + default: + return Response.json({ msg: "Method Not Allowed" }, { status: 405 }); + } + } catch (err) { + console.warn(err); + return Response.json( + { + error: "Internal Server Error", + message: + "The server was unable to complete your request. Please try again later.", + }, + { + status: 500, + }, + ); + } } async function del(request: Request, user: User, params: any) { - const { deviceId } = params + const { deviceId } = params; - if (!deviceId) { - throw json({ message: 'Device ID is required' }, { status: 400 }) - } + if (!deviceId) { + throw json({ message: "Device ID is required" }, { status: 400 }); + } - const device = (await getDevice({ id: deviceId })) as unknown as Device + const device = (await getDevice({ id: deviceId })) as unknown as Device; - if (!device) { - throw json({ message: 'Device not found' }, { status: 404 }) - } + if (!device) { + throw json({ message: "Device not found" }, { status: 404 }); + } - const body = await request.json() + const body = await request.json(); - if (!body.password) { - throw json( - { message: 'Password is required for device deletion' }, - { status: 400 }, - ) - } + if (!body.password) { + throw json( + { message: "Password is required for device deletion" }, + { status: 400 }, + ); + } - try { - const deleted = await deleteDevice(user, device, body.password) + try { + const deleted = await deleteDevice(user, device, body.password); - if (deleted === 'unauthorized') - return Response.json( - { message: 'Password incorrect' }, - { - status: 401, - headers: { 'Content-Type': 'application/json; charset=utf-8' }, - }, - ) + if (deleted === "unauthorized") + return Response.json( + { message: "Password incorrect" }, + { + status: 401, + headers: { "Content-Type": "application/json; charset=utf-8" }, + }, + ); - return Response.json(null, { - status: 200, - headers: { 'Content-Type': 'application/json; charset=utf-8' }, - }) - } catch (err) { - console.warn(err) - return new Response('Internal Server Error', { status: 500 }) - } + return Response.json(null, { + status: 200, + headers: { "Content-Type": "application/json; charset=utf-8" }, + }); + } catch (err) { + console.warn(err); + return new Response("Internal Server Error", { status: 500 }); + } } async function post(request: Request, user: User) { - try { - const body = await request.json() + try { + const body = await request.json(); - if (!body.location) { - throw json( - { message: 'missing required parameter location' }, - { status: 400 }, - ) - } + if (!body.location) { + throw json( + { message: "missing required parameter location" }, + { status: 400 }, + ); + } - let latitude: number, longitude: number, height: number | undefined + let latitude: number, longitude: number, height: number | undefined; - if (Array.isArray(body.location)) { - // Handle array format [lat, lng, height?] - if (body.location.length < 2) { - throw json( - { - message: `Illegal value for parameter location. missing latitude or longitude in location [${body.location.join(',')}]`, - }, - { status: 422 }, - ) - } - latitude = Number(body.location[0]) - longitude = Number(body.location[1]) - height = body.location[2] ? Number(body.location[2]) : undefined - } else if (typeof body.location === 'object' && body.location !== null) { - // Handle object format { lat, lng, height? } - if (!('lat' in body.location) || !('lng' in body.location)) { - throw json( - { - message: - 'Illegal value for parameter location. missing latitude or longitude', - }, - { status: 422 }, - ) - } - latitude = Number(body.location.lat) - longitude = Number(body.location.lng) - height = body.location.height ? Number(body.location.height) : undefined - } else { - throw json( - { - message: - 'Illegal value for parameter location. Expected array or object', - }, - { status: 422 }, - ) - } + if (Array.isArray(body.location)) { + // Handle array format [lat, lng, height?] + if (body.location.length < 2) { + throw json( + { + message: `Illegal value for parameter location. missing latitude or longitude in location [${body.location.join(",")}]`, + }, + { status: 422 }, + ); + } + latitude = Number(body.location[0]); + longitude = Number(body.location[1]); + height = body.location[2] ? Number(body.location[2]) : undefined; + } else if (typeof body.location === "object" && body.location !== null) { + // Handle object format { lat, lng, height? } + if (!("lat" in body.location) || !("lng" in body.location)) { + throw json( + { + message: + "Illegal value for parameter location. missing latitude or longitude", + }, + { status: 422 }, + ); + } + latitude = Number(body.location.lat); + longitude = Number(body.location.lng); + height = body.location.height ? Number(body.location.height) : undefined; + } else { + throw json( + { + message: + "Illegal value for parameter location. Expected array or object", + }, + { status: 422 }, + ); + } - if (isNaN(latitude) || isNaN(longitude)) { - throw json( - { message: 'Invalid latitude or longitude values' }, - { status: 422 }, - ) - } + if (isNaN(latitude) || isNaN(longitude)) { + throw json( + { message: "Invalid latitude or longitude values" }, + { status: 422 }, + ); + } - const rawAuthorizationHeader = request.headers.get('authorization') - if (!rawAuthorizationHeader) { - throw json({ message: 'Authorization header required' }, { status: 401 }) - } - const [, jwtString] = rawAuthorizationHeader.split(' ') + const rawAuthorizationHeader = request.headers.get("authorization"); + if (!rawAuthorizationHeader) { + throw json({ message: "Authorization header required" }, { status: 401 }); + } + const [, jwtString] = rawAuthorizationHeader.split(" "); - const deviceData = { - ...body, - latitude, - longitude, - } + const deviceData = { + ...body, + latitude, + longitude, + }; - const newDevice = await createDevice(deviceData, user.id) + const newDevice = await createDevice(deviceData, user.id); - return json( - { - data: { - ...newDevice, - access_token: jwtString, - createdAt: newDevice.createdAt || new Date(), - }, - }, - { status: 201 }, - ) - } catch (error) { - console.error('Error creating device:', error) + return json( + { + data: { + ...newDevice, + createdAt: newDevice.createdAt || new Date(), + }, + }, + { status: 201 }, + ); + } catch (error) { + console.error("Error creating device:", error); - if (error instanceof Response) { - throw error - } + if (error instanceof Response) { + throw error; + } - throw json({ message: 'Internal server error' }, { status: 500 }) - } + throw json({ message: "Internal server error" }, { status: 500 }); + } } diff --git a/app/routes/api.ts b/app/routes/api.ts index 5dc54f8a..57ed3ebf 100644 --- a/app/routes/api.ts +++ b/app/routes/api.ts @@ -113,10 +113,10 @@ const routes: { noauth: RouteInfo[]; auth: RouteInfo[] } = { // path: `boxes/:boxId/script`, // method: "GET", // }, - // { - // path: `boxes`, - // method: "POST", - // }, + { + path: `boxes`, + method: "POST", + }, // { // path: `boxes/claim`, // method: "POST", diff --git a/app/routes/api.users.me.boxes.ts b/app/routes/api.users.me.boxes.ts index 1b11f96c..30e5f009 100644 --- a/app/routes/api.users.me.boxes.ts +++ b/app/routes/api.users.me.boxes.ts @@ -1,4 +1,5 @@ import { type LoaderFunction, type LoaderFunctionArgs } from "react-router"; +import { transformDeviceToApiFormat } from "~/lib/device-transform"; import { getUserFromJwt } from "~/lib/jwt"; import { getUserDevices } from "~/models/device.server"; @@ -21,13 +22,14 @@ export const loader: LoaderFunction = async ({ ); const userBoxes = await getUserDevices(jwtResponse.id); + const cleanedBoxes = userBoxes.map((box) => transformDeviceToApiFormat(box)); return Response.json( { code: "Ok", data: { - boxes: userBoxes, - boxes_count: userBoxes.length, + boxes: cleanedBoxes, + boxes_count: cleanedBoxes.length, sharedBoxes: [], }, }, diff --git a/app/schema/sensor.ts b/app/schema/sensor.ts index 681c99f7..0279c4cc 100644 --- a/app/schema/sensor.ts +++ b/app/schema/sensor.ts @@ -9,6 +9,15 @@ import { device } from "./device"; import { DeviceStatusEnum } from "./enum"; import { type Measurement } from "./measurement"; +/** + * Type for lastMeasurement JSON field + */ +export type LastMeasurement = { + value: number | string; + createdAt: string; + sensorId?: string; +} | null; + /** * Table */ @@ -31,7 +40,7 @@ export const sensor = pgTable("sensor", { sensorWikiType: text("sensor_wiki_type"), sensorWikiPhenomenon: text("sensor_wiki_phenomenon"), sensorWikiUnit: text("sensor_wiki_unit"), - lastMeasurement: json("lastMeasurement"), + lastMeasurement: json("lastMeasurement").$type(), data: json("data"), }); diff --git a/tests/lib/device-transform.spec.ts b/tests/lib/device-transform.spec.ts new file mode 100644 index 00000000..76c866f9 --- /dev/null +++ b/tests/lib/device-transform.spec.ts @@ -0,0 +1,262 @@ +import { transformDeviceToApiFormat } from "~/lib/device-transform"; + +describe("transformDeviceToApiFormat", () => { + const mockDevice = { + id: "test-device-id", + name: "Test Device", + description: "A test device", + image: "https://example.com/image.jpg", + link: "https://example.com", + tags: ["bike", "outdoor"], + exposure: "mobile", + model: "Custom", + latitude: 37.7749, + longitude: -122.4194, + useAuth: true, + public: false, + status: "active", + createdAt: new Date("2024-01-01T00:00:00Z"), + updatedAt: new Date("2024-01-01T12:00:00Z"), + expiresAt: new Date("2024-12-31T23:59:59Z"), + userId: "user-123", + sensors: [ + { + id: "sensor-1", + title: "Temperature", + unit: "°C", + sensorType: "HDC1080", + lastMeasurement: { + createdAt: "2024-01-01T12:00:00Z", + value: "25.5" + } + }, + { + id: "sensor-2", + title: "Humidity", + unit: "%", + sensorType: "HDC1080", + lastMeasurement: null + } + ] + }; + + test("transforms device with all fields", () => { + const result = transformDeviceToApiFormat(mockDevice as any); + + expect(result).toEqual({ + _id: "test-device-id", + grouptag: ["bike", "outdoor"], + name: "Test Device", + description: "A test device", + image: "https://example.com/image.jpg", + link: "https://example.com", + exposure: "mobile", + model: "Custom", + latitude: 37.7749, + longitude: -122.4194, + useAuth: true, + public: false, + status: "active", + createdAt: new Date("2024-01-01T00:00:00Z"), + updatedAt: new Date("2024-01-01T12:00:00Z"), + expiresAt: new Date("2024-12-31T23:59:59Z"), + userId: "user-123", + currentLocation: { + type: "Point", + coordinates: [-122.4194, 37.7749], + timestamp: "2024-01-01T12:00:00.000Z" + }, + lastMeasurementAt: "2024-01-01T12:00:00.000Z", + loc: [{ + geometry: { + type: "Point", + coordinates: [-122.4194, 37.7749], + timestamp: "2024-01-01T12:00:00.000Z" + }, + type: "Feature" + }], + integrations: { + mqtt: { + enabled: false + } + }, + sensors: [ + { + _id: "sensor-1", + title: "Temperature", + unit: "°C", + sensorType: "HDC1080", + lastMeasurement: { + createdAt: "2024-01-01T12:00:00Z", + value: "25.5" + } + }, + { + _id: "sensor-2", + title: "Humidity", + unit: "%", + sensorType: "HDC1080", + lastMeasurement: null + } + ] + }); + }); + + test("handles null tags by defaulting to empty array", () => { + const deviceWithNullTags = { ...mockDevice, tags: null }; + const result = transformDeviceToApiFormat(deviceWithNullTags as any); + + expect(result.grouptag).toEqual([]); + }); + + test("handles undefined tags by defaulting to empty array", () => { + const deviceWithUndefinedTags = { ...mockDevice, tags: undefined }; + const result = transformDeviceToApiFormat(deviceWithUndefinedTags as any); + + expect(result.grouptag).toEqual([]); + }); + + test("handles missing sensors by defaulting to empty array", () => { + const deviceWithoutSensors = { ...mockDevice, sensors: undefined }; + const result = transformDeviceToApiFormat(deviceWithoutSensors as any); + + expect(result.sensors).toEqual([]); + }); + + test("handles null sensors by defaulting to empty array", () => { + const deviceWithNullSensors = { ...mockDevice, sensors: null }; + const result = transformDeviceToApiFormat(deviceWithNullSensors as any); + + expect(result.sensors).toEqual([]); + }); + + test("transforms sensors correctly", () => { + const result = transformDeviceToApiFormat(mockDevice as any); + + expect(result.sensors).toHaveLength(2); + expect(result.sensors[0]).toEqual({ + _id: "sensor-1", + title: "Temperature", + unit: "°C", + sensorType: "HDC1080", + lastMeasurement: { + createdAt: "2024-01-01T12:00:00Z", + value: "25.5" + } + }); + expect(result.sensors[1]).toEqual({ + _id: "sensor-2", + title: "Humidity", + unit: "%", + sensorType: "HDC1080", + lastMeasurement: null + }); + }); + + test("generates correct currentLocation structure", () => { + const result = transformDeviceToApiFormat(mockDevice as any); + + expect(result.currentLocation).toEqual({ + type: "Point", + coordinates: [-122.4194, 37.7749], // [longitude, latitude] + timestamp: "2024-01-01T12:00:00.000Z" + }); + }); + + test("generates correct loc array structure", () => { + const result = transformDeviceToApiFormat(mockDevice as any); + + expect(result.loc).toEqual([{ + geometry: { + type: "Point", + coordinates: [-122.4194, 37.7749], // [longitude, latitude] + timestamp: "2024-01-01T12:00:00.000Z" + }, + type: "Feature" + }]); + }); + + test("sets correct integrations structure", () => { + const result = transformDeviceToApiFormat(mockDevice as any); + + expect(result.integrations).toEqual({ + mqtt: { + enabled: false + } + }); + }); + + test("handles minimal device data", () => { + const minimalDevice = { + id: "minimal-id", + name: "Minimal Device", + latitude: 0, + longitude: 0, + updatedAt: new Date("2024-01-01T00:00:00Z") + }; + + const result = transformDeviceToApiFormat(minimalDevice as any); + + expect(result._id).toBe("minimal-id"); + expect(result.name).toBe("Minimal Device"); + expect(result.grouptag).toEqual([]); + expect(result.sensors).toEqual([]); + expect(result.currentLocation.coordinates).toEqual([0, 0]); + expect(result.loc[0].geometry.coordinates).toEqual([0, 0]); + }); + + test("preserves all original device fields", () => { + const result = transformDeviceToApiFormat(mockDevice as any); + + // Check that all original fields are preserved + expect(result.name).toBe(mockDevice.name); + expect(result.description).toBe(mockDevice.description); + expect(result.image).toBe(mockDevice.image); + expect(result.link).toBe(mockDevice.link); + expect(result.exposure).toBe(mockDevice.exposure); + expect(result.model).toBe(mockDevice.model); + expect(result.latitude).toBe(mockDevice.latitude); + expect(result.longitude).toBe(mockDevice.longitude); + expect(result.useAuth).toBe(mockDevice.useAuth); + expect(result.public).toBe(mockDevice.public); + expect(result.status).toBe(mockDevice.status); + expect(result.createdAt).toBe(mockDevice.createdAt); + expect(result.updatedAt).toBe(mockDevice.updatedAt); + expect(result.expiresAt).toBe(mockDevice.expiresAt); + expect(result.userId).toBe(mockDevice.userId); + }); + + + test("converts numeric lastMeasurement values to strings", () => { + const deviceWithNumericMeasurement = { + ...mockDevice, + sensors: [ + { + id: "sensor-1", + title: "Temperature", + unit: "°C", + sensorType: "HDC1080", + lastMeasurement: { + createdAt: "2024-01-01T12:00:00Z", + value: 25.5 + } + } + ] + }; + + const result = transformDeviceToApiFormat(deviceWithNumericMeasurement as any); + + expect(result.sensors[0].lastMeasurement).toEqual({ + createdAt: "2024-01-01T12:00:00Z", + value: "25.5" + }); + expect(typeof result.sensors[0].lastMeasurement?.value).toBe("string"); + }); + + test("preserves string lastMeasurement values", () => { + const result = transformDeviceToApiFormat(mockDevice as any); + + expect(result.sensors[0].lastMeasurement?.value).toBe("25.5"); + expect(typeof result.sensors[0].lastMeasurement?.value).toBe("string"); + }); +}); diff --git a/tests/models/device.server.spec.ts b/tests/models/device.server.spec.ts new file mode 100644 index 00000000..19b18467 --- /dev/null +++ b/tests/models/device.server.spec.ts @@ -0,0 +1,171 @@ +import { registerUser } from "~/lib/user-service.server"; +import { createDevice, deleteDevice } from "~/models/device.server"; +import { deleteUserByEmail } from "~/models/user.server"; +import { type User } from "~/schema"; + +const DEVICE_MODEL_TEST_USER = { + name: "device model tester", + email: "test@devicemodel.me", + password: "some secure password", +}; + +describe("Device Model: createDevice", () => { + let userId: string = ""; + let createdDeviceIds: string[] = []; + + beforeAll(async () => { + const user = await registerUser( + DEVICE_MODEL_TEST_USER.name, + DEVICE_MODEL_TEST_USER.email, + DEVICE_MODEL_TEST_USER.password, + "en_US", + ); + userId = (user as User).id; + }); + + afterAll(async () => { + // Clean up created devices + for (const deviceId of createdDeviceIds) { + try { + await deleteDevice({ id: deviceId }); + } catch (error) { + console.error(`Failed to delete device ${deviceId}:`, error); + } + } + // Clean up test user + await deleteUserByEmail(DEVICE_MODEL_TEST_USER.email); + }); + + it("should create a device and return it with multiple sensors", async () => { + const deviceData = { + name: "Test Device with Sensors", + latitude: 51.969, + longitude: 7.596, + exposure: "outdoor", + model: "homeV2Wifi", + sensors: [ + { title: "Temperature", unit: "°C", sensorType: "HDC1080" }, + { title: "Humidity", unit: "%", sensorType: "HDC1080" }, + { title: "Pressure", unit: "hPa", sensorType: "BMP280" }, + ], + }; + + const result = await createDevice(deviceData, userId); + + createdDeviceIds.push(result.id); + + expect(result).toBeDefined(); + expect(result).toHaveProperty("id"); + expect(result).toHaveProperty("name", "Test Device with Sensors"); + expect(result).toHaveProperty("latitude", 51.969); + expect(result).toHaveProperty("longitude", 7.596); + expect(result).toHaveProperty("userId", userId); + + expect(result).toHaveProperty("sensors"); + expect(Array.isArray(result.sensors)).toBe(true); + expect(result.sensors).toHaveLength(3); + + expect(result.sensors[0]).toHaveProperty("id"); + expect(result.sensors[0]).toHaveProperty("title", "Temperature"); + expect(result.sensors[0]).toHaveProperty("unit", "°C"); + expect(result.sensors[0]).toHaveProperty("sensorType", "HDC1080"); + expect(result.sensors[0]).toHaveProperty("deviceId", result.id); + + expect(result.sensors[1]).toHaveProperty("title", "Humidity"); + expect(result.sensors[2]).toHaveProperty("title", "Pressure"); + + result.sensors.forEach((sensor) => { + expect(sensor.deviceId).toBe(result.id); + expect(sensor).toHaveProperty("createdAt"); + expect(sensor).toHaveProperty("updatedAt"); + }); + }); + + it("should create a device with empty sensors array when no sensors provided", async () => { + const deviceData = { + name: "Device Without Sensors", + latitude: 52.0, + longitude: 8.0, + exposure: "indoor", + model: "Custom", + }; + + const result = await createDevice(deviceData, userId); + + createdDeviceIds.push(result.id); + expect(result).toBeDefined(); + expect(result).toHaveProperty("id"); + expect(result).toHaveProperty("name", "Device Without Sensors"); + expect(result).toHaveProperty("sensors"); + expect(Array.isArray(result.sensors)).toBe(true); + expect(result.sensors).toHaveLength(0); + }); + + it("should create device with tags/grouptag", async () => { + const deviceData = { + name: "Tagged Device", + latitude: 51.5, + longitude: 7.5, + exposure: "outdoor", + model: "Custom", + tags: ["weather", "city", "test"], + sensors: [ + { title: "Temperature", unit: "°C", sensorType: "DHT22" }, + ], + }; + + const result = await createDevice(deviceData, userId); + + createdDeviceIds.push(result.id); + expect(result).toHaveProperty("tags"); + expect(Array.isArray(result.tags)).toBe(true); + expect(result.tags).toEqual(["weather", "city", "test"]); + expect(result.sensors).toHaveLength(1); + }); + + it("should create device with optional fields", async () => { + const deviceData = { + name: "Full Featured Device", + latitude: 51.0, + longitude: 7.0, + exposure: "mobile", + model: "homeV2Lora", + description: "A comprehensive test device", + image: "https://example.com/device.jpg", + link: "https://example.com", + public: true, + tags: ["test"], + sensors: [ + { title: "Temperature", unit: "°C", sensorType: "SHT31" }, + ], + }; + + const result = await createDevice(deviceData, userId); + + createdDeviceIds.push(result.id); + expect(result).toHaveProperty("description", "A comprehensive test device"); + expect(result).toHaveProperty("image", "https://example.com/device.jpg"); + expect(result).toHaveProperty("link", "https://example.com"); + expect(result).toHaveProperty("public", true); + expect(result).toHaveProperty("exposure", "mobile"); + expect(result).toHaveProperty("model", "homeV2Lora"); + expect(result.sensors).toHaveLength(1); + }); + + it("should set default values for optional fields when not provided", async () => { + const deviceData = { + name: "Minimal Device", + latitude: 50.0, + longitude: 7.0, + sensors: [], + }; + + const result = await createDevice(deviceData, userId); + + createdDeviceIds.push(result.id); + expect(result).toHaveProperty("public", false); + expect(result).toHaveProperty("useAuth", true); + expect(result).toHaveProperty("expiresAt", null); + expect(result.sensors).toHaveLength(0); + }); +}); diff --git a/tests/routes/api.boxes.spec.ts b/tests/routes/api.boxes.spec.ts new file mode 100644 index 00000000..0636de82 --- /dev/null +++ b/tests/routes/api.boxes.spec.ts @@ -0,0 +1,352 @@ +import { type ActionFunctionArgs } from "react-router"; +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 { deleteUserByEmail } from "~/models/user.server"; +import { action } from "~/routes/api.boxes"; +import { type User } from "~/schema"; + +const BOXES_POST_TEST_USER = { + name: "testing post boxes", + email: "test@postboxes.me", + password: "some secure password", +}; + +describe("openSenseMap API Routes: /boxes", () => { + let user: User | null = null; + let jwt: string = ""; + let createdDeviceIds: string[] = []; + + beforeAll(async () => { + const testUser = await registerUser( + BOXES_POST_TEST_USER.name, + BOXES_POST_TEST_USER.email, + BOXES_POST_TEST_USER.password, + "en_US", + ); + user = testUser as User; + const { token } = await createToken(testUser as User); + jwt = token; + }); + + afterAll(async () => { + for (const deviceId of createdDeviceIds) { + try { + await deleteDevice({ id: deviceId }); + } catch (error) { + console.error(`Failed to delete device ${deviceId}:`, error); + } + } + if (user) { + await deleteUserByEmail(BOXES_POST_TEST_USER.email); + } + }); + + describe("POST", () => { + it("should create a new box with sensors", async () => { + const requestBody = { + name: "Test Weather Station", + location: [7.596, 51.969], + exposure: "outdoor", + model: "homeV2Wifi", + grouptag: ["weather", "test"], + sensors: [ + { + id: "0", + title: "Temperature", + unit: "°C", + sensorType: "HDC1080", + }, + { + id: "1", + title: "Humidity", + unit: "%", + sensorType: "HDC1080", + }, + ], + }; + + const request = new Request(`${BASE_URL}/boxes`, { + method: "POST", + headers: { + Authorization: `Bearer ${jwt}`, + "Content-Type": "application/json", + }, + body: JSON.stringify(requestBody), + }); + + const response = (await action({ + request, + } as ActionFunctionArgs)) as Response; + const body = await response.json(); + + if (body._id) { + createdDeviceIds.push(body._id); + } + + expect(response.status).toBe(201); + expect(body).toHaveProperty("_id"); + expect(body).toHaveProperty("name", "Test Weather Station"); + expect(body).toHaveProperty("sensors"); + expect(body.sensors).toHaveLength(2); + expect(body.sensors[0]).toHaveProperty("title", "Temperature"); + expect(body.sensors[1]).toHaveProperty("title", "Humidity"); + }); + + it("should create a box with minimal data (no sensors)", async () => { + const requestBody = { + name: "Minimal Test Box", + location: [7.5, 51.9], + }; + + const request = new Request(`${BASE_URL}/boxes`, { + method: "POST", + headers: { + Authorization: `Bearer ${jwt}`, + "Content-Type": "application/json", + }, + body: JSON.stringify(requestBody), + }); + + const response = (await action({ + request, + } as ActionFunctionArgs)) as Response; + const body = await response.json(); + + if (body._id) { + createdDeviceIds.push(body._id); + } + + expect(response.status).toBe(201); + expect(body).toHaveProperty("_id"); + expect(body).toHaveProperty("name", "Minimal Test Box"); + expect(body).toHaveProperty("sensors"); + expect(Array.isArray(body.sensors)).toBe(true); + expect(body.sensors).toHaveLength(0); + }); + + it("should reject creation without authentication", async () => { + const requestBody = { + name: "Unauthorized Box", + location: [7.5, 51.9], + }; + + const request = new Request(`${BASE_URL}/boxes`, { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify(requestBody), + }); + + const response = (await action({ + request, + } as ActionFunctionArgs)) as Response; + const body = await response.json(); + + expect(response.status).toBe(403); + expect(body).toHaveProperty("code", "Forbidden"); + expect(body).toHaveProperty("message"); + }); + + it("should reject creation with invalid JWT", async () => { + const requestBody = { + name: "Invalid JWT Box", + location: [7.5, 51.9], + }; + + const request = new Request(`${BASE_URL}/boxes`, { + method: "POST", + headers: { + Authorization: "Bearer invalid_jwt_token", + "Content-Type": "application/json", + }, + body: JSON.stringify(requestBody), + }); + + const response = (await action({ + request, + } as ActionFunctionArgs)) as Response; + const body = await response.json(); + + expect(response.status).toBe(403); + expect(body).toHaveProperty("code", "Forbidden"); + }); + + it("should reject creation with missing required fields", async () => { + const requestBody = { + location: [7.5, 51.9], + }; + + const request = new Request(`${BASE_URL}/boxes`, { + method: "POST", + headers: { + Authorization: `Bearer ${jwt}`, + "Content-Type": "application/json", + }, + body: JSON.stringify(requestBody), + }); + + const response = (await action({ + request, + } as ActionFunctionArgs)) as Response; + const body = await response.json(); + + expect(response.status).toBe(400); + expect(body).toHaveProperty("code", "Bad Request"); + expect(body).toHaveProperty("errors"); + expect(Array.isArray(body.errors)).toBe(true); + }); + + it("should reject creation with invalid location format", async () => { + const requestBody = { + name: "Invalid Location Box", + location: [7.5], + }; + + const request = new Request(`${BASE_URL}/boxes`, { + method: "POST", + headers: { + Authorization: `Bearer ${jwt}`, + "Content-Type": "application/json", + }, + body: JSON.stringify(requestBody), + }); + + const response = (await action({ + request, + } as ActionFunctionArgs)) as Response; + const body = await response.json(); + + expect(response.status).toBe(400); + expect(body).toHaveProperty("code", "Bad Request"); + expect(body).toHaveProperty("errors"); + }); + + it("should reject creation with invalid JSON", async () => { + const request = new Request(`${BASE_URL}/boxes`, { + method: "POST", + headers: { + Authorization: `Bearer ${jwt}`, + "Content-Type": "application/json", + }, + body: "invalid json {", + }); + + const response = (await action({ + request, + } as ActionFunctionArgs)) as Response; + const body = await response.json(); + + expect(response.status).toBe(400); + expect(body).toHaveProperty("code", "Bad Request"); + expect(body).toHaveProperty("message", "Invalid JSON in request body"); + }); + + it("should create box with default values for optional fields", async () => { + const requestBody = { + name: "Default Values Box", + location: [7.5, 51.9], + }; + + const request = new Request(`${BASE_URL}/boxes`, { + method: "POST", + headers: { + Authorization: `Bearer ${jwt}`, + "Content-Type": "application/json", + }, + body: JSON.stringify(requestBody), + }); + + const response = (await action({ + request, + } as ActionFunctionArgs)) as Response; + const body = await response.json(); + + if (body._id) { + createdDeviceIds.push(body._id); + } + + expect(response.status).toBe(201); + expect(body).toHaveProperty("exposure", "unknown"); + expect(body).toHaveProperty("model", "Custom"); + expect(body).toHaveProperty("grouptag"); + expect(body.grouptag).toEqual([]); + }); + }); + + describe("Method Not Allowed", () => { + it("should return 405 for GET requests", async () => { + const request = new Request(`${BASE_URL}/boxes`, { + method: "GET", + headers: { + Authorization: `Bearer ${jwt}`, + }, + }); + + const response = (await action({ + request, + } as ActionFunctionArgs)) as Response; + const body = await response.json(); + + expect(response.status).toBe(405); + expect(body).toHaveProperty("message", "Method Not Allowed"); + }); + + it("should return 405 for PUT requests", async () => { + const request = new Request(`${BASE_URL}/boxes`, { + method: "PUT", + headers: { + Authorization: `Bearer ${jwt}`, + "Content-Type": "application/json", + }, + body: JSON.stringify({ name: "Test" }), + }); + + const response = (await action({ + request, + } as ActionFunctionArgs)) as Response; + const body = await response.json(); + + expect(response.status).toBe(405); + expect(body).toHaveProperty("message", "Method Not Allowed"); + }); + + it("should return 405 for DELETE requests", async () => { + const request = new Request(`${BASE_URL}/boxes`, { + method: "DELETE", + headers: { + Authorization: `Bearer ${jwt}`, + }, + }); + + const response = (await action({ + request, + } as ActionFunctionArgs)) as Response; + const body = await response.json(); + + expect(response.status).toBe(405); + expect(body).toHaveProperty("message", "Method Not Allowed"); + }); + + it("should return 405 for PATCH requests", async () => { + const request = new Request(`${BASE_URL}/boxes`, { + method: "PATCH", + headers: { + Authorization: `Bearer ${jwt}`, + "Content-Type": "application/json", + }, + body: JSON.stringify({ name: "Test" }), + }); + + const response = (await action({ + request, + } as ActionFunctionArgs)) as Response; + const body = await response.json(); + + expect(response.status).toBe(405); + expect(body).toHaveProperty("message", "Method Not Allowed"); + }); + }); +}); diff --git a/tests/routes/api.users.me.boxes.spec.ts b/tests/routes/api.users.me.boxes.spec.ts index 87170e1c..8a6eb4aa 100644 --- a/tests/routes/api.users.me.boxes.spec.ts +++ b/tests/routes/api.users.me.boxes.spec.ts @@ -55,19 +55,111 @@ describe("openSenseMap API Routes: /users", () => { } as LoaderFunctionArgs)) as Response; const body = await response?.json(); - // Assert expect(response.status).toBe(200); - /** TODO(integrations): readd this once integrations have been implemented */ - // expect(body.data.boxes[0].integrations.mqtt).toEqual({ - // enabled: false, - // }); - // expect(body.data.sharedBoxes[0].integrations.mqtt).toEqual({ - // enabled: false, - // }); + expect(response.headers.get("content-type")).toBe("application/json; charset=utf-8"); + + expect(body).toHaveProperty("code", "Ok"); + expect(body).toHaveProperty("data"); + expect(body.data).toHaveProperty("boxes"); + expect(body.data).toHaveProperty("boxes_count"); + expect(body.data).toHaveProperty("sharedBoxes"); + + expect(Array.isArray(body.data.boxes)).toBe(true); + expect(body.data.boxes_count).toBe(body.data.boxes.length); + expect(Array.isArray(body.data.sharedBoxes)).toBe(true); + + if (body.data.boxes.length > 0) { + const box = body.data.boxes[0]; + + expect(box).toHaveProperty("_id"); + expect(box).toHaveProperty("name"); + expect(box).toHaveProperty("exposure"); + expect(box).toHaveProperty("model"); + expect(box).toHaveProperty("grouptag"); + expect(box).toHaveProperty("createdAt"); + expect(box).toHaveProperty("updatedAt"); + expect(box).toHaveProperty("useAuth"); + + expect(box).toHaveProperty("currentLocation"); + expect(box.currentLocation).toHaveProperty("type", "Point"); + expect(box.currentLocation).toHaveProperty("coordinates"); + expect(box.currentLocation).toHaveProperty("timestamp"); + expect(Array.isArray(box.currentLocation.coordinates)).toBe(true); + expect(box.currentLocation.coordinates).toHaveLength(2); + + expect(box).toHaveProperty("lastMeasurementAt"); + expect(box).toHaveProperty("loc"); + expect(Array.isArray(box.loc)).toBe(true); + expect(box.loc[0]).toHaveProperty("geometry"); + expect(box.loc[0]).toHaveProperty("type", "Feature"); + + expect(box).toHaveProperty("integrations"); + expect(box.integrations).toHaveProperty("mqtt"); + expect(box.integrations.mqtt).toHaveProperty("enabled", false); + + expect(box).toHaveProperty("sensors"); + expect(Array.isArray(box.sensors)).toBe(true); + } + }); + + it("should return empty boxes array for user with no devices", async () => { + const userWithNoDevices = await registerUser( + "No Devices User", + "nodevices@test.com", + "password123", + "en_US", + ); + const { token: noDevicesJwt } = await createToken(userWithNoDevices as User); + + const request = new Request(`${BASE_URL}/users/me/boxes`, { + method: "GET", + headers: { Authorization: `Bearer ${noDevicesJwt}` }, + }); + + const response = (await loader({ + request, + } as LoaderFunctionArgs)) as Response; + const body = await response?.json(); + + expect(response.status).toBe(200); + expect(body.data.boxes).toHaveLength(0); + expect(body.data.boxes_count).toBe(0); + expect(body.data.sharedBoxes).toHaveLength(0); + + await deleteUserByEmail("nodevices@test.com"); + }); + + it("should handle invalid JWT token", async () => { + const request = new Request(`${BASE_URL}/users/me/boxes`, { + method: "GET", + headers: { Authorization: `Bearer invalid-token` }, + }); + + const response = (await loader({ + request, + } as LoaderFunctionArgs)) as Response; + const body = await response?.json(); + + expect(response.status).toBe(403); + expect(body.code).toBe("Forbidden"); + expect(body.message).toContain("Invalid JWT authorization"); + }); + + it("should handle missing authorization header", async () => { + const request = new Request(`${BASE_URL}/users/me/boxes`, { + method: "GET", + }); + + const response = (await loader({ + request, + } as LoaderFunctionArgs)) as Response; + const body = await response?.json(); + + expect(response.status).toBe(403); + expect(body.code).toBe("Forbidden"); }); afterAll(async () => { - // delete the valid test user await deleteUserByEmail(BOXES_TEST_USER.email); await deleteDevice({ id: deviceId }); });