diff --git a/app/components/device-detail/device-detail-box.tsx b/app/components/device-detail/device-detail-box.tsx index e92bef07..c15df07f 100644 --- a/app/components/device-detail/device-detail-box.tsx +++ b/app/components/device-detail/device-detail-box.tsx @@ -1,677 +1,677 @@ -import clsx from 'clsx' -import { format, formatDistanceToNow } from 'date-fns' +import clsx from "clsx"; +import { format, formatDistanceToNow } from "date-fns"; import { - ChevronUp, - Minus, - Share2, - XSquare, - EllipsisVertical, - X, - ExternalLink, - Scale, - Archive, - Cpu, - Rss, - CalendarPlus, - Hash, - LandPlot, - Image as ImageIcon, -} from 'lucide-react' -import { Fragment, useEffect, useRef, useState } from 'react' -import { isTablet, isBrowser } from 'react-device-detect' -import Draggable, { type DraggableData } from 'react-draggable' + ChevronUp, + Minus, + Share2, + XSquare, + EllipsisVertical, + X, + ExternalLink, + Scale, + Archive, + Cpu, + Rss, + CalendarPlus, + Hash, + LandPlot, + Image as ImageIcon, +} from "lucide-react"; +import { Fragment, useEffect, useRef, useState } from "react"; +import { isTablet, isBrowser } from "react-device-detect"; +import Draggable, { type DraggableData } from "react-draggable"; import { - useLoaderData, - useMatches, - useNavigate, - useNavigation, - useParams, - useSearchParams, - Link, -} from 'react-router' -import SensorIcon from '../sensor-icon' -import Spinner from '../spinner' + useLoaderData, + useMatches, + useNavigate, + useNavigation, + useParams, + useSearchParams, + Link, +} from "react-router"; +import SensorIcon from "../sensor-icon"; +import Spinner from "../spinner"; import { - Accordion, - AccordionContent, - AccordionItem, - AccordionTrigger, -} from '../ui/accordion' -import { Alert, AlertDescription, AlertTitle } from '../ui/alert' + Accordion, + AccordionContent, + AccordionItem, + AccordionTrigger, +} from "../ui/accordion"; +import { Alert, AlertDescription, AlertTitle } from "../ui/alert"; import { - AlertDialog, - AlertDialogCancel, - AlertDialogContent, - AlertDialogFooter, - AlertDialogHeader, - AlertDialogTitle, - AlertDialogTrigger, -} from '../ui/alert-dialog' -import { Badge } from '../ui/badge' -import { Button } from '../ui/button' + AlertDialog, + AlertDialogCancel, + AlertDialogContent, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, + AlertDialogTrigger, +} from "../ui/alert-dialog"; +import { Badge } from "../ui/badge"; +import { Button } from "../ui/button"; import { - Card, - CardContent, - CardFooter, - CardHeader, - CardTitle, -} from '../ui/card' + Card, + CardContent, + CardFooter, + CardHeader, + CardTitle, +} from "../ui/card"; import { - DropdownMenu, - DropdownMenuContent, - DropdownMenuItem, - DropdownMenuLabel, - DropdownMenuSeparator, - DropdownMenuTrigger, -} from '../ui/dropdown-menu' -import { Separator } from '../ui/separator' + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuLabel, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from "../ui/dropdown-menu"; +import { Separator } from "../ui/separator"; import { - Tooltip, - TooltipContent, - TooltipProvider, - TooltipTrigger, -} from '../ui/tooltip' -import { useToast } from '../ui/use-toast' -import EntryLogs from './entry-logs' -import ShareLink from './share-link' -import { useGlobalCompareMode } from './useGlobalCompareMode' -import { type loader } from '~/routes/explore.$deviceId' -import { type SensorWithLatestMeasurement } from '~/schema' -import { getArchiveLink } from '~/utils/device' + Tooltip, + TooltipContent, + TooltipProvider, + TooltipTrigger, +} from "../ui/tooltip"; +import { useToast } from "../ui/use-toast"; +import EntryLogs from "./entry-logs"; +import ShareLink from "./share-link"; +import { useGlobalCompareMode } from "./useGlobalCompareMode"; +import { type loader } from "~/routes/explore.$deviceId"; +import { type SensorWithLatestMeasurement } from "~/schema"; +import { getArchiveLink } from "~/utils/device"; export interface MeasurementProps { - sensorId: string - time: Date - value: string - min_value: string - max_value: string + sensorId: string; + time: Date; + value: string; + min_value: string; + max_value: string; } export default function DeviceDetailBox() { - const navigation = useNavigation() - const navigate = useNavigate() - const matches = useMatches() - const { toast } = useToast() + const navigation = useNavigation(); + const navigate = useNavigate(); + const matches = useMatches(); + const { toast } = useToast(); - const sensorIds = new Set() + const sensorIds = new Set(); - const data = useLoaderData() - const nodeRef = useRef(null) - // state variables - const [open, setOpen] = useState(true) - const [offsetPositionX, setOffsetPositionX] = useState(0) - const [offsetPositionY, setOffsetPositionY] = useState(0) - const [compareMode, setCompareMode] = useGlobalCompareMode() - const [refreshOn] = useState(false) - const [refreshSecond, setRefreshSecond] = useState(59) + const data = useLoaderData(); + const nodeRef = useRef(null); + // state variables + const [open, setOpen] = useState(true); + const [offsetPositionX, setOffsetPositionX] = useState(0); + const [offsetPositionY, setOffsetPositionY] = useState(0); + const [compareMode, setCompareMode] = useGlobalCompareMode(); + const [refreshOn] = useState(false); + const [refreshSecond, setRefreshSecond] = useState(59); - const [sensors, setSensors] = useState() - useEffect(() => { - const sortedSensors = [...data.sensors].sort( - (a, b) => (a.id as unknown as number) - (b.id as unknown as number), - ) - setSensors(sortedSensors) - }, [data.sensors]) + const [sensors, setSensors] = useState(); + useEffect(() => { + const sortedSensors = [...data.sensors].sort( + (a, b) => (a.id as unknown as number) - (b.id as unknown as number), + ); + setSensors(sortedSensors); + }, [data]); - const [searchParams] = useSearchParams() + const [searchParams] = useSearchParams(); - const { deviceId } = useParams() // Get the deviceId from the URL params + const { deviceId } = useParams(); // Get the deviceId from the URL params - const createSensorLink = (sensorIdToBeSelected: string) => { - const lastSegment = matches[matches.length - 1]?.params?.['*'] - if (lastSegment) { - const secondLastSegment = matches[matches.length - 2]?.params?.sensorId - sensorIds.add(secondLastSegment) - sensorIds.add(lastSegment) - } else { - const lastSegment = matches[matches.length - 1]?.params?.sensorId - if (lastSegment) { - sensorIds.add(lastSegment) - } - } + const createSensorLink = (sensorIdToBeSelected: string) => { + const lastSegment = matches[matches.length - 1]?.params?.["*"]; + if (lastSegment) { + const secondLastSegment = matches[matches.length - 2]?.params?.sensorId; + sensorIds.add(secondLastSegment); + sensorIds.add(lastSegment); + } else { + const lastSegment = matches[matches.length - 1]?.params?.sensorId; + if (lastSegment) { + sensorIds.add(lastSegment); + } + } - // If sensorIdToBeSelected is second selected sensor - if (sensorIds.has(sensorIdToBeSelected) && sensorIds.size === 2) { - const clonedSet = new Set(sensorIds) - clonedSet.delete(sensorIdToBeSelected) - return `/explore/${deviceId}/${Array.from(clonedSet).join('/')}?${searchParams.toString()}` - } else if (sensorIds.has(sensorIdToBeSelected) && sensorIds.size === 1) { - return `/explore/${deviceId}?${searchParams.toString()}` - } else if (sensorIds.size === 0) { - return `/explore/${deviceId}/${sensorIdToBeSelected}?${searchParams.toString()}` - } else if (sensorIds.size === 1) { - return `/explore/${deviceId}/${Array.from(sensorIds).join('/')}/${sensorIdToBeSelected}?${searchParams.toString()}` - } + // If sensorIdToBeSelected is second selected sensor + if (sensorIds.has(sensorIdToBeSelected) && sensorIds.size === 2) { + const clonedSet = new Set(sensorIds); + clonedSet.delete(sensorIdToBeSelected); + return `/explore/${deviceId}/${Array.from(clonedSet).join("/")}?${searchParams.toString()}`; + } else if (sensorIds.has(sensorIdToBeSelected) && sensorIds.size === 1) { + return `/explore/${deviceId}?${searchParams.toString()}`; + } else if (sensorIds.size === 0) { + return `/explore/${deviceId}/${sensorIdToBeSelected}?${searchParams.toString()}`; + } else if (sensorIds.size === 1) { + return `/explore/${deviceId}/${Array.from(sensorIds).join("/")}/${sensorIdToBeSelected}?${searchParams.toString()}`; + } - return '' - } + return ""; + }; - const isSensorActive = (sensorId: string) => { - if (sensorIds.has(sensorId)) { - return 'bg-green-100 dark:bg-dark-green' - } + const isSensorActive = (sensorId: string) => { + if (sensorIds.has(sensorId)) { + return "bg-green-100 dark:bg-dark-green"; + } - return 'hover:bg-muted' - } + return "hover:bg-muted"; + }; - function handleDrag(_e: any, data: DraggableData) { - setOffsetPositionX(data.x) - setOffsetPositionY(data.y) - } + function handleDrag(_e: any, data: DraggableData) { + setOffsetPositionX(data.x); + setOffsetPositionY(data.y); + } - const addLineBreaks = (text: string) => - text.split('\\n').map((text, index) => ( - - {text} -
-
- )) + const addLineBreaks = (text: string) => + text.split("\\n").map((text, index) => ( + + {text} +
+
+ )); - useEffect(() => { - let interval: any = null - if (refreshOn) { - if (refreshSecond == 0) { - setRefreshSecond(59) - } - interval = setInterval(() => { - setRefreshSecond((refreshSecond) => refreshSecond - 1) - }, 1000) - } else if (!refreshOn) { - clearInterval(interval) - } - return () => clearInterval(interval) - }, [refreshOn, refreshSecond]) + useEffect(() => { + let interval: any = null; + if (refreshOn) { + if (refreshSecond == 0) { + setRefreshSecond(59); + } + interval = setInterval(() => { + setRefreshSecond((refreshSecond) => refreshSecond - 1); + }, 1000); + } else if (!refreshOn) { + clearInterval(interval); + } + return () => clearInterval(interval); + }, [refreshOn, refreshSecond]); - if (!data.device) return null + if (!data.device) return null; - return ( - <> - {open && ( - } - defaultPosition={{ x: offsetPositionX, y: offsetPositionY }} - onDrag={handleDrag} - bounds="#osem" - handle="#deviceDetailBoxTop" - disabled={!isBrowser && !isTablet} - > -
-
- {navigation.state === 'loading' && ( -
- -
- )} -
-
-
- {data.device.name} -
- - - - - - - Share this link - - - - Close - - - - - - - - - Actions - - - - Compare - - - - - - Archive - - - - - - - - External Link - - - - - + return ( + <> + {open && ( + } + defaultPosition={{ x: offsetPositionX, y: offsetPositionY }} + onDrag={handleDrag} + bounds="#osem" + handle="#deviceDetailBoxTop" + disabled={!isBrowser && !isTablet} + > +
+
+ {navigation.state === "loading" && ( +
+ +
+ )} +
+
+
+ {data.device.name} +
+ + + + + + + Share this link + + + + Close + + + + + + + + + Actions + + + + Compare + + + + + + Archive + + + + + + + + External Link + + + + + - setOpen(false)} - /> - { - void navigate({ - pathname: '/explore', - search: searchParams.toString(), - }) - }} - /> -
-
-
-
- {data.device.image ? ( - device_image - ) : ( -
- -
- )} -
-
- - - - - - - {data.device.expiresAt && ( - <> - - - - )} -
-
- {data.device.tags && data.device.tags.length > 0 && ( -
-
-
- Tags -
-
- -
- {data.device.tags.map((tag: string) => ( - { - event.stopPropagation() + setOpen(false)} + /> + { + void navigate({ + pathname: "/explore", + search: searchParams.toString(), + }); + }} + /> +
+
+
+
+ {data.device.image ? ( + device_image + ) : ( +
+ +
+ )} +
+
+ + + + + + + {data.device.expiresAt && ( + <> + + + + )} +
+
+ {data.device.tags && data.device.tags.length > 0 && ( +
+
+
+ Tags +
+
+ +
+ {data.device.tags.map((tag: string) => ( + { + event.stopPropagation(); - const currentParams = new URLSearchParams( - searchParams.toString(), - ) + const currentParams = new URLSearchParams( + searchParams.toString(), + ); - // Safely retrieve and parse the current tags - const currentTags = - currentParams.get('tags')?.split(',') || [] + // Safely retrieve and parse the current tags + const currentTags = + currentParams.get("tags")?.split(",") || []; - // Toggle the tag in the list - const updatedTags = currentTags.includes(tag) - ? currentTags.filter((t) => t !== tag) // Remove if already present - : [...currentTags, tag] // Add if not present + // Toggle the tag in the list + const updatedTags = currentTags.includes(tag) + ? currentTags.filter((t) => t !== tag) // Remove if already present + : [...currentTags, tag]; // Add if not present - // Update the tags parameter or remove it if empty - if (updatedTags.length > 0) { - currentParams.set( - 'tags', - updatedTags.join(','), - ) - } else { - currentParams.delete('tags') - } + // Update the tags parameter or remove it if empty + if (updatedTags.length > 0) { + currentParams.set( + "tags", + updatedTags.join(","), + ); + } else { + currentParams.delete("tags"); + } - // Update the URL with the new search params - void navigate({ - search: currentParams.toString(), - }) - }} - > - {tag} - - ))} -
-
-
-
- )} - - {data.device.logEntries.length > 0 && ( - <> - - - - )} - {data.device.description && ( - - - - Description - - - {addLineBreaks(data.device.description)} - - - - )} - - - - Sensors - - -
-
- {sensors && - sensors.map( - (sensor: SensorWithLatestMeasurement) => { - const sensorLink = createSensorLink(sensor.id) - if (sensorLink === '') { - return ( - - toast({ - title: - 'Cant select more than 2 sensors', - description: - 'Deselect one sensor to select another', - variant: 'destructive', - }) - } - > - - - ) - } - return ( - - - - - - ) - }, - )} -
-
-
-
-
-
-
-
- - )} - {compareMode && ( - - { - setCompareMode(!compareMode) - setOpen(true) - }} - /> - Compare devices - - Choose a device from the map to compare with. - - - )} - {!open && ( -
{ - setOpen(true) - }} - className="absolute bottom-[10px] left-4 flex cursor-pointer rounded-xl border border-gray-100 bg-white shadow-lg transition-colors duration-300 ease-in-out hover:brightness-90 dark:bg-zinc-800 dark:text-zinc-200 dark:opacity-90 sm:bottom-[30px] sm:left-[10px]" - > - - - -
- -
-
- -

Open device details

-
-
-
-
- )} - - ) + // Update the URL with the new search params + void navigate({ + search: currentParams.toString(), + }); + }} + > + {tag} + + ))} +
+
+
+
+ )} + + {data.device.logEntries.length > 0 && ( + <> + + + + )} + {data.device.description && ( + + + + Description + + + {addLineBreaks(data.device.description)} + + + + )} + + + + Sensors + + +
+
+ {sensors && + sensors.map( + (sensor: SensorWithLatestMeasurement) => { + const sensorLink = createSensorLink(sensor.id); + if (sensorLink === "") { + return ( + + toast({ + title: + "Cant select more than 2 sensors", + description: + "Deselect one sensor to select another", + variant: "destructive", + }) + } + > + + + ); + } + return ( + + + + + + ); + }, + )} +
+
+
+
+
+
+
+
+
+ )} + {compareMode && ( + + { + setCompareMode(!compareMode); + setOpen(true); + }} + /> + Compare devices + + Choose a device from the map to compare with. + + + )} + {!open && ( +
{ + setOpen(true); + }} + className="absolute bottom-[10px] left-4 flex cursor-pointer rounded-xl border border-gray-100 bg-white shadow-lg transition-colors duration-300 ease-in-out hover:brightness-90 dark:bg-zinc-800 dark:text-zinc-200 dark:opacity-90 sm:bottom-[30px] sm:left-[10px]" + > + + + +
+ +
+
+ +

Open device details

+
+
+
+
+ )} + + ); } const InfoItem = ({ - icon: Icon, - title, - text, + icon: Icon, + title, + text, }: { - icon: React.ElementType - title: string - text?: string + icon: React.ElementType; + title: string; + text?: string; }) => - text && ( -
-
{title}
-
- - {text} -
-
- ) + text && ( +
+
{title}
+
+ + {text} +
+
+ ); diff --git a/app/lib/devices-service.server.ts b/app/lib/devices-service.server.ts new file mode 100644 index 00000000..0871a4e9 --- /dev/null +++ b/app/lib/devices-service.server.ts @@ -0,0 +1,99 @@ +import { Device, User } from '~/schema' +import { + deleteDevice as deleteDeviceById, +} from '~/models/device.server' +import { verifyLogin } from '~/models/user.server' +import { z } from 'zod' + +export const BoxesQuerySchema = z.object({ + format: z.enum(["json", "geojson"] ,{ + errorMap: () => ({ message: "Format must be either 'json' or 'geojson'" }), + }).default("json"), + minimal: z.enum(["true", "false"]).default("false") + .transform((v) => v === "true"), + full: z.enum(["true", "false"]).default("false") + .transform((v) => v === "true"), + limit: z + .string() + .default("5") + .transform((val) => parseInt(val, 10)) + .refine((val) => !isNaN(val), { message: "Limit must be a number" }) + .refine((val) => val >= 1, { message: "Limit must be at least 1" }) + .refine((val) => val <= 20, { message: "Limit must not exceed 20" }), + + name: z.string().optional(), + date: z + .union([z.string().datetime(), z.array(z.string().datetime())]) + .transform((val) => (Array.isArray(val) ? val : [val])) + .refine((arr) => arr.length >= 1 && arr.length <= 2, { + message: "Date must contain 1 or 2 timestamps", + }) + .optional(), + phenomenon: z.string().optional(), + grouptag: z.string().transform((v) => [v]).optional(), + model: z.string().transform((v) => [v]).optional(), + exposure: z.string().transform((v) => [v]).optional(), + + near: z + .string() + .regex(/^[-+]?\d+(\.\d+)?,[-+]?\d+(\.\d+)?$/, { + message: "Invalid 'near' parameter format. Expected: 'lat,lng'", + }) + .transform((val) => val.split(",").map(Number) as [number, number]) + .optional(), + + maxDistance: z.string().transform((v) => Number(v)).optional(), + + bbox: z + .string() + .transform((val) => { + const coords = val.split(",").map(Number); + if (coords.length !== 4 || coords.some((n) => isNaN(n))) { + throw new Error("Invalid bbox parameter"); + } + const [swLng, swLat, neLng, neLat] = coords; + return { + coordinates: [ + [ + [swLat, swLng], + [neLat, swLng], + [neLat, neLng], + [swLat, neLng], + [swLat, swLng], + ], + ], + }; + }) + .optional(), + + fromDate: z.string().datetime().transform((v) => new Date(v)).optional(), + toDate: z.string().datetime().transform((v) => new Date(v)).optional(), + }).refine( + (data) => + !(data.date && !data.phenomenon) && !(data.phenomenon && !data.date), + { + message: "Date and phenomenon must be used together", + path: ["date"], + } + ); + + + export type BoxesQueryParams = z.infer; + +/** + * Deletes a device after verifiying that the user is entitled by checking + * the password. + * @param user The user deleting the device + * @param password The users password to verify + * @returns True if the device was deleted, otherwise false or "unauthorized" + * if the user is not entitled to delete the device with the given parameters + */ +export const deleteDevice = async ( + user: User, + device: Device, + password: string, +): Promise => { + const verifiedUser = await verifyLogin(user.email, password) + if (verifiedUser === null) return 'unauthorized' + return (await deleteDeviceById({ id: device.id })).count > 0 +} diff --git a/app/lib/measurement-service.server.ts b/app/lib/measurement-service.server.ts new file mode 100644 index 00000000..c0937a07 --- /dev/null +++ b/app/lib/measurement-service.server.ts @@ -0,0 +1,27 @@ +import { getDeviceWithoutSensors } from "~/models/device.server"; +import { getSensorsWithLastMeasurement } from "~/models/sensor.server"; + +/** + * + * @param boxId + * @param sensorId + * @param count + */ +export const getLatestMeasurements = async ( + boxId: string, + sensorId: string | undefined, + count: number | undefined, +): Promise => { + const device = await getDeviceWithoutSensors({ id: boxId }); + if (!device) return null; + + const sensorsWithMeasurements = await getSensorsWithLastMeasurement( + device.id, + sensorId, + count, + ); + if (sensorId !== undefined) return sensorsWithMeasurements; // single sensor, no need for having info about device + + (device as any).sensors = sensorsWithMeasurements; + return device; +}; diff --git a/app/lib/openapi.ts b/app/lib/openapi.ts index c9b168be..061484b8 100644 --- a/app/lib/openapi.ts +++ b/app/lib/openapi.ts @@ -1,10 +1,15 @@ import swaggerJsdoc from "swagger-jsdoc"; +const DEV_SERVER = { + url: "http://localhost:3000", + description: "Development server", +}; + const options: swaggerJsdoc.Options = { definition: { openapi: "3.0.0", info: { - title: "", + title: "openSenseMap API", version: "1.0.0", description: `## Documentation of the routes and methods to manage users, stations (also called boxes or senseBoxes), and measurements in the openSenseMap API. You can find the API running at [https://opensensemap.org/api/](https://opensensemap.org/api/). # Timestamps @@ -46,14 +51,11 @@ const options: swaggerJsdoc.Options = { ## If there is something unclear or there is a mistake in this documentation please open an [issue](https://github.com/openSenseMap/frontend/issues/new) in the GitHub repository.`, }, servers: [ + ...(process.env.NODE_ENV !== "production" ? [DEV_SERVER] : []), { url: process.env.OSEM_API_URL || "https://opensensemap.org/api", // Uses environment variable or defaults to production URL description: "Production server", }, - { - url: "http://localhost:3000", - description: "Development server", - }, ], components: { schemas: { diff --git a/app/lib/transfer-service.server.ts b/app/lib/transfer-service.server.ts new file mode 100644 index 00000000..3d551d7b --- /dev/null +++ b/app/lib/transfer-service.server.ts @@ -0,0 +1,129 @@ +import { createTransfer, getTransfer, removeTransfer, TransferCode } from "~/models/transfer.server"; +import { getDevice } from "~/models/device.server"; +import { claim, Claim, device } from "~/schema"; +import { drizzleClient } from "~/db.server"; +import { eq } from "drizzle-orm"; + + +export const createBoxTransfer = async ( + userId: string, + boxId: string, + expiresAt?: string + ): Promise => { + const box = await getDevice({id: boxId}); + if (!box) { + throw new Error("Box not found"); + } + + if (box.user.id !== userId) { + throw new Error("You don't have permission to transfer this box"); + } + + let expirationDate: Date; + if (expiresAt) { + expirationDate = new Date(expiresAt); + if (isNaN(expirationDate.getTime())) { + throw new Error("Invalid expiration date format"); + } + } else { + expirationDate = new Date(); + expirationDate.setHours(expirationDate.getHours() + 24); + } + + if (expirationDate <= new Date()) { + throw new Error("Expiration date must be in the future"); + } + + const transferCode = await createTransfer(boxId, userId, expirationDate); + + return transferCode; + }; + + export const getBoxTransfer = async ( + boxId: string, + ): Promise => { + const transfer = await getTransfer({id: boxId}); + if (!transfer) { + throw new Error("Transfer not found"); + } + + // if (transfer.user.id !== userId) { + // throw new Error("You don't have permission to transfer this box"); + // } + return transfer; + }; + +export const removeBoxTransfer = async ( + userId: string, + boxId: string, + token: string + ): Promise => { + const box = await getDevice({id: boxId}); + if (!box) { + throw new Error("Box not found"); + } + + if (box.user.id !== userId) { + throw new Error("You don't have permission to remove this transfer"); + } + + await removeTransfer(boxId, token); + }; + + export const claimBoxTransfer = async (userId: string, token: string) => { + const [activeClaim] = await drizzleClient + .select() + .from(claim) + .where(eq(claim.token, token)) + .limit(1); + + if (!activeClaim) { + throw new Error("Invalid or expired transfer token"); + } + + if (activeClaim.expiresAt && activeClaim.expiresAt <= new Date()) { + throw new Error("Transfer token has expired"); + } + + const [box] = await drizzleClient + .select() + .from(device) + .where(eq(device.id, activeClaim.boxId)) + .limit(1); + + if (!box) { + throw new Error("Device not found"); + } + + await drizzleClient + .update(device) + .set({ userId }) + .where(eq(device.id, activeClaim.boxId)); + + await drizzleClient + .delete(claim) + .where(eq(claim.id, activeClaim.id)); + + return { message: "Device successfully claimed!", boxId: activeClaim.boxId }; + }; + +export const validateTransferParams = ( + boxId?: string, + expiresAt?: string + ): { isValid: boolean; error?: string } => { + if (!boxId || boxId.trim() === "") { + return { isValid: false, error: "Box ID is required" }; + } + + if (expiresAt) { + const date = new Date(expiresAt); + if (isNaN(date.getTime())) { + return { isValid: false, error: "Invalid date format" }; + } + if (date <= new Date()) { + return { isValid: false, error: "Expiration date must be in the future" }; + } + } + + return { isValid: true }; + }; \ No newline at end of file diff --git a/app/models/device.server.ts b/app/models/device.server.ts index 2f3ff2e4..b42539e6 100644 --- a/app/models/device.server.ts +++ b/app/models/device.server.ts @@ -1,5 +1,5 @@ import { point } from '@turf/helpers' -import { eq, sql, desc } from 'drizzle-orm' +import { eq, sql, desc, ilike, inArray, arrayContains } from 'drizzle-orm' import { type Point } from 'geojson' import { drizzleClient } from '~/db.server' import { device, location, sensor, type Device, type Sensor } from '~/schema' @@ -122,7 +122,13 @@ export function getUserDevices(userId: Device['userId']) { }) } -export async function getDevices() { +type DevicesFormat = 'json' | 'geojson' + +export async function getDevices(format: 'json'): Promise +export async function getDevices(format: 'geojson'): Promise> +export async function getDevices(format?: DevicesFormat): Promise> + +export async function getDevices(format: DevicesFormat = 'json') { const devices = await drizzleClient.query.device.findMany({ columns: { id: true, @@ -135,18 +141,23 @@ export async function getDevices() { tags: true, }, }) - const geojson: GeoJSON.FeatureCollection = { - type: 'FeatureCollection', - features: [], - } - for (const device of devices) { - const coordinates = [device.longitude, device.latitude] - const feature = point(coordinates, device) - geojson.features.push(feature) + if (format === 'geojson') { + const geojson: GeoJSON.FeatureCollection = { + type: 'FeatureCollection', + features: [], + } + + for (const device of devices) { + const coordinates = [device.longitude, device.latitude] + const feature = point(coordinates, device) + geojson.features.push(feature) + } + + return geojson } - return geojson + return devices } export async function getDevicesWithSensors() { @@ -206,6 +217,142 @@ export async function getDevicesWithSensors() { return geojson } +interface BuildWhereClauseOptions { + name?: string; + phenomenon?: string; + fromDate?: string | Date; + toDate?: string | Date; + bbox?: { + coordinates: number[][][]; + }; + near?: [number, number]; // [lat, lng] + maxDistance?: number; + grouptag?: string[]; + exposure?: string[]; + model?: string[]; + } + + export interface FindDevicesOptions extends BuildWhereClauseOptions { + minimal?: string | boolean; + limit?: number; + } + + interface WhereClauseResult { + includeColumns: Record; + whereClause: any[]; + } + + const buildWhereClause = function buildWhereClause( + opts: BuildWhereClauseOptions = {} + ): WhereClauseResult { + const { name, phenomenon, fromDate, toDate, bbox, near, maxDistance, grouptag } = opts; + const clause = []; + const columns = {}; + + if (name) { + clause.push(ilike(device.name, `%${name}%`)); + } + + // if (phenomenon) { + // columns['sensors'] = { + // where: (sensor, { ilike }) => ilike(sensorTable['title'], `%${phenomenon}%`) + // }; + // } + + // simple string parameters + // for (const param of ['exposure', 'model'] as const) { + // if (opts[param]) { + // clause.push(inArray(device[param], opts[param]!)); + // } + // } + + if (grouptag) { + clause.push(arrayContains(device.tags, grouptag)); + } + + // https://orm.drizzle.team/learn/guides/postgis-geometry-point + if (bbox) { + const [latSW, lngSW] = bbox.coordinates[0][0]; + const [latNE, lngNE] = bbox.coordinates[0][2]; + clause.push( + sql`ST_Contains( + ST_MakeEnvelope(${lngSW}, ${latSW}, ${lngNE}, ${latNE}, 4326), + ST_SetSRID(ST_MakePoint(${device.longitude}, ${device.latitude}), 4326) + )` + ); + } + + if (near && maxDistance !== undefined) { + clause.push( + sql`ST_DWithin( + ST_SetSRID(ST_MakePoint(${device.longitude}, ${device.latitude}), 4326), + ST_SetSRID(ST_MakePoint(${near[1]}, ${near[0]}), 4326), + ${maxDistance} + )` + ); + } + + if (fromDate || toDate) { + if (phenomenon) { + // TODO: implement + } + } + + return { + includeColumns: columns, + whereClause: clause + }; + }; + + const MINIMAL_COLUMNS = { + id: true, + name: true, + exposure: true, + longitude: true, + latitude: true + }; + + const DEFAULT_COLUMNS = { + id: true, + name: true, + model: true, + exposure: true, + grouptag: true, + image: true, + description: true, + link: true, + createdAt: true, + updatedAt: true, + longitude: true, + latitude: true + }; + + export async function findDevices ( + opts: FindDevicesOptions = {}, + columns: Record = {}, + relations: Record = {} + ) { + const { minimal, limit } = opts; + const { includeColumns, whereClause } = buildWhereClause(opts); + + columns = (minimal === 'true') ? MINIMAL_COLUMNS : { ...DEFAULT_COLUMNS, ...columns }; + + relations = { + ...relations, + ...includeColumns + }; + const devices = await drizzleClient.query.device.findMany({ + ...(Object.keys(columns).length !== 0 && { columns }), + ...(Object.keys(relations).length !== 0 && { with: relations }), + ...(Object.keys(whereClause).length !== 0 && { + where: (_, { and }) => and(...whereClause) + }), + limit + }); + + return devices; + }; + export async function createDevice(deviceData: any, userId: string) { try { const newDevice = await drizzleClient.transaction(async (tx) => { diff --git a/app/models/sensor.server.ts b/app/models/sensor.server.ts index 74c6b42d..9e5f99ee 100644 --- a/app/models/sensor.server.ts +++ b/app/models/sensor.server.ts @@ -1,6 +1,10 @@ import { eq, sql } from "drizzle-orm"; import { drizzleClient } from "~/db.server"; -import { sensor, type Sensor, type SensorWithLatestMeasurement } from "~/schema"; +import { + sensor, + type Sensor, + type SensorWithLatestMeasurement, +} from "~/schema"; // import { point } from "@turf/helpers"; // import type { Point } from "geojson"; @@ -65,25 +69,63 @@ export function getSensorsFromDevice(deviceId: Sensor["deviceId"]) { }); } -// LATERAL JOIN to get latest measurement for sensors belonging to a specific device, including device name -export function getSensorsWithLastMeasurement(deviceId: Sensor["deviceId"]) { - const result = drizzleClient.execute( - sql`SELECT s.*, d.name AS device_name, measure.* - FROM sensor s - JOIN device d ON s.device_id = d.id - LEFT JOIN LATERAL ( - SELECT * FROM measurement m - WHERE m.sensor_id = s.id - ORDER BY m.time DESC - LIMIT 1 - ) AS measure ON true - WHERE s.device_id = ${deviceId};`, +export async function getSensorsWithLastMeasurement( + deviceId: Sensor["deviceId"], +): Promise; +export async function getSensorsWithLastMeasurement( + deviceId: Sensor["deviceId"], + sensorId: Sensor["id"], +): Promise; +export async function getSensorsWithLastMeasurement( + deviceId: Sensor["deviceId"], + sensorId: Sensor["id"] | undefined = undefined, + count: number = 1, +) { + const result = await drizzleClient.execute( + sql`SELECT + s.id, + s.title, + s.unit, + s.sensor_type, + json_agg( + json_build_object( + 'value', measure.value, + 'createdAt', measure.time + ) + ) FILTER ( + WHERE measure.value IS NOT NULL AND measure.time IS NOT NULL + ) AS "lastMeasurements" + FROM sensor s + JOIN device d ON s.device_id = d.id + LEFT JOIN LATERAL ( + SELECT * FROM measurement m + WHERE m.sensor_id = s.id + ORDER BY m.time DESC + LIMIT ${count} + ) AS measure ON true + WHERE s.device_id = ${deviceId} + GROUP BY s.id;`, ); - return result as unknown as SensorWithLatestMeasurement[]; + const cast = [...result].map((r) => { + if (r["lastMeasurements"] !== null) { + const ret = { + ...r, + lastMeasurement: + (r as any)["lastMeasurements"]["measurements"][0] ?? null, + } as any; + if (count === 1) delete ret["lastMeasurements"]; + return ret; + } else return { ...r, lastMeasurements: [] } as any; + }) as any; + + if (sensorId === undefined) return cast as SensorWithLatestMeasurement[]; + else + return cast.find( + (c: any) => c.id === sensorId, + ) as SensorWithLatestMeasurement; } -//if sensor was registered through osem-frontend the input sensor will have correct sensor-wiki connotations export async function registerSensor(newSensor: Sensor) { const insertedSensor = await drizzleClient .insert(sensor) diff --git a/app/models/transfer.server.ts b/app/models/transfer.server.ts new file mode 100644 index 00000000..5c1c48f5 --- /dev/null +++ b/app/models/transfer.server.ts @@ -0,0 +1,67 @@ +import { Claim, claim, Device } from "~/schema"; +import { drizzleClient } from "~/db.server"; +import { eq } from "drizzle-orm"; + +export interface TransferCode { + id: string; + boxId: string; + token: string; + expiresAt: Date; + createdAt: Date; + } + + export const createTransfer = async ( + boxId: string, + userId: string, + expiresAt: Date + ): Promise => { + const code = generateTransferCode(); + + const [inserted] = await drizzleClient + .insert(claim) + .values({ + boxId, + token: code, + expiresAt: expiresAt, + }) + .returning(); + + return { + id: inserted.id, + boxId: inserted.boxId, + token: inserted.token!, + expiresAt: inserted.expiresAt!, + createdAt: inserted.createdAt!, + }; + }; + + export const generateTransferCode = (): string => { + const crypto = require('crypto'); + return crypto.randomBytes(6).toString('hex'); + }; + + export function getTransfer({ id }: Pick){ + return drizzleClient.query.claim.findFirst({ + where: (claim, {eq}) => eq(claim.boxId, id) + }) + }; + + +export const removeTransfer = async ( + boxId: string, + token: string +): Promise => { + const [existingClaim] = await drizzleClient + .select() + .from(claim) + .where(eq(claim.token, token) && eq(claim.boxId, boxId)); + + if (!existingClaim) { + throw new Error("Transfer token not found"); + } + + await drizzleClient + .delete(claim) + .where(eq(claim.id, existingClaim.id)); +}; + diff --git a/app/routes/api.boxes.$deviceId.sensors.$sensorId.ts b/app/routes/api.boxes.$deviceId.sensors.$sensorId.ts new file mode 100644 index 00000000..5f6445ce --- /dev/null +++ b/app/routes/api.boxes.$deviceId.sensors.$sensorId.ts @@ -0,0 +1,87 @@ +import { type LoaderFunction, type LoaderFunctionArgs } from "react-router"; +import { getLatestMeasurements } from "~/lib/measurement-service.server"; + +export const loader: LoaderFunction = async ({ + request, + params, +}: LoaderFunctionArgs): Promise => { + try { + const deviceId = params.deviceId; + if (deviceId === undefined) + return Response.json( + { + code: "Bad Request", + message: "Invalid device id specified", + }, + { + status: 400, + headers: { + "Content-Type": "application/json; charset=utf-8", + }, + }, + ); + + const sensorId = params.sensorId; + if (sensorId === undefined) + return Response.json( + { + code: "Bad Request", + message: "Invalid sensor id specified", + }, + { + status: 400, + headers: { + "Content-Type": "application/json; charset=utf-8", + }, + }, + ); + + const searchParams = new URL(request.url).searchParams; + const onlyValue = + (searchParams.get("onlyValue")?.toLowerCase() ?? "") === "true"; + if (sensorId === undefined && onlyValue) + return Response.json( + { + code: "Bad Request", + message: "onlyValue can only be used when a sensor id is specified", + }, + { + status: 400, + headers: { + "Content-Type": "application/json; charset=utf-8", + }, + }, + ); + + const meas = await getLatestMeasurements(deviceId, sensorId, undefined); + + if (onlyValue) + return Response.json(meas["lastMeasurement"]?.value ?? null, { + status: 200, + headers: { "Content-Type": "application/json; charset=utf-8" }, + }); + + return Response.json( + { ...meas, _id: meas.id }, // for legacy purposes + { + status: 200, + headers: { "Content-Type": "application/json; charset=utf-8" }, + }, + ); + } catch (err) { + console.warn(err); + return Response.json( + { + error: "Internal Server Error", + message: + "The server was unable to complete your request. Please try again later.", + }, + { + status: 500, + headers: { + "Content-Type": "application/json; charset=utf-8", + }, + }, + ); + } +}; diff --git a/app/routes/api.boxes.$deviceId.sensors.ts b/app/routes/api.boxes.$deviceId.sensors.ts new file mode 100644 index 00000000..f604cf4e --- /dev/null +++ b/app/routes/api.boxes.$deviceId.sensors.ts @@ -0,0 +1,93 @@ +import { type LoaderFunction, type LoaderFunctionArgs } from "react-router"; +import { getLatestMeasurements } from "~/lib/measurement-service.server"; + +/** + * @openapi + * /boxes/{deviceId}/sensors: + * get: + * tags: + * - Sensors + * summary: Get the latest measurements of all sensors of the specified senseBox. + * parameters: + * - in: path + * name: deviceId + * required: true + * schema: + * type: string + * description: the ID of the senseBox you are referring to + * - in: query + * name: count + * required: false + * schema: + * type: integer + * minimum: 1 + * maximum: 100 + * description: Number of measurements to be retrieved for every sensor + * responses: + * 200: + * description: Success + * content: + */ + +export const loader: LoaderFunction = async ({ + request, + params, +}: LoaderFunctionArgs): Promise => { + try { + const deviceId = params.deviceId; + if (deviceId === undefined) + return Response.json( + { + code: "Bad Request", + message: "Invalid device id specified", + }, + { + status: 400, + headers: { + "Content-Type": "application/json; charset=utf-8", + }, + }, + ); + + const url = new URL(request.url); + const countParam = url.searchParams.get("count"); + + let count: undefined | number = undefined; + if (countParam !== null && Number.isNaN(countParam)) + return Response.json( + { + error: "Bad Request", + message: "Illegal value for parameter count. allowed values: numbers", + }, + { + status: 400, + headers: { + "Content-Type": "application/json; charset=utf-8", + }, + }, + ); + count = countParam === null ? undefined : Number(countParam); + + const meas = await getLatestMeasurements(deviceId, undefined, count); + + return Response.json(meas, { + status: 200, + headers: { "Content-Type": "application/json; charset=utf-8" }, + }); + } catch (err) { + console.warn(err); + return Response.json( + { + error: "Internal Server Error", + message: + "The server was unable to complete your request. Please try again later.", + }, + { + status: 500, + headers: { + "Content-Type": "application/json; charset=utf-8", + }, + }, + ); + } +}; diff --git a/app/routes/api.device.$deviceId.ts b/app/routes/api.device.$deviceId.ts new file mode 100644 index 00000000..159aa0b0 --- /dev/null +++ b/app/routes/api.device.$deviceId.ts @@ -0,0 +1,101 @@ +import { json, type LoaderFunctionArgs } from '@remix-run/node' +import { getDevice } from '~/models/device.server' + +/** + * @openapi + * /api/device/{deviceId}: + * get: + * summary: Get device by ID + * description: Retrieve a single device by their unique identifier + * tags: + * - Device + * parameters: + * - in: path + * name: id + * required: true + * description: Unique identifier of the user + * schema: + * type: string + * example: "12345" + * responses: + * 200: + * description: Device retrieved successfully + * content: + * application/json: + * schema: + * type: object + * properties: + * id: + * type: string + * example: "12345" + * name: + * type: string + * example: "John Doe" + * email: + * type: string + * example: "john.doe@example.com" + * createdAt: + * type: string + * format: date-time + * example: "2023-01-15T10:30:00Z" + * 404: + * description: Device not found + * content: + * application/json: + * schema: + * type: object + * properties: + * error: + * type: string + * example: "Device not found" + * 400: + * description: Device ID is required + * content: + * application/json: + * schema: + * type: object + * properties: + * error: + * type: string + * example: "Device ID is required." + * 500: + * description: Internal server error + */ +export async function loader({ params }: LoaderFunctionArgs) { + const { deviceId } = params + + if (!deviceId) { + return new Response(JSON.stringify({ message: 'Device ID is required.' }), { + status: 400, + headers: { + 'content-type': 'application/json; charset=utf-8', + }, + }) + } + + try { + const device = await getDevice({ id: deviceId }) + + if (!device) { + return new Response(JSON.stringify({ message: 'Device not found.' }), { + status: 404, + headers: { + 'content-type': 'application/json; charset=utf-8', + }, + }) + } + + return json(device) + } catch (error) { + console.error('Error fetching box:', error) + + if (error instanceof Response) { + throw error + } + + throw json( + { error: 'Internal server error while fetching box' }, + { status: 500 }, + ) + } +} diff --git a/app/routes/api.devices.ts b/app/routes/api.devices.ts new file mode 100644 index 00000000..f9aae3b1 --- /dev/null +++ b/app/routes/api.devices.ts @@ -0,0 +1,577 @@ +import { json, type LoaderFunctionArgs } from '@remix-run/node' +import { + createDevice, + findDevices, + FindDevicesOptions, + // deleteDevice, + getDevice, + getDevices, + getDevicesWithSensors, +} from '~/models/device.server' +import { ActionFunctionArgs } from 'react-router' +import { getUserFromJwt } from '~/lib/jwt' +import { + BoxesQuerySchema, + deleteDevice, + type BoxesQueryParams, +} from '~/lib/devices-service.server' +import { Device, DeviceExposureType, User } from '~/schema' +import { Point } from 'geojson' + +function fromToTimeParamsSanityCheck(fromDate: Date | null, toDate: Date | null) { + if ((fromDate && !toDate) || (toDate && !fromDate)) { + throw json( + { error: 'fromDate and toDate need to be specified simultaneously' }, + { status: 400 } + ); + } + + if (fromDate && toDate) { + if (fromDate.getTime() > toDate.getTime()) { + throw json( + { error: `Invalid time frame specified: fromDate (${fromDate.toISOString()}) is after toDate (${toDate.toISOString()})` }, + { status: 422 } + ); + } + } + } + + function parseAndValidateTimeParams(searchParams: URLSearchParams) { + const dateParams = searchParams.getAll('date'); + + if (dateParams.length === 0) { + return { fromDate: null, toDate: null }; + } + + if (dateParams.length > 2) { + throw json( + { error: 'invalid number of dates for date parameter supplied' }, + { status: 422 } + ); + } + + const [fromDateStr, toDateStr] = dateParams; + + const fromDate = new Date(fromDateStr); + if (isNaN(fromDate.getTime())) { + throw json( + { error: `Invalid date format: ${fromDateStr}` }, + { status: 422 } + ); + } + + let toDate: Date; + + if (!toDateStr) { + // If only one date provided, create a range of ±4 hours + toDate = new Date(fromDate.getTime() + (4 * 60 * 60 * 1000)); // +4 hours + const adjustedFromDate = new Date(fromDate.getTime() - (4 * 60 * 60 * 1000)); // -4 hours + + return { fromDate: adjustedFromDate, toDate }; + } else { + toDate = new Date(toDateStr); + if (isNaN(toDate.getTime())) { + throw json( + { error: `Invalid date format: ${toDateStr}` }, + { status: 422 } + ); + } + + fromToTimeParamsSanityCheck(fromDate, toDate); + + return { fromDate, toDate }; + } + } + +/** + * @openapi + * /api/devices: + * get: + * tags: + * - Devices + * summary: Get devices with filtering options + * description: Retrieves devices based on various filter criteria. Supports both JSON and GeoJSON formats. + * parameters: + * - name: format + * in: query + * required: false + * schema: + * type: string + * enum: [json, geojson] + * default: json + * description: Response format + * - name: minimal + * in: query + * required: false + * schema: + * type: string + * enum: [true, false] + * default: false + * description: Return minimal device information + * - name: full + * in: query + * required: false + * schema: + * type: string + * enum: [true, false] + * default: false + * description: Return full device information + * - name: limit + * in: query + * required: false + * schema: + * type: integer + * minimum: 1 + * maximum: 20 + * default: 5 + * description: Maximum number of devices to return + * - name: name + * in: query + * required: false + * schema: + * type: string + * description: Filter devices by name + * - name: phenomenon + * in: query + * required: false + * schema: + * type: string + * description: Filter devices by phenomenon type + * - name: fromDate + * in: query + * required: false + * schema: + * type: string + * format: date-time + * description: Filter devices from this date + * example: "2023-05-15T10:00:00Z" + * - name: toDate + * in: query + * required: false + * schema: + * type: string + * format: date-time + * description: Filter devices to this date + * example: "2023-05-15T12:00:00Z" + * - name: grouptag + * in: query + * required: false + * schema: + * type: string + * description: Filter devices by group tag + * - name: exposure + * in: query + * required: false + * schema: + * type: string + * description: Filter devices by exposure type + * - name: near + * in: query + * required: false + * schema: + * type: string + * pattern: '^-?\d+\.?\d*,-?\d+\.?\d*$' + * description: Find devices near coordinates (lat,lng) + * example: "52.5200,13.4050" + * - name: maxDistance + * in: query + * required: false + * schema: + * type: number + * default: 1000 + * description: Maximum distance in meters when using 'near' parameter + * - name: bbox + * in: query + * required: false + * schema: + * type: string + * pattern: '^-?\d+\.?\d*,-?\d+\.?\d*,-?\d+\.?\d*,-?\d+\.?\d*$' + * description: Bounding box coordinates (swLng,swLat,neLng,neLat) + * example: "13.2,52.4,13.6,52.6" + * - name: date + * in: query + * required: false + * schema: + * type: string + * format: date-time + * description: Specific date filter (TODO - not implemented) + * responses: + * 200: + * description: Successfully retrieved devices + * content: + * application/json: + * schema: + * oneOf: + * - type: array + * items: + * $ref: '#/components/schemas/Device' + * - $ref: '#/components/schemas/GeoJSONFeatureCollection' + * 400: + * description: Invalid request + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/ErrorResponse' + * 422: + * description: Invalid parameters + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/ErrorResponse' + * examples: + * invalidFormat: + * summary: Invalid format parameter + * value: + * error: "Failed to fetch devices" + * invalidLimit: + * summary: Invalid limit parameter + * value: + * error: "Limit must be at least 1" + * exceedsLimit: + * summary: Limit exceeds maximum + * value: + * error: "Limit should not exceed 20" + * invalidNear: + * summary: Invalid near parameter + * value: + * error: "Invalid 'near' parameter format. Expected: 'lat,lng'" + * 500: + * description: Internal server error + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/ErrorResponse' + * example: + * error: "Failed to fetch devices" + * + * components: + * schemas: + * Device: + * type: object + * required: + * - id + * - latitude + * - longitude + * properties: + * id: + * type: string + * description: Unique device identifier + * example: "device-123" + * name: + * type: string + * description: Device name + * example: "Temperature Sensor A1" + * latitude: + * type: number + * format: float + * description: Device latitude coordinate + * example: 52.5200 + * longitude: + * type: number + * format: float + * description: Device longitude coordinate + * example: 13.4050 + * phenomenon: + * type: string + * description: Type of phenomenon measured + * example: "temperature" + * grouptag: + * type: string + * description: Group tag for device categorization + * example: "outdoor-sensors" + * exposure: + * type: string + * description: Device exposure type + * example: "outdoor" + * createdAt: + * type: string + * format: date-time + * description: Device creation timestamp + * example: "2023-05-15T10:00:00Z" + * updatedAt: + * type: string + * format: date-time + * description: Device last update timestamp + * example: "2023-05-15T12:00:00Z" + * + * GeoJSONFeatureCollection: + * type: object + * required: + * - type + * - features + * properties: + * type: + * type: string + * enum: [FeatureCollection] + * example: "FeatureCollection" + * features: + * type: array + * items: + * $ref: '#/components/schemas/GeoJSONFeature' + * + * GeoJSONFeature: + * type: object + * required: + * - type + * - geometry + * - properties + * properties: + * type: + * type: string + * enum: [Feature] + * example: "Feature" + * geometry: + * $ref: '#/components/schemas/GeoJSONPoint' + * properties: + * $ref: '#/components/schemas/Device' + * + * GeoJSONPoint: + * type: object + * required: + * - type + * - coordinates + * properties: + * type: + * type: string + * enum: [Point] + * example: "Point" + * coordinates: + * type: array + * items: + * type: number + * minItems: 2 + * maxItems: 2 + * description: Longitude and latitude coordinates + * example: [13.4050, 52.5200] + * + * ErrorResponse: + * type: object + * required: + * - error + * properties: + * error: + * type: string + * description: Error message + * example: "Failed to fetch devices" + */ +export async function loader({ request }: LoaderFunctionArgs) { + const url = new URL(request.url); + const queryObj = Object.fromEntries(url.searchParams); + const max_limit = 20; + const { fromDate, toDate } = parseAndValidateTimeParams(url.searchParams); + + 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 } + ); + } + + throw json( + { error: parseResult.error.flatten() }, + { status: 422 } + ); + } + + 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 Response.json( + { + code: 'Forbidden', + message: + 'Invalid JWT authorization. Please sign in to obtain new JWT.', + }, + { + status: 403, + }, + ) + switch (request.method) { + case 'POST': + return await post(request, jwtResponse) + case 'DELETE': + return await del(request, jwtResponse, params) + default: + return Response.json({ msg: 'Method Not Allowed' }, { status: 405 }) + } + } catch (err) { + console.warn(err) + return Response.json( + { + error: 'Internal Server Error', + message: + 'The server was unable to complete your request. Please try again later.', + }, + { + status: 500, + }, + ) + } +} + +async function del(request: Request, user: User, params: any) { + const { deviceId } = params + + if (!deviceId) { + throw json({ message: 'Device ID is required' }, { status: 400 }) + } + + const device = (await getDevice({ id: deviceId })) as unknown as Device + + if (!device) { + throw json({ message: 'Device not found' }, { status: 404 }) + } + + const body = await request.json() + + if (!body.password) { + throw json( + { message: 'Password is required for device deletion' }, + { status: 400 }, + ) + } + + try { + const deleted = await deleteDevice(user, device, body.password) + + if (deleted === 'unauthorized') + return Response.json( + { message: 'Password incorrect' }, + { + status: 401, + headers: { 'Content-Type': 'application/json; charset=utf-8' }, + }, + ) + + return Response.json(null, { + status: 200, + headers: { 'Content-Type': 'application/json; charset=utf-8' }, + }) + } catch (err) { + console.warn(err) + return new Response('Internal Server Error', { status: 500 }) + } +} + +async function post(request: Request, user: User) { + try { + const body = await request.json() + + if (!body.location) { + throw json( + { message: 'missing required parameter location' }, + { status: 400 }, + ) + } + + let latitude: number, longitude: number, height: number | undefined + + if (Array.isArray(body.location)) { + // Handle array format [lat, lng, height?] + if (body.location.length < 2) { + throw json( + { + message: `Illegal value for parameter location. missing latitude or longitude in location [${body.location.join(',')}]`, + }, + { status: 422 }, + ) + } + latitude = Number(body.location[0]) + longitude = Number(body.location[1]) + height = body.location[2] ? Number(body.location[2]) : undefined + } else if (typeof body.location === 'object' && body.location !== null) { + // Handle object format { lat, lng, height? } + if (!('lat' in body.location) || !('lng' in body.location)) { + throw json( + { + message: + 'Illegal value for parameter location. missing latitude or longitude', + }, + { status: 422 }, + ) + } + latitude = Number(body.location.lat) + longitude = Number(body.location.lng) + height = body.location.height ? Number(body.location.height) : undefined + } else { + throw json( + { + message: + 'Illegal value for parameter location. Expected array or object', + }, + { status: 422 }, + ) + } + + if (isNaN(latitude) || isNaN(longitude)) { + throw json( + { message: 'Invalid latitude or longitude values' }, + { status: 422 }, + ) + } + + const rawAuthorizationHeader = request.headers.get('authorization') + if (!rawAuthorizationHeader) { + throw json({ message: 'Authorization header required' }, { status: 401 }) + } + const [, jwtString] = rawAuthorizationHeader.split(' ') + + const deviceData = { + ...body, + latitude, + longitude, + } + + const newDevice = await createDevice(deviceData, user.id) + + return json( + { + data: { + ...newDevice, + access_token: jwtString, + createdAt: newDevice.createdAt || new Date(), + }, + }, + { status: 201 }, + ) + } catch (error) { + console.error('Error creating device:', error) + + if (error instanceof Response) { + throw error + } + + throw json({ message: 'Internal server error' }, { status: 500 }) + } +} diff --git a/app/routes/api.transfer.$deviceId.ts b/app/routes/api.transfer.$deviceId.ts new file mode 100644 index 00000000..c8881981 --- /dev/null +++ b/app/routes/api.transfer.$deviceId.ts @@ -0,0 +1,185 @@ +import { ActionFunction, LoaderFunction } from "@remix-run/node"; +import { json } from "@remix-run/node"; +import type { ActionFunctionArgs, LoaderFunctionArgs } from "@remix-run/node"; +import { eq } from "drizzle-orm"; +import { drizzleClient } from "~/db.server"; +import { getUserFromJwt } from "~/lib/jwt"; +import { getBoxTransfer } from "~/lib/transfer-service.server"; +import { getTransfer } from "~/models/transfer.server"; +import { claim, device } from "~/schema"; + +export async function loader({ params }: LoaderFunctionArgs) { + const { deviceId } = params + + if (!deviceId) { + return new Response(JSON.stringify({ message: 'Device ID is required.' }), { + status: 400, + headers: { + 'content-type': 'application/json; charset=utf-8', + }, + }) + } + + try { + const claim = await getBoxTransfer(deviceId) + + return json( { + data: { + token: claim.token, + boxId: claim.boxId + } + }, + { status: 200 }) + } catch (error) { + console.error('Error fetching claim:', error) + + if (error instanceof Response) { + throw error + } + + throw json( + { error: 'Internal server error while fetching claim' }, + { status: 500 }, + ) + } +} + + +export const action: ActionFunction = async ({ + request, +}: ActionFunctionArgs) => { + 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, + }, + ) + + const url = new URL(request.url); + + switch (request.method) { + case "PUT": { + return await handleTransferUpdate(request, jwtResponse); + } + // case "DELETE": { + // return await handleCancelTransfer(request, jwtResponse); + // } + default: { + return new Response(null, { status: 405 }); + } + } +}; + + +const handleTransferUpdate = async (request: Request, userObj: any) => { + const formEntries = await request.formData(); + const token = formEntries.get("token")?.toString(); + const expiresAtStr = formEntries.get("expiresAt")?.toString(); + + if (!token) { + return json({ error: "token is required" }, { status: 400 }); + } + if (!expiresAtStr) { + return json({ error: "expiresAt is required" }, { status: 400 }); + } + + const expiresAt = new Date(expiresAtStr); + if (isNaN(expiresAt.getTime())) { + return json({ error: "Invalid expiration date format" }, { status: 400 }); + } + if (expiresAt <= new Date()) { + return json({ error: "Expiration date must be in the future" }, { status: 400 }); + } + + const url = new URL(request.url); + const parts = url.pathname.split("/"); + const claimId = parts[parts.length - 1]; + + const [existing] = await drizzleClient + .select({ + id: claim.id, + token: claim.token, + expiresAt: claim.expiresAt, + boxId: claim.boxId, + deviceUserId: device.userId, + }) + .from(claim) + .innerJoin(device, eq(claim.boxId, device.id)) + .where(eq(claim.id, claimId)); + + if (!existing) { + return json({ error: "Transfer not found" }, { status: 404 }); + } + + if (existing.deviceUserId !== userObj.id) { + return json({ error: "You don't have permission to update this transfer" }, { status: 403 }); + } + + if (existing.token !== token) { + return json({ error: "Invalid transfer token" }, { status: 400 }); + } + + const [updated] = await drizzleClient + .update(claim) + .set({ expiresAt }) + .where(eq(claim.id, claimId)) + .returning(); + + return json( + { + message: "Transfer successfully updated", + data: { + id: updated.id, + boxId: updated.boxId, + token: updated.token, + expiresAt: updated.expiresAt, + createdAt: updated.createdAt, + updatedAt: updated.updatedAt, + }, + }, + { status: 200 } + ); + }; + + const handleGetTransfer = async (request: Request, params: any, userObj: any) => { + const deviceId = params.deviceId; + + if (!deviceId) { + return json({ error: "Device ID is required" }, { status: 400 }); + } + + const [transfer] = await drizzleClient + .select({ + id: claim.id, + boxId: claim.boxId, + token: claim.token, + expiresAt: claim.expiresAt, + createdAt: claim.createdAt, + updatedAt: claim.updatedAt, + deviceUserId: device.userId, + }) + .from(claim) + .innerJoin(device, eq(claim.boxId, device.id)) + .where(eq(claim.boxId, deviceId)); + + if (transfer && transfer.deviceUserId !== userObj.id) { + return json({ error: "You don't have permission to view this transfer" }, { status: 403 }); + } + + return json( + { + data: transfer || null, + }, + { status: 200 } + ); + }; + + + diff --git a/app/routes/api.transfer.ts b/app/routes/api.transfer.ts new file mode 100644 index 00000000..00ae49e5 --- /dev/null +++ b/app/routes/api.transfer.ts @@ -0,0 +1,97 @@ +import { ActionFunction, LoaderFunction } from "@remix-run/node"; +import { json } from "@remix-run/node"; +import type { ActionFunctionArgs, LoaderFunctionArgs } from "@remix-run/node"; +import { createBoxTransfer, removeBoxTransfer } from "~/lib/transfer-service.server"; +import { getUserFromJwt } from "~/lib/jwt"; + +export const action: ActionFunction = async ({ + request, +}: ActionFunctionArgs) => { + 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, + }, + ) + + const url = new URL(request.url); + + switch (request.method) { + case "POST": { + return await handleCreateTransfer(request, jwtResponse); + } + case "DELETE": { + return await handleRemoveTransfer(request, jwtResponse); + } + default: { + return new Response(null, { status: 405 }); + } + } +}; + +const handleCreateTransfer = async (request: Request, user: any) => { + const formEntries = await request.formData(); + const boxId = formEntries.get("boxId")?.toString(); + const expiresAt = formEntries.get("expiresAt")?.toString(); + + if (!boxId) { + return json({ error: "boxId is required" }, { status: 400 }); + } + + try { + const transferCode = await createBoxTransfer(user.id, boxId, expiresAt); + return json( + { + message: 'Box successfully prepared for transfer', + data: transferCode, + }, + { status: 201 } + ); + } catch (err) { + console.error('Error creating transfer:', err); + return json( + { error: err instanceof Error ? err.message : 'Internal server error' }, + { status: err instanceof Error && err.message.includes('permission') ? 403 : 500 } + ); + } +}; + +const handleRemoveTransfer = async (request: Request, user: any) => { + const formEntries = await request.formData(); + const boxId = formEntries.get("boxId")?.toString(); + const token = formEntries.get("token")?.toString(); + + if (!boxId) { + return json({ error: "boxId is required" }, { status: 400 }); + } + + if (!token) { + return json({ error: "token is required" }, { status: 400 }); + } + + try { + await removeBoxTransfer(user.id, boxId, token); + return new Response(null, { status: 204 }); + } catch (err) { + console.error('Error removing transfer:', err); + if (err instanceof Error) { + if (err.message.includes('not found')) { + return json({ error: err.message }, { status: 404 }); + } + if (err.message.includes('permission')) { + return json({ error: err.message }, { status: 403 }); + } + } + return json( + { error: err instanceof Error ? err.message : 'Internal server error' }, + { status: 500 } + ); + } +}; \ No newline at end of file diff --git a/app/routes/api.ts b/app/routes/api.ts index c5f8fbca..4b571d65 100644 --- a/app/routes/api.ts +++ b/app/routes/api.ts @@ -26,10 +26,10 @@ const routes: { noauth: RouteInfo[]; auth: RouteInfo[] } = { // method: "GET", // }, - // { - // path: `boxes`, - // method: "GET", - // }, + { + path: `boxes`, + method: "GET", + }, // { // path: `boxes/data`, // method: "GET", @@ -39,14 +39,14 @@ const routes: { noauth: RouteInfo[]; auth: RouteInfo[] } = { // path: `boxes/:boxId`, // method: "GET", // }, - // { - // path: `boxes/:boxId/sensors`, - // method: "GET", - // }, - // { - // path: `boxes/:boxId/sensors/:sensorId`, - // method: "GET", - // }, + { + path: `boxes/:boxId/sensors`, + method: "GET", + }, + { + path: `boxes/:boxId/sensors/:sensorId`, + method: "GET", + }, // { // path: `boxes/:boxId/data/:sensorId`, // method: "GET", @@ -121,30 +121,30 @@ const routes: { noauth: RouteInfo[]; auth: RouteInfo[] } = { // path: `boxes/claim`, // method: "POST", // }, - // { - // path: `boxes/transfer`, - // method: "POST", - // }, - // { - // path: `boxes/transfer`, - // method: "DELETE", - // }, - // { - // path: `boxes/transfer/:boxId`, - // method: "GET", - // }, - // { - // path: `boxes/transfer/:boxId`, - // method: "PUT", - // }, + { + path: `boxes/transfer`, + method: "POST", + }, + { + path: `boxes/transfer`, + method: "DELETE", + }, + { + path: `boxes/transfer/:boxId`, + method: "GET", + }, + { + path: `boxes/transfer/:boxId`, + method: "PUT", + }, // { // path: `boxes/:boxId`, // method: "PUT", // }, - // { - // path: `boxes/:boxId`, - // method: "DELETE", - // }, + { + path: `boxes/:boxId`, + method: "DELETE", + }, // { // path: `boxes/:boxId/:sensorId/measurements`, // method: "DELETE", diff --git a/package.json b/package.json index d02273a4..ab01b57a 100644 --- a/package.json +++ b/package.json @@ -13,9 +13,11 @@ "test": "vitest", "test:ui": "vitest --ui", "test:coverage": "vitest run --coverage", + "db:check": "drizzle-kit check", "db:push": "drizzle-kit push", "db:generate": "drizzle-kit generate", "db:migrate": "drizzle-kit migrate", + "db:studio": "drizzle-kit studio", "db:setup": "npm run db:generate && npm run db:migrate", "db:seed": "tsx ./db/seed.ts", "components": "npx shadcn-ui", diff --git a/tests/routes/api.boxes.$deviceId.sensors.$sensorId.spec.ts b/tests/routes/api.boxes.$deviceId.sensors.$sensorId.spec.ts new file mode 100644 index 00000000..25667f8e --- /dev/null +++ b/tests/routes/api.boxes.$deviceId.sensors.$sensorId.spec.ts @@ -0,0 +1,126 @@ +import { type Params, type LoaderFunctionArgs } from "react-router"; +import { BASE_URL } from "vitest.setup"; +import { registerUser } from "~/lib/user-service.server"; +import { createDevice, deleteDevice } from "~/models/device.server"; +import { getSensors } from "~/models/sensor.server"; +import { deleteUserByEmail } from "~/models/user.server"; +import { loader } from "~/routes/api.boxes.$deviceId.sensors.$sensorId"; +import { type Sensor, type Device, type User } from "~/schema"; + +const DEVICE_SENSORS_ID_USER = { + name: "meTestSensorsIds", + email: "test@box.sensorids", + password: "highlySecurePasswordForTesting", +}; + +const DEVICE_SENSOR_ID_BOX = { + name: `${DEVICE_SENSORS_ID_USER}s Box`, + exposure: "outdoor", + expiresAt: null, + tags: [], + latitude: 0, + longitude: 0, + model: "luftdaten.info", + mqttEnabled: false, + ttnEnabled: false, + sensors: [ + { + title: "Temp", + unit: "°C", + sensorType: "dummy", + }, + { + title: "CO2", + unit: "mol/L", + sensorType: "dummy", + }, + { + title: "Air Pressure", + unit: "kPa", + sensorType: "dummy", + }, + ], +}; + +describe("openSenseMap API Routes: /boxes/:deviceId/sensors/:sensorId", () => { + let device: Device; + let deviceId: string = ""; + let sensors: Sensor[] = []; + + beforeAll(async () => { + const user = await registerUser( + DEVICE_SENSORS_ID_USER.name, + DEVICE_SENSORS_ID_USER.email, + DEVICE_SENSORS_ID_USER.password, + "en_US", + ); + + device = await createDevice(DEVICE_SENSOR_ID_BOX, (user as User).id); + deviceId = device.id; + sensors = await getSensors(deviceId); + }); + + describe("GET", () => { + it("should return a single sensor of a box", async () => { + // Arrange + const request = new Request( + `${BASE_URL}/boxes/${deviceId}/sensors/${sensors[0].id}`, + { method: "GET" }, + ); + + // Act + const dataFunctionValue = await loader({ + request, + params: { + deviceId: `${deviceId}`, + sensorId: `${sensors[0].id}`, + } as Params, + } as LoaderFunctionArgs); // Assuming a separate loader for single sensor + const response = dataFunctionValue as Response; + const body = await response?.json(); + + // Assert + expect(response.status).toBe(200); + expect(response.headers.get("content-type")).toBe( + "application/json; charset=utf-8", + ); + expect(body).toHaveProperty("_id"); + }); + + it("should return only value of a single sensor of a box", async () => { + // Arrange + const request = new Request( + `${BASE_URL}/boxes/${deviceId}/sensors/${sensors[0].id}?onlyValue=true`, + { method: "GET" }, + ); + + // Act + const dataFunctionValue = await loader({ + request, + params: { + deviceId: `${deviceId}`, + sensorId: `${sensors[0].id}`, + } as Params, + } as LoaderFunctionArgs); + const response = dataFunctionValue as Response; + const body = await response?.json(); + + // Assert + expect(response.status).toBe(200); + expect(response.headers.get("content-type")).toBe( + "application/json; charset=utf-8", + ); + + if (isNaN(Number.parseFloat(body))) expect(body).toBeNull(); + else expect(typeof body).toBe("number"); + }); + }); + + afterAll(async () => { + // delete the valid test user + await deleteUserByEmail(DEVICE_SENSORS_ID_USER.email); + + // delete the box + await deleteDevice({ id: deviceId }); + }); +}); diff --git a/tests/routes/api.boxes.$deviceId.sensors.spec.ts b/tests/routes/api.boxes.$deviceId.sensors.spec.ts new file mode 100644 index 00000000..918ddcf3 --- /dev/null +++ b/tests/routes/api.boxes.$deviceId.sensors.spec.ts @@ -0,0 +1,119 @@ +import { type Params, type LoaderFunctionArgs } from "react-router"; +import { BASE_URL } from "vitest.setup"; +import { registerUser } from "~/lib/user-service.server"; +import { createDevice, deleteDevice } from "~/models/device.server"; +import { deleteUserByEmail } from "~/models/user.server"; +import { loader } from "~/routes/api.boxes.$deviceId.sensors"; +import { type User } from "~/schema"; + +const DEVICE_SENSORS_USER = { + name: "meTestSensors", + email: "test@box.sensors", + password: "highlySecurePasswordForTesting", +}; + +const DEVICE_SENSOR_BOX = { + name: `${DEVICE_SENSORS_USER}s Box`, + exposure: "outdoor", + expiresAt: null, + tags: [], + latitude: 0, + longitude: 0, + model: "luftdaten.info", + mqttEnabled: false, + ttnEnabled: false, + sensors: [ + { + title: "Temp", + unit: "°C", + sensorType: "dummy", + }, + { + title: "CO2", + unit: "mol/L", + sensorType: "dummy", + }, + { + title: "Air Pressure", + unit: "kPa", + sensorType: "dummy", + }, + ], +}; + +describe("openSenseMap API Routes: /boxes/:deviceId/sensors", () => { + let deviceId: string = ""; + + beforeAll(async () => { + const user = await registerUser( + DEVICE_SENSORS_USER.name, + DEVICE_SENSORS_USER.email, + DEVICE_SENSORS_USER.password, + "en_US", + ); + + const device = await createDevice(DEVICE_SENSOR_BOX, (user as User).id); + deviceId = device.id; + }); + + describe("GET", () => { + it("should return all sensors of a box/ device", async () => { + // Arrange + const request = new Request(`${BASE_URL}/boxes/${deviceId}/sensors`, { + method: "GET", + }); + + // Act + const dataFunctionValue = await loader({ + request, + params: { deviceId: `${deviceId}` } as Params, + } as LoaderFunctionArgs); + const response = dataFunctionValue as Response; + const body = await response?.json(); + + // Assert + expect(response.status).toBe(200); + expect(response.headers.get("content-type")).toBe( + "application/json; charset=utf-8", + ); + expect(body).toHaveProperty("sensors"); + }); + + it("should return all sensors of a box with a maximum of 3 measurements when ?count=3 is used", async () => { + // Arrange + const request = new Request( + `${BASE_URL}/boxes/${deviceId}/sensors?count=3`, + { method: "GET" }, + ); + + // Act + const dataFunctionValue = await loader({ + request, + params: { deviceId: `${deviceId}` } as Params, + } as LoaderFunctionArgs); + const response = dataFunctionValue as Response; + const body = await response?.json(); + + // Assert + expect(response.status).toBe(200); + expect(response.headers.get("content-type")).toBe( + "application/json; charset=utf-8", + ); + expect(body.sensors[0].lastMeasurements).toBeDefined(); + expect(body.sensors[0].lastMeasurements).not.toBeNull(); + + if (body.sensors[0].lastMeasurements.length > 0) + expect( + body.sensors[0].lastMeasurements.measurements.length, + ).toBeGreaterThanOrEqual(3); + }); + }); + + afterAll(async () => { + // delete the valid test user + await deleteUserByEmail(DEVICE_SENSORS_USER.email); + + // delete the box + await deleteDevice({ id: deviceId }); + }); +}); diff --git a/tests/routes/api.devices.spec.ts b/tests/routes/api.devices.spec.ts new file mode 100644 index 00000000..76179882 --- /dev/null +++ b/tests/routes/api.devices.spec.ts @@ -0,0 +1,690 @@ +import { + type AppLoadContext, + type LoaderFunctionArgs, + type ActionFunctionArgs, +} from 'react-router' +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 } from '~/routes/api.device.$deviceId' +import { + loader as devicesLoader, + action as devicesAction, +} from '~/routes/api.devices' +import { type User, type Device } from '~/schema' + +const DEVICE_TEST_USER = { + name: 'deviceTest', + email: 'test@devices.endpoint', + password: 'highlySecurePasswordForTesting', +} + +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 }, + (testUser as User).id, + ) + }) + + describe('GET', () => { + it('should search for boxes with a specific name', async () => { + // Arrange + const request = new Request( + `${BASE_URL}?format=geojson&name=${queryableDevice?.name}`, + { + method: 'GET', + headers: { 'Content-Type': 'application/json' }, + }, + ) + + // Act + const response = await devicesLoader({ + request: request, + } as LoaderFunctionArgs) + + expect(response).toBeDefined() + expect(Array.isArray(response?.features)).toBe(true) + expect(response?.features.length).lessThanOrEqual(5) // 5 is default limit + }) + + it('should search for boxes with a specific name and limit the results', async () => { + // Arrange + const request = new Request( + `${BASE_URL}?format=geojson&name=${queryableDevice?.name}&limit=2`, + { + method: 'GET', + headers: { 'Content-Type': 'application/json' }, + }, + ) + + // Act + const response = await devicesLoader({ + request: request, + } as LoaderFunctionArgs) + + expect(response).toBeDefined() + expect(Array.isArray(response?.features)).toBe(true) + expect(response?.features.length).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`, + // { + // method: 'GET', + // headers: { 'Content-Type': 'application/json' }, + // }, + // ) + + // const response = await devicesLoader({ + // request: request, + // } as LoaderFunctionArgs) + + // expect(response).toBeDefined() + // expect(Array.isArray(response?.features)).toBe(true) + // // return chakram.get(`${BASE_URL}/boxes?minimal=true`) + // // .then(function (response) { + // // expect(response).to.have.status(200); + // // expect(response).to.have.header('content-type', 'application/json; charset=utf-8'); + // // expect(Array.isArray(response.body)).to.be.true; + // // expect(response.body.length).to.be.equal(boxCount); + // // for (const box of response.body) { + // // expect(Object.keys(box)) + // // .to.not.include('loc') + // // .and.to.not.include('locations') + // // .and.not.include('weblink') + // // .and.not.include('image') + // // .and.not.include('description') + // // .and.not.include('model') + // // .and.not.include('sensors'); + // // } + + // // return chakram.wait(); + // // }); + // }); + + // it('should return the correct count and correct schema of boxes for /boxes GET with date parameter', async () => { + // const tenDaysAgoIso = new Date(Date.now() - 10 * 24 * 60 * 60 * 1000).toISOString(); + + // // Arrange + // const request = new Request( + // `${BASE_URL}?format=geojson&date=${tenDaysAgoIso}`, + // { + // method: 'GET', + // headers: { 'Content-Type': 'application/json' }, + // }, + // ) + + // const response = await devicesLoader({ + // request: request, + // } as LoaderFunctionArgs) + + // expect(response).toBeDefined() + + // // return chakram.get(`${BASE_URL}/boxes?date=${ten_days_ago.toISOString()}`) + // // .then(function (response) { + // // expect(response).to.have.status(200); + // // expect(Array.isArray(response.body)).to.be.true; + // // expect(response.body.length).to.be.equal(1); + // // expect(response).to.have.header('content-type', 'application/json; charset=utf-8'); + // // expect(response).to.have.schema(findAllSchema); + // // expect(response.body[0].sensors.some(function (sensor) { + // // return moment.utc(sensor.lastMeasurement.createdAt).diff(ten_days_ago) < 10; + // // })).to.be.true; + + // // return chakram.wait(); + // // }); + // }); + + it('should reject filtering boxes near a location with wrong parameter values', async () => { + // Arrange + const request = new Request(`${BASE_URL}?near=test,60`, { + 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 = 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=newgroup`, { + method: 'GET', + headers: { 'Content-Type': 'application/json' }, + }) + + const response = await devicesLoader({ + request: request, + } as LoaderFunctionArgs) + + expect(response).toBeDefined() + expect(response?.length).toBe(0) + + // return chakram.get(`${BASE_URL}/grouptag=newgroup`) + // .then(function (response) { + // expect(response).to.have.status(200); + // expect(response).to.have.header('content-type', 'application/json; charset=utf-8'); + // expect(Array.isArray(response.body)).to.be.true; + // expect(response.body.length).to.be.equal(2); + + // return chakram.wait(); + // }); + }) + + // it('should allow filtering boxes by bounding box', async () => { + // // Arrange + // const request = new Request( + // `${BASE_URL}?format=geojson&bbox=120,60,121,61`, + // { + // method: 'GET', + // headers: { 'Content-Type': 'application/json' }, + // }, + // ) + + // // Act + // const response = await devicesLoader({ + // request: request, + // } as LoaderFunctionArgs) + + // expect(response).toBeDefined() + + // if (response) { + // // Assert + // expect(response.type).toBe('FeatureCollection') + // expect(Array.isArray(response.features)).toBe(true) + + // if (response.features.length > 0) { + // response.features.forEach((feature: any) => { + // expect(feature.type).toBe('Feature') + // expect(feature.geometry).toBeDefined() + // expect(feature.geometry.coordinates).toBeDefined() + + // const [longitude, latitude] = feature.geometry.coordinates + + // // Verify coordinates are within the bounding box [120,60,121,61] + // expect(longitude).toBeGreaterThanOrEqual(120) + // expect(longitude).toBeLessThanOrEqual(121) + // expect(latitude).toBeGreaterThanOrEqual(60) + // expect(latitude).toBeLessThanOrEqual(61) + // }) + // } + // } + // }) + }) + + describe('POST', () => { + 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 () => { + // // Arrange + // const requestBody = minimalSensebox([52]) // Invalid: missing longitude + + // const request = new Request(BASE_URL, { + // method: 'POST', + // headers: { Authorization: `Bearer ${jwt}` }, + // body: JSON.stringify(requestBody), + // }) + + // try { + // await devicesAction({ + // request: request, + // } as ActionFunctionArgs) + // fail('Expected action to throw an error') + // } catch (error) { + // if (error instanceof Response) { + // expect(error.status).toBe(422) + // const errorData = await error.json() + // expect(errorData.message).toBe( + // 'Illegal value for parameter location. missing latitude or longitude in location [52]', + // ) + // } else { + // throw error + // } + // } + // }) + + // it('should reject a new box without location field', async () => { + // // Arrange + // const requestBody = minimalSensebox() + // delete requestBody.location + + // const request = new Request(BASE_URL, { + // method: 'POST', + // headers: { Authorization: `Bearer ${jwt}` }, + + // body: JSON.stringify(requestBody), + // }) + + // // Act & Assert + // try { + // await devicesAction({ + // request: request, + // } as ActionFunctionArgs) + // fail('Expected action to throw an error') + // } catch (error) { + // if (error instanceof Response) { + // expect(error.status).toBe(400) + // const errorData = await error.json() + // expect(errorData.message).toBe('missing required parameter location') + // } else { + // throw error + // } + // } + // }) + }) + + describe('/: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('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).toEqual({ message: '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) + }) +}) + +// describe('openSenseMap API Routes: /boxes', () => { +// describe('GET /boxes', () => { +// it('should reject filtering boxes near a location with wrong parameter values', async () => { +// // Arrange +// const request = new Request(`${BASE_URL}?near=test,60`, { +// method: 'GET', +// headers: { 'Content-Type': 'application/json' }, +// }) + +// // Act & Assert +// await expect(async () => { +// await devicesLoader({ +// request: request, +// } as LoaderFunctionArgs) +// }).rejects.toThrow() +// }) + +// it('should return geojson format when requested', async () => { +// // Arrange +// const request = new Request(`${BASE_URL}?format=geojson`, { +// method: 'GET', +// headers: { 'Content-Type': 'application/json' }, +// }) + +// // Act +// const geojsonData = await devicesLoader({ +// request: request, +// } as LoaderFunctionArgs) + +// // Assert - this should always be GeoJSON since that's what the loader returns +// expect(geojsonData.type).toBe('FeatureCollection') +// expect(Array.isArray(geojsonData.features)).toBe(true) + +// if (geojsonData.features.length > 0) { +// expect(geojsonData.features[0].type).toBe('Feature') +// expect(geojsonData.features[0].geometry).toBeDefined() +// expect(geojsonData.features[0].properties).toBeDefined() +// } +// }) + +// it('should return minimal data when minimal=true', async () => { +// // Arrange +// const request = new Request(`${BASE_URL}?minimal=true`, { +// method: 'GET', +// headers: { 'Content-Type': 'application/json' }, +// }) + +// // Act +// const geojsonData = await devicesLoader({ +// request: request, +// } as LoaderFunctionArgs) + +// // Assert - working with GeoJSON FeatureCollection +// expect(geojsonData.type).toBe('FeatureCollection') +// expect(Array.isArray(geojsonData.features)).toBe(true) + +// if (geojsonData.features.length > 0) { +// const feature = geojsonData.features[0] +// expect(feature.type).toBe('Feature') +// expect(feature.properties).toBeDefined() + +// // Should have minimal fields in properties +// expect(feature.properties?._id || feature.properties?.id).toBeDefined() +// expect(feature.properties?.name).toBeDefined() +// expect(feature.properties?.exposure).toBeDefined() +// expect( +// feature.properties?.currentLocation || +// feature.properties?.location || +// feature.geometry, +// ).toBeDefined() + +// // Should not have full sensor data +// expect(feature.properties?.sensors).toBeUndefined() +// } +// }) +// }) +// }) diff --git a/tests/routes/api.stats.spec.ts b/tests/routes/api.stats.spec.ts index 3e12b798..75bc0c46 100644 --- a/tests/routes/api.stats.spec.ts +++ b/tests/routes/api.stats.spec.ts @@ -6,11 +6,17 @@ import { loader } from "~/routes/api.stats"; describe("openSenseMap API Routes: /stats", () => { let boxCount: number = 0; + let measurementsCount: number = 0; beforeAll(async () => { const [count] = await drizzleClient.execute( sql`SELECT * FROM approximate_row_count('device');`, ); boxCount = Number(count.approximate_row_count); + + const [count2] = await drizzleClient.execute( + sql`SELECT * FROM approximate_row_count('measurement');`, + ); + measurementsCount = Number(count2.approximate_row_count); }); it("should return /stats correctly", async () => { @@ -31,7 +37,7 @@ describe("openSenseMap API Routes: /stats", () => { expect(response.status).toBe(200); const [boxes, measurements] = body; expect(boxes).toBe(boxCount); - expect(measurements).toBe(0); + expect(measurements).toBe(measurementsCount); }); it("should return a json array with three numbers", async () => { diff --git a/tests/routes/api.transfers.spec.ts b/tests/routes/api.transfers.spec.ts new file mode 100644 index 00000000..4b7dc793 --- /dev/null +++ b/tests/routes/api.transfers.spec.ts @@ -0,0 +1,217 @@ +import { LoaderFunctionArgs, type ActionFunctionArgs } from "react-router"; +import { BASE_URL } from "vitest.setup"; +import { createToken } from "~/lib/jwt"; +import { registerUser } from '~/lib/user-service.server' +import { type Device, type User } from "~/schema"; +import { createDevice } from "~/models/device.server"; +import {action as transferAction} from "~/routes/api.transfer" +import {action as transferUpdateAction, loader as transferLoader} from "~/routes/api.transfer.$deviceId" +import { deleteUserByEmail } from "~/models/user.server"; +import { action as claimAction} from "~/routes/api.claim" + +const TRANSFER_TEST_USER = { + name: 'asdfhwerskdfsdfnxmcv', + email: 'test@asdfasdasehrasdweradfsdjhgjdfhgf.endpoint', + password: 'highlySecurePasswordForTesting', +} + +const generateMinimalDevice = ( + location: number[] | {} = [123, 12, 34], + exposure = 'mobile', + name = '' + new Date().getTime(), +) => ({ + exposure, + location, + name, + model: 'homeV2Ethernet', +}) + +describe("openSenseMap API Routes: /boxes/transfer", () => { + + let user: User | null = null + let jwt: string = '' + let queryableDevice: Device | null = null + + let transferToken: string = '' + let transferBoxId: string = '' + + beforeAll(async () => { + const testUser = await registerUser( + TRANSFER_TEST_USER.name, + TRANSFER_TEST_USER.email, + TRANSFER_TEST_USER.password, + 'en_US', + ) + user = testUser as User + const { token: t } = await createToken(testUser as User) + jwt = t + + queryableDevice = await createDevice( + { ...generateMinimalDevice(), latitude: 123, longitude: 12 }, + (testUser as User).id, + ) + + }) + describe('POST', () => { + it("should mark a device for transferring", async () => { + + const request = new Request(`${BASE_URL}/boxes/transfer`, { + method: "POST", + headers: { + "Content-Type": "application/x-www-form-urlencoded", + "Authorization": `Bearer ${jwt}` + }, + body: new URLSearchParams({ boxId: queryableDevice!.id }), + }); + + const response = (await transferAction({ + request, + } as ActionFunctionArgs)) as Response; + + const body = await response.json(); + + transferToken = body.data.token; + transferBoxId = body.data.id; + + // Assertions + expect(response.status).toBe(201); + expect(response.headers.get("content-type")).toBe( + "application/json; charset=utf-8", + ); + expect(body).toHaveProperty("message", "Box successfully prepared for transfer"); + expect(body).toHaveProperty("data"); + expect(body.data).toBeDefined(); + expect(body.data.token).toBeDefined(); + expect(typeof body.data.token).toBe("string"); + expect(body.data.token).toHaveLength(12); + + expect(body.data.expiresAt).toBeDefined(); + const expiresAt = new Date(body.data.expiresAt); + const now = new Date(); + const diffInHours = (expiresAt.getTime() - now.getTime()) / (1000 * 60 * 60); + expect(diffInHours).toBeCloseTo(24, 1); + + }); + + describe('PUT', () => { + it("should update expiresAt of a transfer token", async () => { + const newExpiry = new Date(); + newExpiry.setDate(newExpiry.getDate() + 2); + + const request = new Request( + `${BASE_URL}/boxes/transfer/${transferBoxId}`, + { + method: "PUT", + headers: { + "Content-Type": "application/x-www-form-urlencoded", + Authorization: `Bearer ${jwt}`, + }, + body: new URLSearchParams({ + token: transferToken, + expiresAt: newExpiry.toISOString(), + }), + }, + ); + + const response = (await transferUpdateAction({ + request, + params: { id: transferBoxId }, + } as unknown as ActionFunctionArgs)) as Response; + + const body = await response.json(); + + expect(response.status).toBe(200); + expect(body.message).toBe("Transfer successfully updated"); + expect(body.data).toBeDefined(); + expect(body.data.token).toHaveLength(12); + expect(body.data.token).toBe(transferToken); + + const expiresAt = new Date(body.data.expiresAt); + const diffInHours = + (expiresAt.getTime() - Date.now()) / (1000 * 60 * 60); + expect(diffInHours).toBeCloseTo(48, 1); // within 1 hour of 48h + }); + }) + + describe('GET', () => { + it("should get transfer information for a device", async () => { + const request = new Request( + `${BASE_URL}/boxes/transfer/${queryableDevice!.id}`, + { + method: "GET", + headers: { + Authorization: `Bearer ${jwt}`, + }, + }, + ); + + const response = (await transferLoader({ + request, + params: { deviceId: queryableDevice!.id }, + } as unknown as LoaderFunctionArgs)) as Response; + + const body = await response.json(); + + expect(response.status).toBe(200); + expect(body).toHaveProperty("data"); + expect(body.data).not.toBeNull(); + expect(body.data.boxId).toBe(queryableDevice!.id); + expect(body.data.token).toBe(transferToken); + }); + }); + + describe('DELETE', () => { + it('should revoke and delete a transfer token', async () => { + // We have to raise the timeout here and wait for the TTL! + // More information: https://www.mongodb.com/docs/manual/core/index-ttl/#timing-of-the-delete-operation + + const request = new Request(`${BASE_URL}/boxes/transfer`, { + method: "DELETE", + headers: { + "Content-Type": "application/x-www-form-urlencoded", + "Authorization": `Bearer ${jwt}` + }, + body: new URLSearchParams({ + boxId: queryableDevice!.id, + token: transferToken + }), + }); + + const response = (await transferAction({ + request, + } as ActionFunctionArgs)) as Response; + + expect(response.status).toBe(204); + + // Verify the transfer token is actually deleted by trying to update it + const verifyRequest = new Request( + `${BASE_URL}/boxes/transfer/${transferBoxId}`, + { + method: "PUT", + headers: { + "Content-Type": "application/x-www-form-urlencoded", + Authorization: `Bearer ${jwt}`, + }, + body: new URLSearchParams({ + token: transferToken, + expiresAt: new Date(Date.now() + 24 * 60 * 60 * 1000).toISOString(), + }), + }, + ); + + const verifyResponse = (await transferUpdateAction({ + request: verifyRequest, + params: { id: transferBoxId }, + } as unknown as ActionFunctionArgs)) as Response; + + expect(verifyResponse.status).toBe(404); + const verifyBody = await verifyResponse.json(); + expect(verifyBody.error).toBe("Transfer not found"); + }, 120000); // 2 minute timeout like in the original test + }); + + afterAll(async () => { + await deleteUserByEmail(TRANSFER_TEST_USER.email); + }); + }) +})