diff --git a/tests/data/generate_test_user.ts b/tests/data/generate_test_user.ts new file mode 100644 index 00000000..6b49b79f --- /dev/null +++ b/tests/data/generate_test_user.ts @@ -0,0 +1,17 @@ +import { randomBytes, randomUUID } from 'crypto' + +/** + * Generates valid credentials for a user to run a unit test with. + * @returns A valid user object containing the credentials required to create it + */ +export const generateTestUserCredentials = (): { + name: string + email: string + password: string +} => { + return { + name: randomUUID().toString(), + email: `${randomBytes(6).toString('hex')}@${randomBytes(6).toString('hex')}.${randomBytes(2).toString('hex')}`, + password: randomBytes(20).toString('hex'), + } +} diff --git a/tests/routes/api.boxes.$deviceId.data.$sensorId.spec.ts b/tests/routes/api.boxes.$deviceId.data.$sensorId.spec.ts index e77ed60b..459b4329 100644 --- a/tests/routes/api.boxes.$deviceId.data.$sensorId.spec.ts +++ b/tests/routes/api.boxes.$deviceId.data.$sensorId.spec.ts @@ -1,154 +1,155 @@ -import { type Params, type LoaderFunctionArgs } from "react-router"; -import { BASE_URL } from "vitest.setup"; -import { registerUser } from "~/lib/user-service.server"; -import { createDevice, deleteDevice } from "~/models/device.server"; -import { deleteMeasurementsForSensor, deleteMeasurementsForTime, insertMeasurements } from "~/models/measurement.server"; -import { getSensors } from "~/models/sensor.server"; -import { deleteUserByEmail } from "~/models/user.server"; -import { loader } from "~/routes/api.boxes.$deviceId.data.$sensorId"; -import { type Sensor, type Device, type User } from "~/schema"; +import { type Params, type LoaderFunctionArgs } from 'react-router' +import { generateTestUserCredentials } from 'tests/data/generate_test_user' +import { BASE_URL } from 'vitest.setup' +import { registerUser } from '~/lib/user-service.server' +import { createDevice, deleteDevice } from '~/models/device.server' +import { + deleteMeasurementsForSensor, + deleteMeasurementsForTime, + insertMeasurements, +} from '~/models/measurement.server' +import { getSensors } from '~/models/sensor.server' +import { deleteUserByEmail } from '~/models/user.server' +import { loader } from '~/routes/api.boxes.$deviceId.data.$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_SENSORS_ID_USER = generateTestUserCredentials() 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", - }, - ], -}; + name: `${DEVICE_SENSORS_ID_USER}s Box`, + exposure: 'outdoor', + expiresAt: null, + tags: [], + latitude: 0, + longitude: 0, + model: 'luftdaten.info', + mqttEnabled: false, + ttnEnabled: false, + sensors: [ + { + title: 'Temp', + unit: '°C', + sensorType: 'dummy', + }, + { + title: 'CO2', + unit: 'mol/L', + sensorType: 'dummy', + }, + { + title: 'Air Pressure', + unit: 'kPa', + sensorType: 'dummy', + }, + ], +} const MEASUREMENTS = [ { value: 1589625, createdAt: new Date('1954-06-07 12:00:00+00'), - sensor_id: "" + sensor_id: '', + }, + { + value: 3.14159, + createdAt: new Date('1988-03-14 1:59:26+00'), + sensor_id: '', }, - { - value: 3.14159, - createdAt: new Date('1988-03-14 1:59:26+00'), - sensor_id: "" - } ] -describe("openSenseMap API Routes: /api/boxes/:deviceId/data/:sensorId", () => { - let device: Device; - let deviceId: string = ""; - let sensors: Sensor[] = []; +describe('openSenseMap API Routes: /api/boxes/:deviceId/data/: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", - ); + 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); + device = await createDevice(DEVICE_SENSOR_ID_BOX, (user as User).id) + deviceId = device.id + sensors = await getSensors(deviceId) - MEASUREMENTS.forEach(meas => meas.sensor_id = sensors[0].id) - await insertMeasurements(MEASUREMENTS); - }); + MEASUREMENTS.forEach((meas) => (meas.sensor_id = sensors[0].id)) + await insertMeasurements(MEASUREMENTS) + }) - describe("GET", () => { - it("should return measurements for a single sensor of a box in json format", async () => { - // Arrange - const request = new Request( - `${BASE_URL}/api/boxes/${deviceId}/data/${sensors[0].id}?from-date=${new Date('1954-06-07 11:00:00+00')}&to-date=${new Date('1988-03-14 1:59:27+00')}`, - { method: "GET" }, - ); + describe('GET', () => { + it('should return measurements for a single sensor of a box in json format', async () => { + // Arrange + const request = new Request( + `${BASE_URL}/api/boxes/${deviceId}/data/${sensors[0].id}?from-date=${new Date('1954-06-07 11:00:00+00')}&to-date=${new Date('1988-03-14 1:59:27+00')}`, + { method: 'GET' }, + ) - // Act - const dataFunctionValue = await loader({ - request, - params: { - deviceId: `${deviceId}`, - 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(); + // 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).toHaveLength(2); - expect(body[0].sensor_id).toBe(sensors[0].id); - expect(body[1].sensor_id).toBe(sensors[0].id); - expect(body[0].time).toBe('1988-03-14 01:59:26+00'); - expect(body[1].time).toBe('1954-06-07 12:00:00+00'); - expect(body[0].value).toBeCloseTo(3.14159); - expect(body[1].value).toBe(1589625); - }); + // Assert + expect(response.status).toBe(200) + expect(response.headers.get('content-type')).toBe( + 'application/json; charset=utf-8', + ) + expect(body).toHaveLength(2) + expect(body[0].sensor_id).toBe(sensors[0].id) + expect(body[1].sensor_id).toBe(sensors[0].id) + expect(body[0].time).toBe('1988-03-14 01:59:26+00') + expect(body[1].time).toBe('1954-06-07 12:00:00+00') + expect(body[0].value).toBeCloseTo(3.14159) + expect(body[1].value).toBe(1589625) + }) - it("should return measurements for a single sensor of a box in csv format", async () => { - // Arrange - const request = new Request( - `${BASE_URL}/api/boxes/${deviceId}/data/${sensors[0].id}?from-date=${new Date('1954-06-07 11:00:00+00')}&to-date=${new Date('1988-03-14 1:59:27+00')}&format=csv`, - { method: "GET" }, - ); + it('should return measurements for a single sensor of a box in csv format', async () => { + // Arrange + const request = new Request( + `${BASE_URL}/api/boxes/${deviceId}/data/${sensors[0].id}?from-date=${new Date('1954-06-07 11:00:00+00')}&to-date=${new Date('1988-03-14 1:59:27+00')}&format=csv`, + { 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?.text(); + // 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?.text() - // Assert - expect(response.status).toBe(200); - expect(response.headers.get("content-type")).toBe( - "text/csv; charset=utf-8", - ); - expect(body).toBe( + // Assert + expect(response.status).toBe(200) + expect(response.headers.get('content-type')).toBe( + 'text/csv; charset=utf-8', + ) + expect(body).toBe( 'createdAt,value\n1988-03-14 01:59:26+00,3.14159\n1954-06-07 12:00:00+00,1589625', ) - }); - }); + }) + }) - afterAll(async () => { - //delete measurements - if (sensors.length > 0) { - await deleteMeasurementsForSensor(sensors[0].id); - await deleteMeasurementsForTime(MEASUREMENTS[0].createdAt); - await deleteMeasurementsForTime(MEASUREMENTS[1].createdAt); - } - // delete the valid test user - await deleteUserByEmail(DEVICE_SENSORS_ID_USER.email); - // delete the box - await deleteDevice({ id: deviceId }); - }); -}); + afterAll(async () => { + //delete measurements + if (sensors.length > 0) { + await deleteMeasurementsForSensor(sensors[0].id) + await deleteMeasurementsForTime(MEASUREMENTS[0].createdAt) + await deleteMeasurementsForTime(MEASUREMENTS[1].createdAt) + } + // delete the valid test user + await deleteUserByEmail(DEVICE_SENSORS_ID_USER.email) + // delete the box + await deleteDevice({ id: deviceId }) + }) +}) diff --git a/tests/routes/api.boxes.$deviceId.locations.spec.ts b/tests/routes/api.boxes.$deviceId.locations.spec.ts index 2685f6ee..fb5d8d85 100644 --- a/tests/routes/api.boxes.$deviceId.locations.spec.ts +++ b/tests/routes/api.boxes.$deviceId.locations.spec.ts @@ -1,183 +1,187 @@ -import { type Params, type LoaderFunctionArgs } from "react-router"; -import { BASE_URL } from "vitest.setup"; -import { registerUser } from "~/lib/user-service.server"; -import { createDevice, deleteDevice } from "~/models/device.server"; -import { deleteMeasurementsForSensor, deleteMeasurementsForTime, saveMeasurements } from "~/models/measurement.server"; -import { getSensors } from "~/models/sensor.server"; -import { deleteUserByEmail } from "~/models/user.server"; -import { loader } from "~/routes/api.boxes.$deviceId.locations"; -import { type Sensor, type Device, type User } from "~/schema"; +import { type Params, type LoaderFunctionArgs } from 'react-router' +import { generateTestUserCredentials } from 'tests/data/generate_test_user' +import { BASE_URL } from 'vitest.setup' +import { registerUser } from '~/lib/user-service.server' +import { createDevice, deleteDevice } from '~/models/device.server' +import { + deleteMeasurementsForSensor, + deleteMeasurementsForTime, + saveMeasurements, +} from '~/models/measurement.server' +import { getSensors } from '~/models/sensor.server' +import { deleteUserByEmail } from '~/models/user.server' +import { loader } from '~/routes/api.boxes.$deviceId.locations' +import { type Sensor, type Device, type User } from '~/schema' -const DEVICE_SENSORS_ID_USER = { - name: "meTestSensorsIds", - email: "test@box.sensorids", - password: "highlySecurePasswordForTesting", -}; +const DEVICE_SENSORS_ID_USER = generateTestUserCredentials() 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", - }, - ], -}; + name: `${DEVICE_SENSORS_ID_USER}s Box`, + exposure: 'outdoor', + expiresAt: null, + tags: [], + latitude: 0, + longitude: 0, + model: 'luftdaten.info', + mqttEnabled: false, + ttnEnabled: false, + sensors: [ + { + title: 'Temp', + unit: '°C', + sensorType: 'dummy', + }, + { + title: 'CO2', + unit: 'mol/L', + sensorType: 'dummy', + }, + { + title: 'Air Pressure', + unit: 'kPa', + sensorType: 'dummy', + }, + ], +} const MEASUREMENTS = [ { value: 1589625, createdAt: new Date('1954-06-07 12:00:00+00'), - sensor_id: "", - location: { - lng: 1, - lat: 2, - height: 3 - } + sensor_id: '', + location: { + lng: 1, + lat: 2, + height: 3, + }, + }, + { + value: 3.14159, + createdAt: new Date('1988-03-14 1:59:26+00'), + sensor_id: '', + location: { + lng: 4, + lat: 5, + }, + }, + { + value: 0, + createdAt: new Date('2000-05-25 11:11:11+00'), + sensor_id: '', + location: { + lng: 6, + lat: 7, + height: 8, + }, }, - { - value: 3.14159, - createdAt: new Date('1988-03-14 1:59:26+00'), - sensor_id: "", - location: { - lng: 4, - lat: 5, - } - }, - { - value: 0, - createdAt: new Date('2000-05-25 11:11:11+00'), - sensor_id: "", - location: { - lng: 6, - lat: 7, - height: 8 - } - } ] -describe("openSenseMap API Routes: /api/boxes/:deviceId/locations", () => { - let device: Device; - let deviceId: string = ""; - let sensors: Sensor[]; +describe('openSenseMap API Routes: /api/boxes/:deviceId/locations', () => { + let device: Device + let deviceId: string = '' + let sensors: Sensor[] - beforeAll(async () => { - const user = await registerUser( - DEVICE_SENSORS_ID_USER.name, - DEVICE_SENSORS_ID_USER.email, - DEVICE_SENSORS_ID_USER.password, - "en_US", - ); + 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); + device = await createDevice(DEVICE_SENSOR_ID_BOX, (user as User).id) + deviceId = device.id + sensors = await getSensors(deviceId) - MEASUREMENTS.forEach(meas => meas.sensor_id = sensors[0].id) - await saveMeasurements(device, MEASUREMENTS); - }); + MEASUREMENTS.forEach((meas) => (meas.sensor_id = sensors[0].id)) + await saveMeasurements(device, MEASUREMENTS) + }) - describe("GET", () => { - it("should return locations of a box in json format", async () => { - // Arrange - const request = new Request( - `${BASE_URL}/api/boxes/${deviceId}/locations?from-date=${new Date('1954-06-07 11:00:00+00')}&to-date=${new Date('1988-03-14 1:59:27+00')}`, - { method: "GET" }, - ); + describe('GET', () => { + it('should return locations of a box in json format', async () => { + // Arrange + const request = new Request( + `${BASE_URL}/api/boxes/${deviceId}/locations?from-date=${new Date('1954-06-07 11:00:00+00')}&to-date=${new Date('1988-03-14 1:59:27+00')}`, + { method: 'GET' }, + ) - // Act - const dataFunctionValue = await loader({ - request, - params: { - deviceId: `${deviceId}` - } as Params, - } as LoaderFunctionArgs); // Assuming a separate loader for single sensor - const response = dataFunctionValue as Response; - const body = await response?.json(); + // Act + const dataFunctionValue = await loader({ + request, + params: { + deviceId: `${deviceId}`, + } as Params, + } as LoaderFunctionArgs) // Assuming a separate loader for single sensor + const response = dataFunctionValue as Response + const body = await response?.json() - // Assert - expect(response.status).toBe(200); - expect(response.headers.get("content-type")).toBe( - "application/json; charset=utf-8", - ); - expect(body).toHaveLength(2); - expect(body[0].coordinates).toHaveLength(2); - expect(body[1].coordinates).toHaveLength(2); - expect(body[0].coordinates[0]).toBe(4); - expect(body[0].coordinates[1]).toBe(5); - expect(body[1].coordinates[0]).toBe(1); - expect(body[1].coordinates[1]).toBe(2); - expect(body[0].type).toBe("Point"); - expect(body[1].type).toBe("Point"); - expect(body[0].timestamp).toBe('1988-03-14T01:59:26.000Z'); - expect(body[1].timestamp).toBe('1954-06-07T12:00:00.000Z'); - }); + // Assert + expect(response.status).toBe(200) + expect(response.headers.get('content-type')).toBe( + 'application/json; charset=utf-8', + ) + expect(body).toHaveLength(2) + expect(body[0].coordinates).toHaveLength(2) + expect(body[1].coordinates).toHaveLength(2) + expect(body[0].coordinates[0]).toBe(4) + expect(body[0].coordinates[1]).toBe(5) + expect(body[1].coordinates[0]).toBe(1) + expect(body[1].coordinates[1]).toBe(2) + expect(body[0].type).toBe('Point') + expect(body[1].type).toBe('Point') + expect(body[0].timestamp).toBe('1988-03-14T01:59:26.000Z') + expect(body[1].timestamp).toBe('1954-06-07T12:00:00.000Z') + }) - it("should return locations of a box in geojson format", async () => { - // Arrange - const request = new Request( - `${BASE_URL}/api/boxes/${deviceId}/locations?from-date=${new Date('1954-06-07 11:00:00+00')}&to-date=${new Date('1988-03-14 1:59:27+00')}&format=geojson`, - { method: "GET" }, - ); + it('should return locations of a box in geojson format', async () => { + // Arrange + const request = new Request( + `${BASE_URL}/api/boxes/${deviceId}/locations?from-date=${new Date('1954-06-07 11:00:00+00')}&to-date=${new Date('1988-03-14 1:59:27+00')}&format=geojson`, + { method: 'GET' }, + ) - // Act - const dataFunctionValue = await loader({ - request, - params: { - deviceId: `${deviceId}` - } as Params, - } as LoaderFunctionArgs); // Assuming a separate loader for single sensor - const response = dataFunctionValue as Response; - const body = await response?.json(); + // Act + const dataFunctionValue = await loader({ + request, + params: { + deviceId: `${deviceId}`, + } as Params, + } as LoaderFunctionArgs) // Assuming a separate loader for single sensor + const response = dataFunctionValue as Response + const body = await response?.json() - // Assert - expect(response.status).toBe(200); - expect(response.headers.get("content-type")).toBe( - "application/geo+json; charset=utf-8", - ); - expect(body.type).toBe("Feature"); - expect(body.geometry.type).toBe("LineString"); - expect(body.geometry.coordinates).toHaveLength(2); - expect(body.geometry.coordinates[0]).toHaveLength(2); - expect(body.geometry.coordinates[1]).toHaveLength(2); - expect(body.geometry.coordinates[0][0]).toBe(4); - expect(body.geometry.coordinates[0][1]).toBe(5); - expect(body.geometry.coordinates[1][0]).toBe(1); - expect(body.geometry.coordinates[1][1]).toBe(2); - expect(body.properties.timestamps).toHaveLength(2); - expect(body.properties.timestamps[0]).toBe('1988-03-14T01:59:26.000Z'); - expect(body.properties.timestamps[1]).toBe('1954-06-07T12:00:00.000Z'); - }); - }); + // Assert + expect(response.status).toBe(200) + expect(response.headers.get('content-type')).toBe( + 'application/geo+json; charset=utf-8', + ) + expect(body.type).toBe('Feature') + expect(body.geometry.type).toBe('LineString') + expect(body.geometry.coordinates).toHaveLength(2) + expect(body.geometry.coordinates[0]).toHaveLength(2) + expect(body.geometry.coordinates[1]).toHaveLength(2) + expect(body.geometry.coordinates[0][0]).toBe(4) + expect(body.geometry.coordinates[0][1]).toBe(5) + expect(body.geometry.coordinates[1][0]).toBe(1) + expect(body.geometry.coordinates[1][1]).toBe(2) + expect(body.properties.timestamps).toHaveLength(2) + expect(body.properties.timestamps[0]).toBe('1988-03-14T01:59:26.000Z') + expect(body.properties.timestamps[1]).toBe('1954-06-07T12:00:00.000Z') + }) + }) - afterAll(async () => { - //delete measurements - if (sensors?.length > 0) { - await deleteMeasurementsForSensor(sensors[0].id); - MEASUREMENTS.forEach(async (measurement) => await deleteMeasurementsForTime(measurement.createdAt)); - } - // delete the valid test user - await deleteUserByEmail(DEVICE_SENSORS_ID_USER.email); - // delete the box - await deleteDevice({ id: deviceId }); - }); -}); + afterAll(async () => { + //delete measurements + if (sensors?.length > 0) { + await deleteMeasurementsForSensor(sensors[0].id) + MEASUREMENTS.forEach( + async (measurement) => + await deleteMeasurementsForTime(measurement.createdAt), + ) + } + // 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.$sensorId.spec.ts b/tests/routes/api.boxes.$deviceId.sensors.$sensorId.spec.ts index 25667f8e..4cc366d4 100644 --- a/tests/routes/api.boxes.$deviceId.sensors.$sensorId.spec.ts +++ b/tests/routes/api.boxes.$deviceId.sensors.$sensorId.spec.ts @@ -1,126 +1,123 @@ -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"; +import { type Params, type LoaderFunctionArgs } from 'react-router' +import { generateTestUserCredentials } from 'tests/data/generate_test_user' +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_SENSORS_ID_USER = generateTestUserCredentials() 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", - }, - ], -}; + 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[] = []; +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", - ); + 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); - }); + 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" }, - ); + 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(); + // 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"); - }); + // 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" }, - ); + 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(); + // 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", - ); + // 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"); - }); - }); + 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); + afterAll(async () => { + // delete the valid test user + await deleteUserByEmail(DEVICE_SENSORS_ID_USER.email) - // delete the box - await deleteDevice({ id: deviceId }); - }); -}); + // 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 index 918ddcf3..623b5d33 100644 --- a/tests/routes/api.boxes.$deviceId.sensors.spec.ts +++ b/tests/routes/api.boxes.$deviceId.sensors.spec.ts @@ -1,119 +1,116 @@ -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"; +import { type Params, type LoaderFunctionArgs } from 'react-router' +import { generateTestUserCredentials } from 'tests/data/generate_test_user' +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_SENSORS_USER = generateTestUserCredentials() 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", - }, - ], -}; + 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 = ""; +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", - ); + 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; - }); + 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", - }); + 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(); + // 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"); - }); + // 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" }, - ); + 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(); + // 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(); + // 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); - }); - }); + 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); + afterAll(async () => { + // delete the valid test user + await deleteUserByEmail(DEVICE_SENSORS_USER.email) - // delete the box - await deleteDevice({ id: deviceId }); - }); -}); + // delete the box + await deleteDevice({ id: deviceId }) + }) +}) diff --git a/tests/routes/api.boxes.spec.ts b/tests/routes/api.boxes.spec.ts index 0636de82..909b5281 100644 --- a/tests/routes/api.boxes.spec.ts +++ b/tests/routes/api.boxes.spec.ts @@ -1,352 +1,349 @@ -import { type ActionFunctionArgs } from "react-router"; -import { BASE_URL } from "vitest.setup"; -import { createToken } from "~/lib/jwt"; -import { registerUser } from "~/lib/user-service.server"; -import { deleteDevice } from "~/models/device.server"; -import { deleteUserByEmail } from "~/models/user.server"; -import { action } from "~/routes/api.boxes"; -import { type User } from "~/schema"; - -const BOXES_POST_TEST_USER = { - name: "testing post boxes", - email: "test@postboxes.me", - password: "some secure password", -}; - -describe("openSenseMap API Routes: /boxes", () => { - let user: User | null = null; - let jwt: string = ""; - let createdDeviceIds: string[] = []; - - beforeAll(async () => { - const testUser = await registerUser( - BOXES_POST_TEST_USER.name, - BOXES_POST_TEST_USER.email, - BOXES_POST_TEST_USER.password, - "en_US", - ); - user = testUser as User; - const { token } = await createToken(testUser as User); - jwt = token; - }); - - afterAll(async () => { - for (const deviceId of createdDeviceIds) { - try { - await deleteDevice({ id: deviceId }); - } catch (error) { - console.error(`Failed to delete device ${deviceId}:`, error); - } - } - if (user) { - await deleteUserByEmail(BOXES_POST_TEST_USER.email); - } - }); - - describe("POST", () => { - it("should create a new box with sensors", async () => { - const requestBody = { - name: "Test Weather Station", - location: [7.596, 51.969], - exposure: "outdoor", - model: "homeV2Wifi", - grouptag: ["weather", "test"], - sensors: [ - { - id: "0", - title: "Temperature", - unit: "°C", - sensorType: "HDC1080", - }, - { - id: "1", - title: "Humidity", - unit: "%", - sensorType: "HDC1080", - }, - ], - }; - - const request = new Request(`${BASE_URL}/boxes`, { - method: "POST", - headers: { - Authorization: `Bearer ${jwt}`, - "Content-Type": "application/json", - }, - body: JSON.stringify(requestBody), - }); - - const response = (await action({ - request, - } as ActionFunctionArgs)) as Response; - const body = await response.json(); - - if (body._id) { - createdDeviceIds.push(body._id); - } - - expect(response.status).toBe(201); - expect(body).toHaveProperty("_id"); - expect(body).toHaveProperty("name", "Test Weather Station"); - expect(body).toHaveProperty("sensors"); - expect(body.sensors).toHaveLength(2); - expect(body.sensors[0]).toHaveProperty("title", "Temperature"); - expect(body.sensors[1]).toHaveProperty("title", "Humidity"); - }); - - it("should create a box with minimal data (no sensors)", async () => { - const requestBody = { - name: "Minimal Test Box", - location: [7.5, 51.9], - }; - - const request = new Request(`${BASE_URL}/boxes`, { - method: "POST", - headers: { - Authorization: `Bearer ${jwt}`, - "Content-Type": "application/json", - }, - body: JSON.stringify(requestBody), - }); - - const response = (await action({ - request, - } as ActionFunctionArgs)) as Response; - const body = await response.json(); - - if (body._id) { - createdDeviceIds.push(body._id); - } - - expect(response.status).toBe(201); - expect(body).toHaveProperty("_id"); - expect(body).toHaveProperty("name", "Minimal Test Box"); - expect(body).toHaveProperty("sensors"); - expect(Array.isArray(body.sensors)).toBe(true); - expect(body.sensors).toHaveLength(0); - }); - - it("should reject creation without authentication", async () => { - const requestBody = { - name: "Unauthorized Box", - location: [7.5, 51.9], - }; - - const request = new Request(`${BASE_URL}/boxes`, { - method: "POST", - headers: { - "Content-Type": "application/json", - }, - body: JSON.stringify(requestBody), - }); - - const response = (await action({ - request, - } as ActionFunctionArgs)) as Response; - const body = await response.json(); - - expect(response.status).toBe(403); - expect(body).toHaveProperty("code", "Forbidden"); - expect(body).toHaveProperty("message"); - }); - - it("should reject creation with invalid JWT", async () => { - const requestBody = { - name: "Invalid JWT Box", - location: [7.5, 51.9], - }; - - const request = new Request(`${BASE_URL}/boxes`, { - method: "POST", - headers: { - Authorization: "Bearer invalid_jwt_token", - "Content-Type": "application/json", - }, - body: JSON.stringify(requestBody), - }); - - const response = (await action({ - request, - } as ActionFunctionArgs)) as Response; - const body = await response.json(); - - expect(response.status).toBe(403); - expect(body).toHaveProperty("code", "Forbidden"); - }); - - it("should reject creation with missing required fields", async () => { - const requestBody = { - location: [7.5, 51.9], - }; - - const request = new Request(`${BASE_URL}/boxes`, { - method: "POST", - headers: { - Authorization: `Bearer ${jwt}`, - "Content-Type": "application/json", - }, - body: JSON.stringify(requestBody), - }); - - const response = (await action({ - request, - } as ActionFunctionArgs)) as Response; - const body = await response.json(); - - expect(response.status).toBe(400); - expect(body).toHaveProperty("code", "Bad Request"); - expect(body).toHaveProperty("errors"); - expect(Array.isArray(body.errors)).toBe(true); - }); - - it("should reject creation with invalid location format", async () => { - const requestBody = { - name: "Invalid Location Box", - location: [7.5], - }; - - const request = new Request(`${BASE_URL}/boxes`, { - method: "POST", - headers: { - Authorization: `Bearer ${jwt}`, - "Content-Type": "application/json", - }, - body: JSON.stringify(requestBody), - }); - - const response = (await action({ - request, - } as ActionFunctionArgs)) as Response; - const body = await response.json(); - - expect(response.status).toBe(400); - expect(body).toHaveProperty("code", "Bad Request"); - expect(body).toHaveProperty("errors"); - }); - - it("should reject creation with invalid JSON", async () => { - const request = new Request(`${BASE_URL}/boxes`, { - method: "POST", - headers: { - Authorization: `Bearer ${jwt}`, - "Content-Type": "application/json", - }, - body: "invalid json {", - }); - - const response = (await action({ - request, - } as ActionFunctionArgs)) as Response; - const body = await response.json(); - - expect(response.status).toBe(400); - expect(body).toHaveProperty("code", "Bad Request"); - expect(body).toHaveProperty("message", "Invalid JSON in request body"); - }); - - it("should create box with default values for optional fields", async () => { - const requestBody = { - name: "Default Values Box", - location: [7.5, 51.9], - }; - - const request = new Request(`${BASE_URL}/boxes`, { - method: "POST", - headers: { - Authorization: `Bearer ${jwt}`, - "Content-Type": "application/json", - }, - body: JSON.stringify(requestBody), - }); - - const response = (await action({ - request, - } as ActionFunctionArgs)) as Response; - const body = await response.json(); - - if (body._id) { - createdDeviceIds.push(body._id); - } - - expect(response.status).toBe(201); - expect(body).toHaveProperty("exposure", "unknown"); - expect(body).toHaveProperty("model", "Custom"); - expect(body).toHaveProperty("grouptag"); - expect(body.grouptag).toEqual([]); - }); - }); - - describe("Method Not Allowed", () => { - it("should return 405 for GET requests", async () => { - const request = new Request(`${BASE_URL}/boxes`, { - method: "GET", - headers: { - Authorization: `Bearer ${jwt}`, - }, - }); - - const response = (await action({ - request, - } as ActionFunctionArgs)) as Response; - const body = await response.json(); - - expect(response.status).toBe(405); - expect(body).toHaveProperty("message", "Method Not Allowed"); - }); - - it("should return 405 for PUT requests", async () => { - const request = new Request(`${BASE_URL}/boxes`, { - method: "PUT", - headers: { - Authorization: `Bearer ${jwt}`, - "Content-Type": "application/json", - }, - body: JSON.stringify({ name: "Test" }), - }); - - const response = (await action({ - request, - } as ActionFunctionArgs)) as Response; - const body = await response.json(); - - expect(response.status).toBe(405); - expect(body).toHaveProperty("message", "Method Not Allowed"); - }); - - it("should return 405 for DELETE requests", async () => { - const request = new Request(`${BASE_URL}/boxes`, { - method: "DELETE", - headers: { - Authorization: `Bearer ${jwt}`, - }, - }); - - const response = (await action({ - request, - } as ActionFunctionArgs)) as Response; - const body = await response.json(); - - expect(response.status).toBe(405); - expect(body).toHaveProperty("message", "Method Not Allowed"); - }); - - it("should return 405 for PATCH requests", async () => { - const request = new Request(`${BASE_URL}/boxes`, { - method: "PATCH", - headers: { - Authorization: `Bearer ${jwt}`, - "Content-Type": "application/json", - }, - body: JSON.stringify({ name: "Test" }), - }); - - const response = (await action({ - request, - } as ActionFunctionArgs)) as Response; - const body = await response.json(); - - expect(response.status).toBe(405); - expect(body).toHaveProperty("message", "Method Not Allowed"); - }); - }); -}); +import { type ActionFunctionArgs } from 'react-router' +import { generateTestUserCredentials } from 'tests/data/generate_test_user' +import { BASE_URL } from 'vitest.setup' +import { createToken } from '~/lib/jwt' +import { registerUser } from '~/lib/user-service.server' +import { deleteDevice } from '~/models/device.server' +import { deleteUserByEmail } from '~/models/user.server' +import { action } from '~/routes/api.boxes' +import { type User } from '~/schema' + +const BOXES_POST_TEST_USER = generateTestUserCredentials() + +describe('openSenseMap API Routes: /boxes', () => { + let user: User | null = null + let jwt: string = '' + let createdDeviceIds: string[] = [] + + beforeAll(async () => { + const testUser = await registerUser( + BOXES_POST_TEST_USER.name, + BOXES_POST_TEST_USER.email, + BOXES_POST_TEST_USER.password, + 'en_US', + ) + user = testUser as User + const { token } = await createToken(testUser as User) + jwt = token + }) + + afterAll(async () => { + for (const deviceId of createdDeviceIds) { + try { + await deleteDevice({ id: deviceId }) + } catch (error) { + console.error(`Failed to delete device ${deviceId}:`, error) + } + } + if (user) { + await deleteUserByEmail(BOXES_POST_TEST_USER.email) + } + }) + + describe('POST', () => { + it('should create a new box with sensors', async () => { + const requestBody = { + name: 'Test Weather Station', + location: [7.596, 51.969], + exposure: 'outdoor', + model: 'homeV2Wifi', + grouptag: ['weather', 'test'], + sensors: [ + { + id: '0', + title: 'Temperature', + unit: '°C', + sensorType: 'HDC1080', + }, + { + id: '1', + title: 'Humidity', + unit: '%', + sensorType: 'HDC1080', + }, + ], + } + + const request = new Request(`${BASE_URL}/boxes`, { + method: 'POST', + headers: { + Authorization: `Bearer ${jwt}`, + 'Content-Type': 'application/json', + }, + body: JSON.stringify(requestBody), + }) + + const response = (await action({ + request, + } as ActionFunctionArgs)) as Response + const body = await response.json() + + if (body._id) { + createdDeviceIds.push(body._id) + } + + expect(response.status).toBe(201) + expect(body).toHaveProperty('_id') + expect(body).toHaveProperty('name', 'Test Weather Station') + expect(body).toHaveProperty('sensors') + expect(body.sensors).toHaveLength(2) + expect(body.sensors[0]).toHaveProperty('title', 'Temperature') + expect(body.sensors[1]).toHaveProperty('title', 'Humidity') + }) + + it('should create a box with minimal data (no sensors)', async () => { + const requestBody = { + name: 'Minimal Test Box', + location: [7.5, 51.9], + } + + const request = new Request(`${BASE_URL}/boxes`, { + method: 'POST', + headers: { + Authorization: `Bearer ${jwt}`, + 'Content-Type': 'application/json', + }, + body: JSON.stringify(requestBody), + }) + + const response = (await action({ + request, + } as ActionFunctionArgs)) as Response + const body = await response.json() + + if (body._id) { + createdDeviceIds.push(body._id) + } + + expect(response.status).toBe(201) + expect(body).toHaveProperty('_id') + expect(body).toHaveProperty('name', 'Minimal Test Box') + expect(body).toHaveProperty('sensors') + expect(Array.isArray(body.sensors)).toBe(true) + expect(body.sensors).toHaveLength(0) + }) + + it('should reject creation without authentication', async () => { + const requestBody = { + name: 'Unauthorized Box', + location: [7.5, 51.9], + } + + const request = new Request(`${BASE_URL}/boxes`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify(requestBody), + }) + + const response = (await action({ + request, + } as ActionFunctionArgs)) as Response + const body = await response.json() + + expect(response.status).toBe(403) + expect(body).toHaveProperty('code', 'Forbidden') + expect(body).toHaveProperty('message') + }) + + it('should reject creation with invalid JWT', async () => { + const requestBody = { + name: 'Invalid JWT Box', + location: [7.5, 51.9], + } + + const request = new Request(`${BASE_URL}/boxes`, { + method: 'POST', + headers: { + Authorization: 'Bearer invalid_jwt_token', + 'Content-Type': 'application/json', + }, + body: JSON.stringify(requestBody), + }) + + const response = (await action({ + request, + } as ActionFunctionArgs)) as Response + const body = await response.json() + + expect(response.status).toBe(403) + expect(body).toHaveProperty('code', 'Forbidden') + }) + + it('should reject creation with missing required fields', async () => { + const requestBody = { + location: [7.5, 51.9], + } + + const request = new Request(`${BASE_URL}/boxes`, { + method: 'POST', + headers: { + Authorization: `Bearer ${jwt}`, + 'Content-Type': 'application/json', + }, + body: JSON.stringify(requestBody), + }) + + const response = (await action({ + request, + } as ActionFunctionArgs)) as Response + const body = await response.json() + + expect(response.status).toBe(400) + expect(body).toHaveProperty('code', 'Bad Request') + expect(body).toHaveProperty('errors') + expect(Array.isArray(body.errors)).toBe(true) + }) + + it('should reject creation with invalid location format', async () => { + const requestBody = { + name: 'Invalid Location Box', + location: [7.5], + } + + const request = new Request(`${BASE_URL}/boxes`, { + method: 'POST', + headers: { + Authorization: `Bearer ${jwt}`, + 'Content-Type': 'application/json', + }, + body: JSON.stringify(requestBody), + }) + + const response = (await action({ + request, + } as ActionFunctionArgs)) as Response + const body = await response.json() + + expect(response.status).toBe(400) + expect(body).toHaveProperty('code', 'Bad Request') + expect(body).toHaveProperty('errors') + }) + + it('should reject creation with invalid JSON', async () => { + const request = new Request(`${BASE_URL}/boxes`, { + method: 'POST', + headers: { + Authorization: `Bearer ${jwt}`, + 'Content-Type': 'application/json', + }, + body: 'invalid json {', + }) + + const response = (await action({ + request, + } as ActionFunctionArgs)) as Response + const body = await response.json() + + expect(response.status).toBe(400) + expect(body).toHaveProperty('code', 'Bad Request') + expect(body).toHaveProperty('message', 'Invalid JSON in request body') + }) + + it('should create box with default values for optional fields', async () => { + const requestBody = { + name: 'Default Values Box', + location: [7.5, 51.9], + } + + const request = new Request(`${BASE_URL}/boxes`, { + method: 'POST', + headers: { + Authorization: `Bearer ${jwt}`, + 'Content-Type': 'application/json', + }, + body: JSON.stringify(requestBody), + }) + + const response = (await action({ + request, + } as ActionFunctionArgs)) as Response + const body = await response.json() + + if (body._id) { + createdDeviceIds.push(body._id) + } + + expect(response.status).toBe(201) + expect(body).toHaveProperty('exposure', 'unknown') + expect(body).toHaveProperty('model', 'Custom') + expect(body).toHaveProperty('grouptag') + expect(body.grouptag).toEqual([]) + }) + }) + + describe('Method Not Allowed', () => { + it('should return 405 for GET requests', async () => { + const request = new Request(`${BASE_URL}/boxes`, { + method: 'GET', + headers: { + Authorization: `Bearer ${jwt}`, + }, + }) + + const response = (await action({ + request, + } as ActionFunctionArgs)) as Response + const body = await response.json() + + expect(response.status).toBe(405) + expect(body).toHaveProperty('message', 'Method Not Allowed') + }) + + it('should return 405 for PUT requests', async () => { + const request = new Request(`${BASE_URL}/boxes`, { + method: 'PUT', + headers: { + Authorization: `Bearer ${jwt}`, + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ name: 'Test' }), + }) + + const response = (await action({ + request, + } as ActionFunctionArgs)) as Response + const body = await response.json() + + expect(response.status).toBe(405) + expect(body).toHaveProperty('message', 'Method Not Allowed') + }) + + it('should return 405 for DELETE requests', async () => { + const request = new Request(`${BASE_URL}/boxes`, { + method: 'DELETE', + headers: { + Authorization: `Bearer ${jwt}`, + }, + }) + + const response = (await action({ + request, + } as ActionFunctionArgs)) as Response + const body = await response.json() + + expect(response.status).toBe(405) + expect(body).toHaveProperty('message', 'Method Not Allowed') + }) + + it('should return 405 for PATCH requests', async () => { + const request = new Request(`${BASE_URL}/boxes`, { + method: 'PATCH', + headers: { + Authorization: `Bearer ${jwt}`, + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ name: 'Test' }), + }) + + const response = (await action({ + request, + } as ActionFunctionArgs)) as Response + const body = await response.json() + + expect(response.status).toBe(405) + expect(body).toHaveProperty('message', 'Method Not Allowed') + }) + }) +}) diff --git a/tests/routes/api.claim.spec.ts b/tests/routes/api.claim.spec.ts index 3107d800..409d43fc 100644 --- a/tests/routes/api.claim.spec.ts +++ b/tests/routes/api.claim.spec.ts @@ -1,253 +1,246 @@ -import { type ActionFunctionArgs } from "react-router"; -import { BASE_URL } from "vitest.setup"; -import { createToken } from "~/lib/jwt"; -import { registerUser } from "~/lib/user-service.server"; -import { createDevice, getDevice } from "~/models/device.server"; -import { deleteUserByEmail } from "~/models/user.server"; -import { action as claimAction } from "~/routes/api.claim"; -import { action as transferAction } from "~/routes/api.transfer"; -import { type Device, type User } from "~/schema"; - -const CLAIM_TEST_USER = { - name: "claimtestuser" + Date.now(), - email: `claimtest${Date.now()}@test.com`, - password: "highlySecurePasswordForTesting", -}; +import { type ActionFunctionArgs } from 'react-router' +import { generateTestUserCredentials } from 'tests/data/generate_test_user' +import { BASE_URL } from 'vitest.setup' +import { createToken } from '~/lib/jwt' +import { registerUser } from '~/lib/user-service.server' +import { createDevice, getDevice } from '~/models/device.server' +import { deleteUserByEmail } from '~/models/user.server' +import { action as claimAction } from '~/routes/api.claim' +import { action as transferAction } from '~/routes/api.transfer' +import { type Device, type User } from '~/schema' + +const CLAIM_TEST_USER = generateTestUserCredentials() const createTestUser = async (suffix: string): Promise => { - const result = await registerUser( - "testuser" + suffix, - `test${suffix}@test.com`, - "password123", - "en_US" - ); + const u = generateTestUserCredentials() + const result = await registerUser(u.name, u.email, u.password, 'en_US') - if (!result || (typeof result === 'object' && 'isValid' in result)) { - throw new Error("Failed to create test user"); - } + if (!result || (typeof result === 'object' && 'isValid' in result)) { + throw new Error('Failed to create test user') + } - return result as User; -}; + return result as User +} const generateMinimalDevice = ( - location: number[] | {} = [123, 12, 34], - exposure = "mobile", - name = "" + new Date().getTime() + location: number[] | {} = [123, 12, 34], + exposure = 'mobile', + name = '' + new Date().getTime(), ) => ({ - exposure, - location, - name, - model: "homeV2Ethernet", -}); - -describe("openSenseMap API Routes: /boxes/claim", () => { - let user: User | null = null; - let jwt: string = ""; - let queryableDevice: Device | null = null; - - beforeAll(async () => { - const testUser = await registerUser( - CLAIM_TEST_USER.name, - CLAIM_TEST_USER.email, - CLAIM_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 - ); - }); - - afterAll(async () => { - await deleteUserByEmail(CLAIM_TEST_USER.email); - }); - - describe("POST /boxes/claim", () => { - it("should claim a device and transfer ownership from one user to another", async () => { - // Create a new transfer for the claim test - const createTransferRequest = 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 transferResponse = (await transferAction({ - request: createTransferRequest, - } as ActionFunctionArgs)) as Response; - - const transferBody = await transferResponse.json(); - const claimToken = transferBody.data.token; - - const newUser = await createTestUser(Date.now().toString()); - const { token: newUserJwt } = await createToken(newUser); - - // Claim the device - const claimRequest = new Request(`${BASE_URL}/boxes/claim`, { - method: "POST", - headers: { - "Content-Type": "application/json", - Authorization: `Bearer ${newUserJwt}`, - }, - body: JSON.stringify({ token: claimToken }), - }); - - const claimResponse = (await claimAction({ - request: claimRequest, - } as ActionFunctionArgs)) as Response; - - expect(claimResponse.status).toBe(200); - const claimBody = await claimResponse.json(); - expect(claimBody.message).toBe("Device successfully claimed!"); - expect(claimBody.data.boxId).toBe(queryableDevice!.id); - - // Verify the device is now owned by the new user - const updatedDevice = await getDevice({ id: queryableDevice!.id }); - expect(updatedDevice?.user.id).toBe(newUser.id); - - // Verify the transfer token is deleted (can't be used again) - const reusedClaimRequest = new Request(`${BASE_URL}/boxes/claim`, { - method: "POST", - headers: { - "Content-Type": "application/json", - Authorization: `Bearer ${newUserJwt}`, - }, - body: JSON.stringify({ token: claimToken }), - }); - - const reusedResponse = (await claimAction({ - request: reusedClaimRequest, - } as ActionFunctionArgs)) as Response; - - expect(reusedResponse.status).toBe(410); - - // Cleanup - await deleteUserByEmail((newUser as User).email); - }); - - it("should reject claim with invalid content-type", async () => { - // Create a fresh device for this test - const testDevice = await createDevice( - { ...generateMinimalDevice(), latitude: 456, longitude: 78 }, - (user as User).id - ); - - // Create a transfer for this test - const createTransferRequest = new Request(`${BASE_URL}/boxes/transfer`, { - method: "POST", - headers: { - "Content-Type": "application/x-www-form-urlencoded", - Authorization: `Bearer ${jwt}`, - }, - body: new URLSearchParams({ boxId: testDevice!.id }), - }); - - const transferResponse = (await transferAction({ - request: createTransferRequest, - } as ActionFunctionArgs)) as Response; - - expect(transferResponse.status).toBe(201); - const transferBody = await transferResponse.json(); - expect(transferBody.data).toBeDefined(); - const claimToken = transferBody.data.token; - - const claimRequest = new Request(`${BASE_URL}/boxes/claim`, { - method: "POST", - headers: { - "Content-Type": "application/x-www-form-urlencoded", - Authorization: `Bearer ${jwt}`, - }, - body: new URLSearchParams({ token: claimToken }), - }); - - const claimResponse = (await claimAction({ - request: claimRequest, - } as ActionFunctionArgs)) as Response; - - expect(claimResponse.status).toBe(415); - const body = await claimResponse.json(); - expect(body.code).toBe("Unsupported Media Type"); - expect(body.message).toContain("application/json"); - }); - - it("should reject claim without Authorization header", async () => { - // Create a fresh device for this test - const testDevice = await createDevice( - { ...generateMinimalDevice(), latitude: 789, longitude: 101 }, - (user as User).id - ); - - // Create a transfer for this test - const createTransferRequest = new Request(`${BASE_URL}/boxes/transfer`, { - method: "POST", - headers: { - "Content-Type": "application/x-www-form-urlencoded", - Authorization: `Bearer ${jwt}`, - }, - body: new URLSearchParams({ boxId: testDevice!.id }), - }); - - const transferResponse = (await transferAction({ - request: createTransferRequest, - } as ActionFunctionArgs)) as Response; - - expect(transferResponse.status).toBe(201); - const transferBody = await transferResponse.json(); - expect(transferBody.data).toBeDefined(); - const claimToken = transferBody.data.token; - - const claimRequest = new Request(`${BASE_URL}/boxes/claim`, { - method: "POST", - headers: { - "Content-Type": "application/json", - }, - body: JSON.stringify({ token: claimToken }), - }); - - const claimResponse = (await claimAction({ - request: claimRequest, - } as ActionFunctionArgs)) as Response; - - expect(claimResponse.status).toBe(403); - const body = await claimResponse.json(); - expect(body.code).toBe("Forbidden"); - }); - - it("should reject claim with expired transfer token", async () => { - // Create a new user to attempt the claim - const newUser = await registerUser( - "claimer" + Date.now(), - `claimer${Date.now()}@test.com`, - "password123", - "en_US" - ); - const { token: newUserJwt } = await createToken(newUser as User); - - const claimRequest = new Request(`${BASE_URL}/boxes/claim`, { - method: "POST", - headers: { - "Content-Type": "application/json", - Authorization: `Bearer ${newUserJwt}`, - }, - body: JSON.stringify({ token: "invalid-or-expired-token" }), - }); - - const claimResponse = (await claimAction({ - request: claimRequest, - } as ActionFunctionArgs)) as Response; - - expect(claimResponse.status).toBe(410); - const body = await claimResponse.json(); - expect(body.error).toContain("expired"); - - // Cleanup - await deleteUserByEmail((newUser as User).email); - }); - }); -}); \ No newline at end of file + exposure, + location, + name, + model: 'homeV2Ethernet', +}) + +describe('openSenseMap API Routes: /boxes/claim', () => { + let user: User | null = null + let jwt: string = '' + let queryableDevice: Device | null = null + + beforeAll(async () => { + const testUser = await registerUser( + CLAIM_TEST_USER.name, + CLAIM_TEST_USER.email, + CLAIM_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, + ) + }) + + afterAll(async () => { + await deleteUserByEmail(CLAIM_TEST_USER.email) + }) + + describe('POST /boxes/claim', () => { + it('should claim a device and transfer ownership from one user to another', async () => { + // Create a new transfer for the claim test + const createTransferRequest = 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 transferResponse = (await transferAction({ + request: createTransferRequest, + } as ActionFunctionArgs)) as Response + + const transferBody = await transferResponse.json() + const claimToken = transferBody.data.token + + const newUser = await createTestUser(Date.now().toString()) + const { token: newUserJwt } = await createToken(newUser) + + // Claim the device + const claimRequest = new Request(`${BASE_URL}/boxes/claim`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${newUserJwt}`, + }, + body: JSON.stringify({ token: claimToken }), + }) + + const claimResponse = (await claimAction({ + request: claimRequest, + } as ActionFunctionArgs)) as Response + + expect(claimResponse.status).toBe(200) + const claimBody = await claimResponse.json() + expect(claimBody.message).toBe('Device successfully claimed!') + expect(claimBody.data.boxId).toBe(queryableDevice!.id) + + // Verify the device is now owned by the new user + const updatedDevice = await getDevice({ id: queryableDevice!.id }) + expect(updatedDevice?.user.id).toBe(newUser.id) + + // Verify the transfer token is deleted (can't be used again) + const reusedClaimRequest = new Request(`${BASE_URL}/boxes/claim`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${newUserJwt}`, + }, + body: JSON.stringify({ token: claimToken }), + }) + + const reusedResponse = (await claimAction({ + request: reusedClaimRequest, + } as ActionFunctionArgs)) as Response + + expect(reusedResponse.status).toBe(410) + + // Cleanup + await deleteUserByEmail((newUser as User).email) + }) + + it('should reject claim with invalid content-type', async () => { + // Create a fresh device for this test + const testDevice = await createDevice( + { ...generateMinimalDevice(), latitude: 456, longitude: 78 }, + (user as User).id, + ) + + // Create a transfer for this test + const createTransferRequest = new Request(`${BASE_URL}/boxes/transfer`, { + method: 'POST', + headers: { + 'Content-Type': 'application/x-www-form-urlencoded', + Authorization: `Bearer ${jwt}`, + }, + body: new URLSearchParams({ boxId: testDevice!.id }), + }) + + const transferResponse = (await transferAction({ + request: createTransferRequest, + } as ActionFunctionArgs)) as Response + + expect(transferResponse.status).toBe(201) + const transferBody = await transferResponse.json() + expect(transferBody.data).toBeDefined() + const claimToken = transferBody.data.token + + const claimRequest = new Request(`${BASE_URL}/boxes/claim`, { + method: 'POST', + headers: { + 'Content-Type': 'application/x-www-form-urlencoded', + Authorization: `Bearer ${jwt}`, + }, + body: new URLSearchParams({ token: claimToken }), + }) + + const claimResponse = (await claimAction({ + request: claimRequest, + } as ActionFunctionArgs)) as Response + + expect(claimResponse.status).toBe(415) + const body = await claimResponse.json() + expect(body.code).toBe('Unsupported Media Type') + expect(body.message).toContain('application/json') + }) + + it('should reject claim without Authorization header', async () => { + // Create a fresh device for this test + const testDevice = await createDevice( + { ...generateMinimalDevice(), latitude: 789, longitude: 101 }, + (user as User).id, + ) + + // Create a transfer for this test + const createTransferRequest = new Request(`${BASE_URL}/boxes/transfer`, { + method: 'POST', + headers: { + 'Content-Type': 'application/x-www-form-urlencoded', + Authorization: `Bearer ${jwt}`, + }, + body: new URLSearchParams({ boxId: testDevice!.id }), + }) + + const transferResponse = (await transferAction({ + request: createTransferRequest, + } as ActionFunctionArgs)) as Response + + expect(transferResponse.status).toBe(201) + const transferBody = await transferResponse.json() + expect(transferBody.data).toBeDefined() + const claimToken = transferBody.data.token + + const claimRequest = new Request(`${BASE_URL}/boxes/claim`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ token: claimToken }), + }) + + const claimResponse = (await claimAction({ + request: claimRequest, + } as ActionFunctionArgs)) as Response + + expect(claimResponse.status).toBe(403) + const body = await claimResponse.json() + expect(body.code).toBe('Forbidden') + }) + + it('should reject claim with expired transfer token', async () => { + // Create a new user to attempt the claim + const newUser = await registerUser( + 'claimer' + Date.now(), + `claimer${Date.now()}@test.com`, + 'password123', + 'en_US', + ) + const { token: newUserJwt } = await createToken(newUser as User) + + const claimRequest = new Request(`${BASE_URL}/boxes/claim`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${newUserJwt}`, + }, + body: JSON.stringify({ token: 'invalid-or-expired-token' }), + }) + + const claimResponse = (await claimAction({ + request: claimRequest, + } as ActionFunctionArgs)) as Response + + expect(claimResponse.status).toBe(410) + const body = await claimResponse.json() + expect(body.error).toContain('expired') + + // Cleanup + await deleteUserByEmail((newUser as User).email) + }) + }) +}) diff --git a/tests/routes/api.devices.spec.ts b/tests/routes/api.devices.spec.ts index 4a3d5452..21f2f981 100644 --- a/tests/routes/api.devices.spec.ts +++ b/tests/routes/api.devices.spec.ts @@ -3,6 +3,7 @@ import { type LoaderFunctionArgs, type ActionFunctionArgs, } from 'react-router' +import { generateTestUserCredentials } from 'tests/data/generate_test_user' import { BASE_URL } from 'vitest.setup' import { createToken } from '~/lib/jwt' import { registerUser } from '~/lib/user-service.server' @@ -15,11 +16,7 @@ import { } from '~/routes/api.devices' import { type User, type Device } from '~/schema' -const DEVICE_TEST_USER = { - name: 'deviceTest', - email: 'test@devices.endpoint', - password: 'highlySecurePasswordForTesting', -} +const DEVICE_TEST_USER = generateTestUserCredentials() const generateMinimalDevice = ( location: number[] | {} = [123, 12, 34], @@ -49,7 +46,12 @@ describe('openSenseMap API Routes: /boxes', () => { jwt = t queryableDevice = await createDevice( - { ...generateMinimalDevice(), latitude: 123, longitude: 12, tags: ["newgroup"] }, + { + ...generateMinimalDevice(), + latitude: 123, + longitude: 12, + tags: ['newgroup'], + }, (testUser as User).id, ) }) @@ -299,27 +301,29 @@ describe('openSenseMap API Routes: /boxes', () => { it('should allow to filter boxes by grouptag', async () => { // Arrange const request = new Request(`${BASE_URL}?grouptag=newgroup`, { - method: 'GET', - headers: { 'Content-Type': 'application/json' }, - }); - + method: 'GET', + headers: { 'Content-Type': 'application/json' }, + }) + // Act - const response = await devicesLoader({ request } as LoaderFunctionArgs); - + const response = await devicesLoader({ request } as LoaderFunctionArgs) + // Handle case where loader returned a Response (e.g. validation error) - const data = response instanceof Response ? await response.json() : response; - - expect(data).toBeDefined(); - expect(Array.isArray(data)).toBe(true); - - expect(data).toHaveLength(1); - + const data = + response instanceof Response ? await response.json() : response + + expect(data).toBeDefined() + expect(Array.isArray(data)).toBe(true) + + expect(data).toHaveLength(1) + if (response instanceof Response) { - expect(response.status).toBe(200); - expect(response.headers.get('content-type')).toMatch(/application\/json/); + expect(response.status).toBe(200) + expect(response.headers.get('content-type')).toMatch( + /application\/json/, + ) } - }); - + }) it('should allow filtering boxes by bounding box', async () => { // Arrange @@ -431,51 +435,54 @@ describe('openSenseMap API Routes: /boxes', () => { it('should reject a new box with invalid coords', async () => { function minimalSensebox(coords: number[]) { - return { - name: "Test Box", - location: coords, - sensors: [], - }; + return { + name: 'Test Box', + location: coords, + sensors: [], + } } - - const requestBody = minimalSensebox([52]); - + + const requestBody = minimalSensebox([52]) + const request = new Request(BASE_URL, { - method: 'POST', - headers: { - Authorization: `Bearer ${jwt}`, - }, - body: JSON.stringify(requestBody), - }); - + method: 'POST', + headers: { + Authorization: `Bearer ${jwt}`, + }, + body: JSON.stringify(requestBody), + }) + try { - await devicesAction({ request } as ActionFunctionArgs); + await devicesAction({ request } as ActionFunctionArgs) } catch (error) { - if (error instanceof Response) { - expect(error.status).toBe(422); - - const errorData = await error.json(); - expect(errorData.message).toBe( - 'Illegal value for parameter location. missing latitude or longitude in location [52]' - ); - } else { - throw error; - } + if (error instanceof Response) { + expect(error.status).toBe(422) + + const errorData = await error.json() + expect(errorData.message).toBe( + 'Illegal value for parameter location. missing latitude or longitude in location [52]', + ) + } else { + throw error + } } - }); - + }) it('should reject a new box without location field', async () => { // Arrange - function minimalSensebox(coords: number[]): {name: string, location?: number[], sensors: any[]} { + function minimalSensebox(coords: number[]): { + name: string + location?: number[] + sensors: any[] + } { return { - name: "Test Box", - location: coords, - sensors: [], - }; - } - - const requestBody = minimalSensebox([52]); + name: 'Test Box', + location: coords, + sensors: [], + } + } + + const requestBody = minimalSensebox([52]) delete requestBody.location const request = new Request(BASE_URL, { diff --git a/tests/routes/api.location.spec.ts b/tests/routes/api.location.spec.ts index 8db61caf..abb48c3b 100644 --- a/tests/routes/api.location.spec.ts +++ b/tests/routes/api.location.spec.ts @@ -1,680 +1,695 @@ -import { eq, sql } from "drizzle-orm"; -import { type AppLoadContext, type ActionFunctionArgs } from "react-router"; -import { describe, it, expect, beforeAll, afterAll } from "vitest"; -import { BASE_URL } from "vitest.setup"; -import { drizzleClient } from "~/db.server"; -import { registerUser } from "~/lib/user-service.server"; -import { createDevice, deleteDevice, getDevice } from "~/models/device.server"; -import { deleteUserByEmail } from "~/models/user.server"; -import { action as postSingleMeasurementAction } from "~/routes/api.boxes.$deviceId.$sensorId"; -import { action as postMeasurementsAction} from "~/routes/api.boxes.$deviceId.data"; -import { location, deviceToLocation, measurement, type User, device } from "~/schema"; - -const mockAccessToken = "valid-access-token-location-tests"; - -const TEST_USER = { - name: "testing location measurements", - email: "test@locationmeasurement.me", - password: "some secure password for locations", -}; +import { eq, sql } from 'drizzle-orm' +import { type AppLoadContext, type ActionFunctionArgs } from 'react-router' +import { generateTestUserCredentials } from 'tests/data/generate_test_user' +import { describe, it, expect, beforeAll, afterAll } from 'vitest' +import { BASE_URL } from 'vitest.setup' +import { drizzleClient } from '~/db.server' +import { registerUser } from '~/lib/user-service.server' +import { createDevice, deleteDevice, getDevice } from '~/models/device.server' +import { deleteUserByEmail } from '~/models/user.server' +import { action as postSingleMeasurementAction } from '~/routes/api.boxes.$deviceId.$sensorId' +import { action as postMeasurementsAction } from '~/routes/api.boxes.$deviceId.data' +import { location, deviceToLocation, measurement, type User } from '~/schema' + +const mockAccessToken = 'valid-access-token-location-tests' + +const TEST_USER = generateTestUserCredentials() const TEST_BOX = { - name: `'${TEST_USER.name}'s Box`, - exposure: "outdoor", - expiresAt: null, - tags: [], - latitude: 0, - longitude: 0, - model: "luftdaten.info", - mqttEnabled: false, - ttnEnabled: false, - sensors: [ - { title: "Temperature", unit: "°C", sensorType: "temperature" }, - { title: "Humidity", unit: "%", sensorType: "humidity" }, - { title: "Pressure", unit: "hPa", sensorType: "pressure" } - ], -}; - -describe("openSenseMap API Routes: Location Measurements", () => { - let userId: string = ""; - let deviceId: string = ""; - let sensorIds: string[] = []; - let sensors: any[] = []; - - // Helper function to get device's current location - async function getDeviceCurrentLocation(deviceId: string) { - const deviceWithLocations = await drizzleClient.query.device.findFirst({ - where: (device, { eq }) => eq(device.id, deviceId), - with: { - locations: { - orderBy: (deviceToLocation, { desc }) => [desc(deviceToLocation.time)], - limit: 1, - with: { - geometry: { - columns: {}, - extras: { - x: sql`ST_X(${location.location})`.as('x'), - y: sql`ST_Y(${location.location})`.as('y'), - }, - }, - }, - }, - }, - }); - - if (deviceWithLocations?.locations?.[0]?.geometry) { - const geo = deviceWithLocations.locations[0].geometry; - return { - coordinates: [geo.x, geo.y, 0], - time: deviceWithLocations.locations[0].time, - }; - } - return null; - } - - // Helper to get all device locations - async function getDeviceLocations(deviceId: string) { - const result = await drizzleClient - .select({ - timestamp: deviceToLocation.time, - x: sql`ST_X(${location.location})`, - y: sql`ST_Y(${location.location})`, - }) - .from(deviceToLocation) - .innerJoin(location, eq(deviceToLocation.locationId, location.id)) - .where(eq(deviceToLocation.deviceId, deviceId)) - .orderBy(deviceToLocation.time); - - return result.map(r => ({ - timestamp: r.timestamp, - coordinates: [r.x, r.y, 0], - })); - } - - // Helper to get measurements for a sensor - async function getSensorMeasurements(sensorId: string) { - const results = await drizzleClient - .select({ - value: measurement.value, - time: measurement.time, - locationId: measurement.locationId, - x: sql`ST_X(${location.location})`, - y: sql`ST_Y(${location.location})`, - }) - .from(measurement) - .leftJoin(location, eq(measurement.locationId, location.id)) - .where(eq(measurement.sensorId, sensorId)) - .orderBy(measurement.time); - - return results.map(r => ({ - value: String(r.value), - time: r.time, - location: r.x && r.y ? [r.x, r.y, 0] : null, - })); - } - - beforeAll(async () => { - const user = await registerUser( - TEST_USER.name, - TEST_USER.email, - TEST_USER.password, - "en_US", - ); - userId = (user as User).id; - const device = await createDevice(TEST_BOX, userId); - deviceId = device.id; - - const deviceWithSensors = await getDevice({ id: deviceId }); - sensorIds = deviceWithSensors?.sensors?.map((sensor: any) => sensor.id) || []; - sensors = deviceWithSensors?.sensors?.map((sensor: any) => sensor) || []; - }); - - afterAll(async () => { - await deleteUserByEmail(TEST_USER.email); - await deleteDevice({ id: deviceId }); - }); - - describe("POST /boxes/:deviceId/:sensorId with locations", () => { - it("should allow updating a box's location via new measurement (array)", async () => { - const measurement = { - value: 3, - location: [3, 3, 3] - }; - - const request = new Request( - `${BASE_URL}/api/boxes/${deviceId}/${sensorIds[0]}`, - { - method: "POST", - headers: { - "Content-Type": "application/json", - Authorization: mockAccessToken, - }, - body: JSON.stringify(measurement), - } - ); - - const response: any = await postSingleMeasurementAction({ - request, - params: { deviceId: deviceId, sensorId: sensorIds[0] }, - context: {} as AppLoadContext - } satisfies ActionFunctionArgs); - - expect(response).toBeInstanceOf(Response); - expect(response.status).toBe(201); - expect(await response.text()).toBe("Measurement saved in box"); - - const currentLocation = await getDeviceCurrentLocation(deviceId); - expect(currentLocation).not.toBeNull(); - expect(currentLocation!.coordinates[0]).toBeCloseTo(3, 5); - expect(currentLocation!.coordinates[1]).toBeCloseTo(3, 5); - }); - - it("should allow updating a box's location via new measurement (latLng)", async () => { - const measurement = { - value: 4, - location: { lat: 4, lng: 4, height: 4 } - }; - - const request = new Request( - `${BASE_URL}/api/boxes/${deviceId}/${sensorIds[0]}`, - { - method: "POST", - headers: { - "Content-Type": "application/json", - Authorization: mockAccessToken, - }, - body: JSON.stringify(measurement), - } - ); - - const response: any = await postSingleMeasurementAction({ - request, - params: { deviceId: deviceId, sensorId: sensorIds[0] }, - context: {} as AppLoadContext - } satisfies ActionFunctionArgs); - - expect(response).toBeInstanceOf(Response); - expect(response.status).toBe(201); - - const currentLocation = await getDeviceCurrentLocation(deviceId); - expect(currentLocation).not.toBeNull(); - expect(currentLocation!.coordinates[0]).toBeCloseTo(4, 5); - expect(currentLocation!.coordinates[1]).toBeCloseTo(4, 5); - }); - - it("should not update box.currentLocation for an earlier timestamp", async () => { - // First, post a measurement with current time and location [4, 4] - const currentMeasurement = { - value: 4.1, - location: [4, 4, 0] - }; - - let request = new Request( - `${BASE_URL}/api/boxes/${deviceId}/${sensorIds[0]}`, - { - method: "POST", - headers: { - "Content-Type": "application/json", - Authorization: mockAccessToken, - }, - body: JSON.stringify(currentMeasurement), - } - ); - - await postSingleMeasurementAction({ - request, - params: { deviceId: deviceId, sensorId: sensorIds[0] }, - context: {} as AppLoadContext - } satisfies ActionFunctionArgs); - - // Get current location after first post - const locationAfterCurrent = await getDeviceCurrentLocation(deviceId); - expect(locationAfterCurrent!.coordinates[0]).toBeCloseTo(4, 5); - expect(locationAfterCurrent!.coordinates[1]).toBeCloseTo(4, 5); - - // Now post a measurement with an earlier timestamp - const pastTime = new Date(Date.now() - 60000); // 1 minute ago - const pastMeasurement = { - value: -1, - location: [-1, -1, -1], - createdAt: pastTime.toISOString(), - }; - - request = new Request( - `${BASE_URL}/api/boxes/${deviceId}/${sensorIds[0]}`, - { - method: "POST", - headers: { - "Content-Type": "application/json", - Authorization: mockAccessToken, - }, - body: JSON.stringify(pastMeasurement), - } - ); - - const response: any = await postSingleMeasurementAction({ - request, - params: { deviceId: deviceId, sensorId: sensorIds[0] }, - context: {} as AppLoadContext - } satisfies ActionFunctionArgs); - - expect(response.status).toBe(201); - - // Verify location was NOT updated (should still be [4, 4]) - const locationAfterPast = await getDeviceCurrentLocation(deviceId); - expect(locationAfterPast!.coordinates[0]).toBeCloseTo(4, 5); - expect(locationAfterPast!.coordinates[1]).toBeCloseTo(4, 5); - }); - - it("should predate first location for measurement with timestamp and no location", async () => { - // Create a fresh device for this test to avoid interference - const testDevice = await createDevice({ - ...TEST_BOX, - name: "Location Predate Test Box" - }, userId); - - - const testDeviceData = await getDevice({ id: testDevice.id }); - const testSensorId = testDeviceData?.sensors?.[0]?.id; - - const createdAt = new Date(Date.now() - 600000); // 10 minutes ago - const measurement = { - value: -1, - createdAt: createdAt.toISOString() - }; - - const request = new Request( - `${BASE_URL}/api/boxes/${testDevice.id}/${testSensorId}`, - { - method: "POST", - headers: { - "Content-Type": "application/json", - Authorization: mockAccessToken, - }, - body: JSON.stringify(measurement), - } - ); - - const response: any = await postSingleMeasurementAction({ - request, - params: { deviceId: testDevice.id, sensorId: testSensorId }, - context: {} as AppLoadContext - } satisfies ActionFunctionArgs); - - expect(response.status).toBe(201); - - // Get device locations - should be empty since no location was provided - const locations = await getDeviceLocations(testDevice.id); - expect(locations).toHaveLength(0); - - // Cleanup - await deleteDevice({ id: testDevice.id }); - }); - - it("should infer measurement.location for measurements without location", async () => { - // Create a fresh device for this test - const testDevice = await createDevice({ - ...TEST_BOX, - name: "Location Inference Test Box" - }, userId); - - const testDeviceData = await getDevice({ id: testDevice.id }); - const testSensorId = testDeviceData?.sensors?.[0]?.id; - - // First, set a location at time T-2 minutes - const time1 = new Date(Date.now() - 120000); - const measurement1 = { - value: -1, - location: [-1, -1, -1], - createdAt: time1.toISOString() - }; - - let request = new Request( - `${BASE_URL}/api/boxes/${testDevice.id}/${testSensorId}`, - { - method: "POST", - headers: { - "Content-Type": "application/json", - Authorization: mockAccessToken, - }, - body: JSON.stringify(measurement1), - } - ); - - await postSingleMeasurementAction({ - request, - params: { deviceId: testDevice.id, sensorId: testSensorId }, - context: {} as AppLoadContext - } satisfies ActionFunctionArgs); - - // Second, set a different location at time T (now) - const time2 = new Date(); - const measurement2 = { - value: 1, - location: [1, 1, 1], - createdAt: time2.toISOString() - }; - - request = new Request( - `${BASE_URL}/api/boxes/${testDevice.id}/${testSensorId}`, - { - method: "POST", - headers: { - "Content-Type": "application/json", - Authorization: mockAccessToken, - }, - body: JSON.stringify(measurement2), - } - ); - - await postSingleMeasurementAction({ - request, - params: { deviceId: testDevice.id, sensorId: testSensorId }, - context: {} as AppLoadContext - } satisfies ActionFunctionArgs); - - // Now post a measurement without location at T-1 minute (between the two locations) - const time3 = new Date(Date.now() - 60000); - const measurement3 = { - value: -0.5, - createdAt: time3.toISOString() - }; - - request = new Request( - `${BASE_URL}/api/boxes/${testDevice.id}/${testSensorId}`, - { - method: "POST", - headers: { - "Content-Type": "application/json", - Authorization: mockAccessToken, - }, - body: JSON.stringify(measurement3), - } - ); - - await postSingleMeasurementAction({ - request, - params: { deviceId: testDevice.id, sensorId: testSensorId }, - context: {} as AppLoadContext - } satisfies ActionFunctionArgs); - - // Get all measurements and check their inferred locations - const measurements = await getSensorMeasurements(testSensorId!); - - const m1 = measurements.find(m => m.value === '-0.5'); - expect(m1).toBeDefined(); - expect(m1!.location).not.toBeNull(); - expect(m1!.location![0]).toBeCloseTo(-1, 5); // Should have location from T-2 - expect(m1!.location![1]).toBeCloseTo(-1, 5); - - const m2 = measurements.find(m => m.value === '1'); - expect(m2).toBeDefined(); - expect(m2!.location).not.toBeNull(); - expect(m2!.location![0]).toBeCloseTo(1, 5); - expect(m2!.location![1]).toBeCloseTo(1, 5); - - // Cleanup - await deleteDevice({ id: testDevice.id }); - }); - - it("should not update location of measurements for retroactive measurements", async () => { - // Create a fresh device for this test - const testDevice = await createDevice({ - ...TEST_BOX, - name: "Retroactive Measurements Test Box" - }, userId); - - const testDeviceData = await getDevice({ id: testDevice.id }); - const testSensorId = testDeviceData?.sensors?.[0]?.id; - - // Post three measurements out of order - const now = new Date(); - - // First post: measurement3 at T with location [6,6,6] - const measurement3 = { - value: 6, - location: [6, 6, 6], - createdAt: now.toISOString() - }; - - let request = new Request( - `${BASE_URL}/api/boxes/${testDevice.id}/${testSensorId}`, - { - method: "POST", - headers: { - "Content-Type": "application/json", - Authorization: mockAccessToken, - }, - body: JSON.stringify(measurement3), - } - ); - - await postSingleMeasurementAction({ - request, - params: { deviceId: testDevice.id, sensorId: testSensorId }, - context: {} as AppLoadContext - } satisfies ActionFunctionArgs); - - // Second post: measurement2 at T-2ms without location - const time2 = new Date(now.getTime() - 2); - const measurement2 = { - value: 4.5, - createdAt: time2.toISOString() - }; - - request = new Request( - `${BASE_URL}/api/boxes/${testDevice.id}/${testSensorId}`, - { - method: "POST", - headers: { - "Content-Type": "application/json", - Authorization: mockAccessToken, - }, - body: JSON.stringify(measurement2), - } - ); - - await postSingleMeasurementAction({ - request, - params: { deviceId: testDevice.id, sensorId: testSensorId }, - context: {} as AppLoadContext - } satisfies ActionFunctionArgs); - - // Third post: measurement1 at T-4ms with location [5,5,5] - const time1 = new Date(now.getTime() - 4); - const measurement1 = { - value: 5, - location: [5, 5, 5], - createdAt: time1.toISOString() - }; - - request = new Request( - `${BASE_URL}/api/boxes/${testDevice.id}/${testSensorId}`, - { - method: "POST", - headers: { - "Content-Type": "application/json", - Authorization: mockAccessToken, - }, - body: JSON.stringify(measurement1), - } - ); - - await postSingleMeasurementAction({ - request, - params: { deviceId: testDevice.id, sensorId: testSensorId }, - context: {} as AppLoadContext - } satisfies ActionFunctionArgs); - - // Get all measurements and verify their locations - const measurements = await getSensorMeasurements(testSensorId!); - - // measurement2 (value 4.5) at T-2ms should have no location - // because at the time it was posted, there was no location before T-2ms - const m2 = measurements.find(m => m.value === '4.5'); - expect(m2).toBeDefined(); - expect(m2!.location).toBeNull(); - - // measurement1 should have its explicit location - const m1 = measurements.find(m => m.value === '5'); - expect(m1).toBeDefined(); - expect(m1!.location).not.toBeNull(); - expect(m1!.location![0]).toBeCloseTo(5, 5); - expect(m1!.location![1]).toBeCloseTo(5, 5); - - // measurement3 should have its explicit location - const m3 = measurements.find(m => m.value === '6'); - expect(m3).toBeDefined(); - expect(m3!.location).not.toBeNull(); - expect(m3!.location![0]).toBeCloseTo(6, 5); - expect(m3!.location![1]).toBeCloseTo(6, 5); - - // Cleanup - await deleteDevice({ id: testDevice.id }); - }); - - it("should reject invalid location coordinates (longitude out of range)", async () => { - const measurement = { - value: 100, - location: [200, 50, 0] - }; - - const request = new Request( - `${BASE_URL}/api/boxes/${deviceId}/${sensorIds[0]}`, - { - method: "POST", - headers: { - "Content-Type": "application/json", - Authorization: mockAccessToken, - }, - body: JSON.stringify(measurement), - } - ); - - const response: any = await postSingleMeasurementAction({ - request, - params: { deviceId: deviceId, sensorId: sensorIds[0] }, - context: {} as AppLoadContext - } satisfies ActionFunctionArgs); - - expect(response.status).toBe(422); - const errorData = await response.json(); - expect(errorData.code).toBe("Unprocessable Content"); - expect(errorData.message).toBe("Invalid location coordinates"); - }); - - it("should reject invalid location coordinates (latitude out of range)", async () => { - const measurement = { - value: 101, - location: [50, 100, 0] - }; - - const request = new Request( - `${BASE_URL}/api/boxes/${deviceId}/${sensorIds[0]}`, - { - method: "POST", - headers: { - "Content-Type": "application/json", - Authorization: mockAccessToken, - }, - body: JSON.stringify(measurement), - } - ); - - const response: any = await postSingleMeasurementAction({ - request, - params: { deviceId: deviceId, sensorId: sensorIds[0] }, - context: {} as AppLoadContext - } satisfies ActionFunctionArgs); - - expect(response.status).toBe(422); - const errorData = await response.json(); - expect(errorData.code).toBe("Unprocessable Content"); - expect(errorData.message).toBe("Invalid location coordinates"); - }); - }); - - describe("openSenseMap API Routes: POST /boxes/:deviceId/data (application/json)", () => { - - it("should accept location in measurement object with [value, time, loc]", async () => { - const now = new Date(); - const body = { - [sensorIds[0]]: [7, new Date(now.getTime() - 2).toISOString(), [7, 7, 7]], - [sensorIds[1]]: [8, now.toISOString(), { lat: 8, lng: 8, height: 8 }], - }; - const request = new Request(`${BASE_URL}/api/boxes/${deviceId}/data`, { - method: "POST", - headers: { - "Content-Type": "application/json", - Authorization: mockAccessToken, - }, - body: JSON.stringify(body), - }); - - const response: any = await postMeasurementsAction({ - request, - params: { deviceId }, - context: {} as AppLoadContext, - } satisfies ActionFunctionArgs); - - expect(response).toBeInstanceOf(Response); - expect(response.status).toBe(201); - - const currentLocation = await getDeviceCurrentLocation(deviceId); - expect(currentLocation).not.toBeNull(); - expect(currentLocation!.coordinates).toEqual([8, 8, 0]); - }); - - it("should accept location in measurement array", async () => { - const sensor = sensorIds[2]; - const measurements = [ - { sensor: sensor, value: 9.6 }, - { sensor: sensor, value: 10, location: { lat: 10, lng: 10, height: 10 } }, - { sensor: sensor, value: 9.5, createdAt: new Date().toISOString() }, - { - sensor: sensor, - value: 9, - createdAt: new Date(Date.now() - 2).toISOString(), - location: [9, 9, 9], - }, - { sensor: sensor, value: 10.5 }, - ]; - - const request = new Request(`${BASE_URL}/api/boxes/${deviceId}/data`, { - method: "POST", - headers: { - "Content-Type": "application/json", - Authorization: mockAccessToken, - }, - body: JSON.stringify(measurements), - }); - - const response: any = await postMeasurementsAction({ - request, - params: { deviceId }, - context: {} as AppLoadContext, - } satisfies ActionFunctionArgs); - - expect(response.status).toBe(201); - - const currentLocation = await getDeviceCurrentLocation(deviceId); - expect(currentLocation).not.toBeNull(); - expect(currentLocation!.coordinates).toEqual([10, 10, 0]); - }); - - // it("should set & infer locations correctly for measurements", async () => { - // const sensor = sensorIds[2]; - // const measurements = await getSensorMeasurements(sensor); - - // expect(measurements.length).toBeGreaterThanOrEqual(5); - - // for (const m of measurements) { - // // For this dataset, value should roghly match coordinate - // const v = parseInt(m.value, 10); - // if (m.location) { - // expect(m.location).toEqual([v, v, 0]); - // } - // } - // }); - }); -}); \ No newline at end of file + name: `'${TEST_USER.name}'s Box`, + exposure: 'outdoor', + expiresAt: null, + tags: [], + latitude: 0, + longitude: 0, + model: 'luftdaten.info', + mqttEnabled: false, + ttnEnabled: false, + sensors: [ + { title: 'Temperature', unit: '°C', sensorType: 'temperature' }, + { title: 'Humidity', unit: '%', sensorType: 'humidity' }, + { title: 'Pressure', unit: 'hPa', sensorType: 'pressure' }, + ], +} + +describe('openSenseMap API Routes: Location Measurements', () => { + let userId: string = '' + let deviceId: string = '' + let sensorIds: string[] = [] + let sensors: any[] = [] + + // Helper function to get device's current location + async function getDeviceCurrentLocation(deviceId: string) { + const deviceWithLocations = await drizzleClient.query.device.findFirst({ + where: (device, { eq }) => eq(device.id, deviceId), + with: { + locations: { + orderBy: (deviceToLocation, { desc }) => [ + desc(deviceToLocation.time), + ], + limit: 1, + with: { + geometry: { + columns: {}, + extras: { + x: sql`ST_X(${location.location})`.as('x'), + y: sql`ST_Y(${location.location})`.as('y'), + }, + }, + }, + }, + }, + }) + + if (deviceWithLocations?.locations?.[0]?.geometry) { + const geo = deviceWithLocations.locations[0].geometry + return { + coordinates: [geo.x, geo.y, 0], + time: deviceWithLocations.locations[0].time, + } + } + return null + } + + // Helper to get all device locations + async function getDeviceLocations(deviceId: string) { + const result = await drizzleClient + .select({ + timestamp: deviceToLocation.time, + x: sql`ST_X(${location.location})`, + y: sql`ST_Y(${location.location})`, + }) + .from(deviceToLocation) + .innerJoin(location, eq(deviceToLocation.locationId, location.id)) + .where(eq(deviceToLocation.deviceId, deviceId)) + .orderBy(deviceToLocation.time) + + return result.map((r) => ({ + timestamp: r.timestamp, + coordinates: [r.x, r.y, 0], + })) + } + + // Helper to get measurements for a sensor + async function getSensorMeasurements(sensorId: string) { + const results = await drizzleClient + .select({ + value: measurement.value, + time: measurement.time, + locationId: measurement.locationId, + x: sql`ST_X(${location.location})`, + y: sql`ST_Y(${location.location})`, + }) + .from(measurement) + .leftJoin(location, eq(measurement.locationId, location.id)) + .where(eq(measurement.sensorId, sensorId)) + .orderBy(measurement.time) + + return results.map((r) => ({ + value: String(r.value), + time: r.time, + location: r.x && r.y ? [r.x, r.y, 0] : null, + })) + } + + beforeAll(async () => { + const user = await registerUser( + TEST_USER.name, + TEST_USER.email, + TEST_USER.password, + 'en_US', + ) + userId = (user as User).id + const device = await createDevice(TEST_BOX, userId) + deviceId = device.id + + const deviceWithSensors = await getDevice({ id: deviceId }) + sensorIds = + deviceWithSensors?.sensors?.map((sensor: any) => sensor.id) || [] + sensors = deviceWithSensors?.sensors?.map((sensor: any) => sensor) || [] + }) + + afterAll(async () => { + await deleteUserByEmail(TEST_USER.email) + await deleteDevice({ id: deviceId }) + }) + + describe('POST /boxes/:deviceId/:sensorId with locations', () => { + it("should allow updating a box's location via new measurement (array)", async () => { + const measurement = { + value: 3, + location: [3, 3, 3], + } + + const request = new Request( + `${BASE_URL}/api/boxes/${deviceId}/${sensorIds[0]}`, + { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Authorization: mockAccessToken, + }, + body: JSON.stringify(measurement), + }, + ) + + const response: any = await postSingleMeasurementAction({ + request, + params: { deviceId: deviceId, sensorId: sensorIds[0] }, + context: {} as AppLoadContext, + } satisfies ActionFunctionArgs) + + expect(response).toBeInstanceOf(Response) + expect(response.status).toBe(201) + expect(await response.text()).toBe('Measurement saved in box') + + const currentLocation = await getDeviceCurrentLocation(deviceId) + expect(currentLocation).not.toBeNull() + expect(currentLocation!.coordinates[0]).toBeCloseTo(3, 5) + expect(currentLocation!.coordinates[1]).toBeCloseTo(3, 5) + }) + + it("should allow updating a box's location via new measurement (latLng)", async () => { + const measurement = { + value: 4, + location: { lat: 4, lng: 4, height: 4 }, + } + + const request = new Request( + `${BASE_URL}/api/boxes/${deviceId}/${sensorIds[0]}`, + { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Authorization: mockAccessToken, + }, + body: JSON.stringify(measurement), + }, + ) + + const response: any = await postSingleMeasurementAction({ + request, + params: { deviceId: deviceId, sensorId: sensorIds[0] }, + context: {} as AppLoadContext, + } satisfies ActionFunctionArgs) + + expect(response).toBeInstanceOf(Response) + expect(response.status).toBe(201) + + const currentLocation = await getDeviceCurrentLocation(deviceId) + expect(currentLocation).not.toBeNull() + expect(currentLocation!.coordinates[0]).toBeCloseTo(4, 5) + expect(currentLocation!.coordinates[1]).toBeCloseTo(4, 5) + }) + + it('should not update box.currentLocation for an earlier timestamp', async () => { + // First, post a measurement with current time and location [4, 4] + const currentMeasurement = { + value: 4.1, + location: [4, 4, 0], + } + + let request = new Request( + `${BASE_URL}/api/boxes/${deviceId}/${sensorIds[0]}`, + { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Authorization: mockAccessToken, + }, + body: JSON.stringify(currentMeasurement), + }, + ) + + await postSingleMeasurementAction({ + request, + params: { deviceId: deviceId, sensorId: sensorIds[0] }, + context: {} as AppLoadContext, + } satisfies ActionFunctionArgs) + + // Get current location after first post + const locationAfterCurrent = await getDeviceCurrentLocation(deviceId) + expect(locationAfterCurrent!.coordinates[0]).toBeCloseTo(4, 5) + expect(locationAfterCurrent!.coordinates[1]).toBeCloseTo(4, 5) + + // Now post a measurement with an earlier timestamp + const pastTime = new Date(Date.now() - 60000) // 1 minute ago + const pastMeasurement = { + value: -1, + location: [-1, -1, -1], + createdAt: pastTime.toISOString(), + } + + request = new Request( + `${BASE_URL}/api/boxes/${deviceId}/${sensorIds[0]}`, + { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Authorization: mockAccessToken, + }, + body: JSON.stringify(pastMeasurement), + }, + ) + + const response: any = await postSingleMeasurementAction({ + request, + params: { deviceId: deviceId, sensorId: sensorIds[0] }, + context: {} as AppLoadContext, + } satisfies ActionFunctionArgs) + + expect(response.status).toBe(201) + + // Verify location was NOT updated (should still be [4, 4]) + const locationAfterPast = await getDeviceCurrentLocation(deviceId) + expect(locationAfterPast!.coordinates[0]).toBeCloseTo(4, 5) + expect(locationAfterPast!.coordinates[1]).toBeCloseTo(4, 5) + }) + + it('should predate first location for measurement with timestamp and no location', async () => { + // Create a fresh device for this test to avoid interference + const testDevice = await createDevice( + { + ...TEST_BOX, + name: 'Location Predate Test Box', + }, + userId, + ) + + const testDeviceData = await getDevice({ id: testDevice.id }) + const testSensorId = testDeviceData?.sensors?.[0]?.id + + const createdAt = new Date(Date.now() - 600000) // 10 minutes ago + const measurement = { + value: -1, + createdAt: createdAt.toISOString(), + } + + const request = new Request( + `${BASE_URL}/api/boxes/${testDevice.id}/${testSensorId}`, + { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Authorization: mockAccessToken, + }, + body: JSON.stringify(measurement), + }, + ) + + const response: any = await postSingleMeasurementAction({ + request, + params: { deviceId: testDevice.id, sensorId: testSensorId }, + context: {} as AppLoadContext, + } satisfies ActionFunctionArgs) + + expect(response.status).toBe(201) + + // Get device locations - should be empty since no location was provided + const locations = await getDeviceLocations(testDevice.id) + expect(locations).toHaveLength(0) + + // Cleanup + await deleteDevice({ id: testDevice.id }) + }) + + it('should infer measurement.location for measurements without location', async () => { + // Create a fresh device for this test + const testDevice = await createDevice( + { + ...TEST_BOX, + name: 'Location Inference Test Box', + }, + userId, + ) + + const testDeviceData = await getDevice({ id: testDevice.id }) + const testSensorId = testDeviceData?.sensors?.[0]?.id + + // First, set a location at time T-2 minutes + const time1 = new Date(Date.now() - 120000) + const measurement1 = { + value: -1, + location: [-1, -1, -1], + createdAt: time1.toISOString(), + } + + let request = new Request( + `${BASE_URL}/api/boxes/${testDevice.id}/${testSensorId}`, + { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Authorization: mockAccessToken, + }, + body: JSON.stringify(measurement1), + }, + ) + + await postSingleMeasurementAction({ + request, + params: { deviceId: testDevice.id, sensorId: testSensorId }, + context: {} as AppLoadContext, + } satisfies ActionFunctionArgs) + + // Second, set a different location at time T (now) + const time2 = new Date() + const measurement2 = { + value: 1, + location: [1, 1, 1], + createdAt: time2.toISOString(), + } + + request = new Request( + `${BASE_URL}/api/boxes/${testDevice.id}/${testSensorId}`, + { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Authorization: mockAccessToken, + }, + body: JSON.stringify(measurement2), + }, + ) + + await postSingleMeasurementAction({ + request, + params: { deviceId: testDevice.id, sensorId: testSensorId }, + context: {} as AppLoadContext, + } satisfies ActionFunctionArgs) + + // Now post a measurement without location at T-1 minute (between the two locations) + const time3 = new Date(Date.now() - 60000) + const measurement3 = { + value: -0.5, + createdAt: time3.toISOString(), + } + + request = new Request( + `${BASE_URL}/api/boxes/${testDevice.id}/${testSensorId}`, + { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Authorization: mockAccessToken, + }, + body: JSON.stringify(measurement3), + }, + ) + + await postSingleMeasurementAction({ + request, + params: { deviceId: testDevice.id, sensorId: testSensorId }, + context: {} as AppLoadContext, + } satisfies ActionFunctionArgs) + + // Get all measurements and check their inferred locations + const measurements = await getSensorMeasurements(testSensorId!) + + const m1 = measurements.find((m) => m.value === '-0.5') + expect(m1).toBeDefined() + expect(m1!.location).not.toBeNull() + expect(m1!.location![0]).toBeCloseTo(-1, 5) // Should have location from T-2 + expect(m1!.location![1]).toBeCloseTo(-1, 5) + + const m2 = measurements.find((m) => m.value === '1') + expect(m2).toBeDefined() + expect(m2!.location).not.toBeNull() + expect(m2!.location![0]).toBeCloseTo(1, 5) + expect(m2!.location![1]).toBeCloseTo(1, 5) + + // Cleanup + await deleteDevice({ id: testDevice.id }) + }) + + it('should not update location of measurements for retroactive measurements', async () => { + // Create a fresh device for this test + const testDevice = await createDevice( + { + ...TEST_BOX, + name: 'Retroactive Measurements Test Box', + }, + userId, + ) + + const testDeviceData = await getDevice({ id: testDevice.id }) + const testSensorId = testDeviceData?.sensors?.[0]?.id + + // Post three measurements out of order + const now = new Date() + + // First post: measurement3 at T with location [6,6,6] + const measurement3 = { + value: 6, + location: [6, 6, 6], + createdAt: now.toISOString(), + } + + let request = new Request( + `${BASE_URL}/api/boxes/${testDevice.id}/${testSensorId}`, + { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Authorization: mockAccessToken, + }, + body: JSON.stringify(measurement3), + }, + ) + + await postSingleMeasurementAction({ + request, + params: { deviceId: testDevice.id, sensorId: testSensorId }, + context: {} as AppLoadContext, + } satisfies ActionFunctionArgs) + + // Second post: measurement2 at T-2ms without location + const time2 = new Date(now.getTime() - 2) + const measurement2 = { + value: 4.5, + createdAt: time2.toISOString(), + } + + request = new Request( + `${BASE_URL}/api/boxes/${testDevice.id}/${testSensorId}`, + { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Authorization: mockAccessToken, + }, + body: JSON.stringify(measurement2), + }, + ) + + await postSingleMeasurementAction({ + request, + params: { deviceId: testDevice.id, sensorId: testSensorId }, + context: {} as AppLoadContext, + } satisfies ActionFunctionArgs) + + // Third post: measurement1 at T-4ms with location [5,5,5] + const time1 = new Date(now.getTime() - 4) + const measurement1 = { + value: 5, + location: [5, 5, 5], + createdAt: time1.toISOString(), + } + + request = new Request( + `${BASE_URL}/api/boxes/${testDevice.id}/${testSensorId}`, + { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Authorization: mockAccessToken, + }, + body: JSON.stringify(measurement1), + }, + ) + + await postSingleMeasurementAction({ + request, + params: { deviceId: testDevice.id, sensorId: testSensorId }, + context: {} as AppLoadContext, + } satisfies ActionFunctionArgs) + + // Get all measurements and verify their locations + const measurements = await getSensorMeasurements(testSensorId!) + + // measurement2 (value 4.5) at T-2ms should have no location + // because at the time it was posted, there was no location before T-2ms + const m2 = measurements.find((m) => m.value === '4.5') + expect(m2).toBeDefined() + expect(m2!.location).toBeNull() + + // measurement1 should have its explicit location + const m1 = measurements.find((m) => m.value === '5') + expect(m1).toBeDefined() + expect(m1!.location).not.toBeNull() + expect(m1!.location![0]).toBeCloseTo(5, 5) + expect(m1!.location![1]).toBeCloseTo(5, 5) + + // measurement3 should have its explicit location + const m3 = measurements.find((m) => m.value === '6') + expect(m3).toBeDefined() + expect(m3!.location).not.toBeNull() + expect(m3!.location![0]).toBeCloseTo(6, 5) + expect(m3!.location![1]).toBeCloseTo(6, 5) + + // Cleanup + await deleteDevice({ id: testDevice.id }) + }) + + it('should reject invalid location coordinates (longitude out of range)', async () => { + const measurement = { + value: 100, + location: [200, 50, 0], + } + + const request = new Request( + `${BASE_URL}/api/boxes/${deviceId}/${sensorIds[0]}`, + { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Authorization: mockAccessToken, + }, + body: JSON.stringify(measurement), + }, + ) + + const response: any = await postSingleMeasurementAction({ + request, + params: { deviceId: deviceId, sensorId: sensorIds[0] }, + context: {} as AppLoadContext, + } satisfies ActionFunctionArgs) + + expect(response.status).toBe(422) + const errorData = await response.json() + expect(errorData.code).toBe('Unprocessable Content') + expect(errorData.message).toBe('Invalid location coordinates') + }) + + it('should reject invalid location coordinates (latitude out of range)', async () => { + const measurement = { + value: 101, + location: [50, 100, 0], + } + + const request = new Request( + `${BASE_URL}/api/boxes/${deviceId}/${sensorIds[0]}`, + { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Authorization: mockAccessToken, + }, + body: JSON.stringify(measurement), + }, + ) + + const response: any = await postSingleMeasurementAction({ + request, + params: { deviceId: deviceId, sensorId: sensorIds[0] }, + context: {} as AppLoadContext, + } satisfies ActionFunctionArgs) + + expect(response.status).toBe(422) + const errorData = await response.json() + expect(errorData.code).toBe('Unprocessable Content') + expect(errorData.message).toBe('Invalid location coordinates') + }) + }) + + describe('openSenseMap API Routes: POST /boxes/:deviceId/data (application/json)', () => { + it('should accept location in measurement object with [value, time, loc]', async () => { + const now = new Date() + const body = { + [sensorIds[0]]: [ + 7, + new Date(now.getTime() - 2).toISOString(), + [7, 7, 7], + ], + [sensorIds[1]]: [8, now.toISOString(), { lat: 8, lng: 8, height: 8 }], + } + const request = new Request(`${BASE_URL}/api/boxes/${deviceId}/data`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Authorization: mockAccessToken, + }, + body: JSON.stringify(body), + }) + + const response: any = await postMeasurementsAction({ + request, + params: { deviceId }, + context: {} as AppLoadContext, + } satisfies ActionFunctionArgs) + + expect(response).toBeInstanceOf(Response) + expect(response.status).toBe(201) + + const currentLocation = await getDeviceCurrentLocation(deviceId) + expect(currentLocation).not.toBeNull() + expect(currentLocation!.coordinates).toEqual([8, 8, 0]) + }) + + it('should accept location in measurement array', async () => { + const sensor = sensorIds[2] + const measurements = [ + { sensor: sensor, value: 9.6 }, + { + sensor: sensor, + value: 10, + location: { lat: 10, lng: 10, height: 10 }, + }, + { sensor: sensor, value: 9.5, createdAt: new Date().toISOString() }, + { + sensor: sensor, + value: 9, + createdAt: new Date(Date.now() - 2).toISOString(), + location: [9, 9, 9], + }, + { sensor: sensor, value: 10.5 }, + ] + + const request = new Request(`${BASE_URL}/api/boxes/${deviceId}/data`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Authorization: mockAccessToken, + }, + body: JSON.stringify(measurements), + }) + + const response: any = await postMeasurementsAction({ + request, + params: { deviceId }, + context: {} as AppLoadContext, + } satisfies ActionFunctionArgs) + + expect(response.status).toBe(201) + + const currentLocation = await getDeviceCurrentLocation(deviceId) + expect(currentLocation).not.toBeNull() + expect(currentLocation!.coordinates).toEqual([10, 10, 0]) + }) + + // it("should set & infer locations correctly for measurements", async () => { + // const sensor = sensorIds[2]; + // const measurements = await getSensorMeasurements(sensor); + + // expect(measurements.length).toBeGreaterThanOrEqual(5); + + // for (const m of measurements) { + // // For this dataset, value should roghly match coordinate + // const v = parseInt(m.value, 10); + // if (m.location) { + // expect(m.location).toEqual([v, v, 0]); + // } + // } + // }); + }) +}) diff --git a/tests/routes/api.measurements.spec.ts b/tests/routes/api.measurements.spec.ts index 22add2f2..51a7af64 100644 --- a/tests/routes/api.measurements.spec.ts +++ b/tests/routes/api.measurements.spec.ts @@ -1,561 +1,532 @@ -import { type AppLoadContext, type ActionFunctionArgs } from "react-router"; -import { csvExampleData, jsonSubmitData, byteSubmitData } from "tests/data"; -import { describe, it, expect, beforeAll, afterAll } from "vitest"; -import { BASE_URL } from "vitest.setup"; -import { drizzleClient } from "~/db.server"; -import { registerUser } from "~/lib/user-service.server"; -import { createDevice, deleteDevice, getDevice } from "~/models/device.server"; -import { deleteUserByEmail } from "~/models/user.server"; -import { action as postSingleMeasurementAction } from "~/routes/api.boxes.$deviceId.$sensorId"; -import { action as postMeasurementsAction } from "~/routes/api.boxes.$deviceId.data"; -import { accessToken, type User } from "~/schema"; - -const mockAccessToken = "valid-access-token"; - -const TEST_USER = { - name: "testing measurement submits", - email: "test@measurementsubmits.me", - password: "some secure password", -}; +import { type AppLoadContext, type ActionFunctionArgs } from 'react-router' +import { csvExampleData, jsonSubmitData, byteSubmitData } from 'tests/data' +import { generateTestUserCredentials } from 'tests/data/generate_test_user' +import { describe, it, expect, beforeAll, afterAll } from 'vitest' +import { BASE_URL } from 'vitest.setup' +import { drizzleClient } from '~/db.server' +import { registerUser } from '~/lib/user-service.server' +import { createDevice, deleteDevice, getDevice } from '~/models/device.server' +import { deleteUserByEmail } from '~/models/user.server' +import { action as postSingleMeasurementAction } from '~/routes/api.boxes.$deviceId.$sensorId' +import { action as postMeasurementsAction } from '~/routes/api.boxes.$deviceId.data' +import { accessToken, type User } from '~/schema' + +const mockAccessToken = 'valid-access-token' + +const TEST_USER = generateTestUserCredentials() const TEST_BOX = { - name: `'${TEST_USER.name}'s Box`, - exposure: "outdoor", - expiresAt: null, - tags: [], - latitude: 0, - longitude: 0, - model: "luftdaten.info", - mqttEnabled: false, - ttnEnabled: false, - sensors: [ - { title: "Temperature", unit: "°C", sensorType: "temperature" }, - { title: "Humidity", unit: "%", sensorType: "humidity" }, - ], -}; - -describe("openSenseMap API Routes: /boxes", () => { - let userId: string = ""; - let deviceId: string = ""; - let sensorIds: string[] = [] - let sensors: any[] = [] - - beforeAll(async () => { - - const user = await registerUser( - TEST_USER.name, - TEST_USER.email, - TEST_USER.password, - "en_US", - ); - userId = (user as User).id; - const device = await createDevice(TEST_BOX, userId); - deviceId = device.id - - const deviceWithSensors = await getDevice({ id: deviceId }); - sensorIds = deviceWithSensors?.sensors?.map((sensor: any) => sensor.id) || []; - sensors = deviceWithSensors?.sensors?.map((sensor: any) => sensor) || [] - - await drizzleClient.insert(accessToken).values({ - deviceId: deviceId, - token: "valid-access-token", - }) - - }); - - - - // --------------------------------------------------- - // Single measurement POST /boxes/:boxId/:sensorId - // --------------------------------------------------- - describe("single measurement POST", () => { - it("should accept a single measurement via POST", async () => { - const request = new Request( - `${BASE_URL}/api/boxes/${deviceId}/${sensorIds[0]}`, - { - method: "POST", - headers: { - "Content-Type": "application/json", - Authorization: mockAccessToken, - }, - body: JSON.stringify({ value: 312.1 }), - } - ); - - const response: any = await postSingleMeasurementAction({ - request, - params: { deviceId: deviceId, sensorId: sensorIds[0] }, - context: {} as AppLoadContext - } satisfies ActionFunctionArgs); - - expect(response).toBeInstanceOf(Response); - expect(response.status).toBe(201); - expect(await response.text()).toBe("Measurement saved in box"); - }); - - it("should reject with wrong access token", async () => { - const request = new Request( - `${BASE_URL}/api/boxes/${deviceId}/${sensorIds[0]}`, - { - method: "POST", - headers: { - "Content-Type": "application/json", - Authorization: "wrongAccessToken", - }, - body: JSON.stringify({ value: 312.1 }), - } - ); - - const response: any = await postSingleMeasurementAction({ - request, - params: { deviceId: deviceId, sensorId: sensorIds[0] }, - context: {} as AppLoadContext, - } satisfies ActionFunctionArgs); - - expect(response.status).toBe(401); - const body = await response.json(); - expect(body.message).toBe("Device access token not valid!"); - }); - - it("should accept a single measurement with timestamp", async () => { - const timestamp = new Date().toISOString(); - - const request = new Request( - `${BASE_URL}/api/boxes/${deviceId}/${sensorIds[1]}`, - { - method: "POST", - headers: { - "Content-Type": "application/json", - Authorization: mockAccessToken, - }, - body: JSON.stringify({ value: 123.4, createdAt: timestamp }), - } - ); - - const response: any = await postSingleMeasurementAction({ - request, - params: { deviceId: deviceId, sensorId: sensorIds[1] }, - context: {} as AppLoadContext - } satisfies ActionFunctionArgs); - - expect(response.status).toBe(201); - expect(await response.text()).toBe("Measurement saved in box"); - }); - - it("should reject measurement with timestamp too far into the future", async () => { - const future = new Date(Date.now() + 90_000).toISOString(); // 1.5 min future - - const request = new Request( - `${BASE_URL}/api/boxes/${deviceId}/${sensorIds[1]}`, - { - method: "POST", - headers: { - "Content-Type": "application/json", - Authorization: mockAccessToken, - }, - body: JSON.stringify({ value: 123.4, createdAt: future }), - } - ); - - const response: any = await postSingleMeasurementAction({ - request, - params: { deviceId: deviceId, sensorId: sensorIds[1] }, - context: {} as AppLoadContext - } satisfies ActionFunctionArgs); - - expect(response.status).toBe(422); - }); - }); - - // --------------------------------------------------- -// Multiple CSV POST -// --------------------------------------------------- -describe("multiple CSV POST /boxes/:id/data", () => { - it("should accept multiple measurements as CSV via POST (no timestamps)", async () => { - const csvPayload = csvExampleData.noTimestamps(sensors); - - const request = new Request( - `${BASE_URL}/api/boxes/${deviceId}/data`, - { - method: "POST", - headers: { - "Content-Type": "text/csv", - Authorization: mockAccessToken, - }, - body: csvPayload, - } - ); - - const response: any = await postMeasurementsAction({ - request, - params: { deviceId: deviceId }, - context: {} as AppLoadContext, - } satisfies ActionFunctionArgs); - - expect(response.status).toBe(201); - expect(await response.text()).toContain("Measurements saved in box"); - }); - - it("should accept multiple measurements as CSV via POST (with timestamps)", async () => { - const csvPayload = csvExampleData.withTimestamps(sensors); - - const request = new Request( - `${BASE_URL}/api/boxes/${deviceId}/data`, - { - method: "POST", - headers: { - "Content-Type": "text/csv", - Authorization: mockAccessToken, - }, - body: csvPayload, - } - ); - - const response: any = await postMeasurementsAction({ - request, - params: { deviceId: deviceId }, - context: {} as AppLoadContext, - } satisfies ActionFunctionArgs); - - expect(response.status).toBe(201); - }); - - it("should reject CSV with future timestamps", async () => { - const csvPayload = csvExampleData.withTimestampsFuture(sensors); - - const request = new Request( - `${BASE_URL}/api/boxes/${deviceId}/data`, - { - method: "POST", - headers: { - "Content-Type": "text/csv", - Authorization: mockAccessToken, - }, - body: csvPayload, - } - ); - - const response: any = await postMeasurementsAction({ - request, - params: { deviceId: deviceId }, - context: {} as AppLoadContext, - } satisfies ActionFunctionArgs); - - expect(response.status).toBe(422); - }); - }); - - - // --------------------------------------------------- - // Multiple bytes POST - // --------------------------------------------------- - describe("multiple bytes POST /boxes/:id/data", () => { - - it("should accept multiple measurements as bytes via POST", async () => { - - const submitTime = new Date(); - const request = new Request( - `${BASE_URL}/api/boxes/${deviceId}/data`, - { - method: "POST", - headers: { - "Content-Type": "application/sbx-bytes", - Authorization: mockAccessToken, - }, - body: byteSubmitData(sensors) as unknown as BodyInit, - } - ); - - const response: any = await postMeasurementsAction({ - request, - params: { deviceId: deviceId }, - context: {} as AppLoadContext - } as ActionFunctionArgs); - - expect(response.status).toBe(201); - expect(await response.text()).toContain("Measurements saved in box"); - - const updatedDevice = await getDevice({ id: deviceId }); - - expect(updatedDevice?.sensors).toBeDefined(); - updatedDevice?.sensors?.forEach((sensor: any) => { - expect(sensor.lastMeasurement).toBeDefined(); - expect(sensor.lastMeasurement).not.toBeNull(); - - // Verify the measurement timestamp is recent - if (sensor.lastMeasurement?.createdAt) { - const createdAt = new Date(sensor.lastMeasurement.createdAt); - const diffMinutes = Math.abs(submitTime.getTime() - createdAt.getTime()) / (1000 * 60); - expect(diffMinutes).toBeLessThan(4); - } - }); - }); - - it("should accept multiple measurements as bytes with timestamps", async () => { - const submitTime = new Date(); - - const request = new Request( - `${BASE_URL}/api/boxes/${deviceId}/data`, - { - method: "POST", - headers: { - "Content-Type": "application/sbx-bytes-ts", - Authorization: mockAccessToken, - }, - body: byteSubmitData(sensors, true) as unknown as BodyInit, - } - ); - - const response: any = await postMeasurementsAction({ - request, - params: { deviceId: deviceId }, - context: {} as AppLoadContext - } as ActionFunctionArgs); - - expect(response.status).toBe(201); - expect(await response.text()).toBe("Measurements saved in box"); - - const updatedDevice = await getDevice({ id: deviceId }); - - expect(updatedDevice?.sensors).toBeDefined(); - expect(updatedDevice?.sensors?.length).toBeGreaterThan(0); - - updatedDevice?.sensors?.forEach((sensor: any) => { - expect(sensor.lastMeasurement).toBeDefined(); - expect(sensor.lastMeasurement).not.toBeNull(); - - expect(sensor.lastMeasurement.createdAt).toBeDefined(); - - // Verify the timestamp is within 5 minutes of submission - const createdAt = new Date(sensor.lastMeasurement.createdAt); - const diffMinutes = Math.abs(submitTime.getTime() - createdAt.getTime()) / (1000 * 60); - expect(diffMinutes).toBeLessThan(5); - }); - }); - }); - - it("should reject measurements with invalid sensor IDs", async () => { - // Create byte data with a non-existent sensor ID - const fakeSensorId = "fakeid123456"; - const bytesPerSensor = 16; - const buffer = new ArrayBuffer(bytesPerSensor); - const view = new DataView(buffer); - const bytes = new Uint8Array(buffer); - - function stringToHex(str: string): string { - let hex = ''; - for (let i = 0; i < str.length; i++) { - const charCode = str.charCodeAt(i); - hex += charCode.toString(16).padStart(2, '0'); - } - return hex; - } - - - // Encode fake sensor ID - const fakeIdHex = stringToHex(fakeSensorId).slice(0, 24); - for (let j = 0; j < 12; j++) { - const hexByteStart = j * 2; - const hexByte = fakeIdHex.slice(hexByteStart, hexByteStart + 2); - bytes[j] = parseInt(hexByte, 16) || 0; - } - view.setFloat32(12, 25.5, true); - - const request = new Request( - `${BASE_URL}/api/boxes/${deviceId}/data`, - { - method: "POST", - headers: { - "Content-Type": "application/sbx-bytes", - Authorization: mockAccessToken, - }, - body: bytes, - } - ); - - const response: any = await postMeasurementsAction({ - request, - params: { deviceId: deviceId }, - context: {} as AppLoadContext - } as ActionFunctionArgs); - - console.log("response invalid sensor", response) - - // Should either reject or silently skip invalid sensors - expect(response.status).toBeGreaterThanOrEqual(200); - }); - - it("should handle empty measurements", async () => { - const request = new Request( - `${BASE_URL}/api/boxes/${deviceId}/data`, - { - method: "POST", - headers: { - "Content-Type": "application/sbx-bytes", - Authorization: mockAccessToken, - }, - body: new Uint8Array(0), - } - ); - - const response: any = await postMeasurementsAction({ - request, - params: { deviceId: deviceId }, - context: {} as AppLoadContext - } as ActionFunctionArgs); - - expect(response.status).toBe(422); // Unprocessable Entity - }); - - // --------------------------------------------------- - // MQTT publishing - // --------------------------------------------------- - // describe("MQTT submission", () => { - // it("should accept measurements through mqtt", async () => { - // // NOTE: You’ll need to wire up a real or mock MQTT client. - // // Example: use `mqtt` npm package and connect to a local broker in test env. - // // Here we just stub: - - // const fakePublishMqttMessage = async ( - // topic: string, - // payload: string - // ) => { - // // call your app’s MQTT ingestion handler directly instead of broker - // const request = new Request(`${BASE_URL}/api/mqtt`, { - // method: "POST", - // headers: { "Content-Type": "application/json" }, - // body: payload, - // }); - // return postMeasurementsAction({ - // request, - // params: { deviceId: deviceId }, - // context: {} as AppLoadContext - - // } as ActionFunctionArgs); - // }; - - // const payload = JSON.stringify(jsonSubmitData.jsonArr(sensors)); - // const mqttResponse: any = await fakePublishMqttMessage("mytopic", payload); - - // expect(mqttResponse.status).toBe(201); - // }); - // }); - -describe("multiple JSON POST /boxes/:id/data", () => { - it("should accept multiple measurements with timestamps as JSON object via POST (content-type: json)", async () => { - const submitData = jsonSubmitData.jsonObj(sensors); - - const request = new Request( - `${BASE_URL}/api/boxes/${deviceId}/data`, - { - method: "POST", - headers: { - "Content-Type": "application/json", - Authorization: mockAccessToken, - }, - body: JSON.stringify(submitData), - } - ); - - const before = new Date(); - - const response: any = await postMeasurementsAction({ - request, - params: { deviceId }, - context: {} as AppLoadContext, - } satisfies ActionFunctionArgs); - - const after = new Date(); - - expect(response.status).toBe(201); - expect(await response.text()).toContain("Measurements saved in box"); - - // Verify sensors got updated - const updatedDevice = await getDevice({ id: deviceId }); - for (const sensor of updatedDevice?.sensors || []) { - expect(sensor.lastMeasurement).toBeTruthy(); - expect(new Date((sensor.lastMeasurement as any).createdAt).getTime()) - .toBeGreaterThanOrEqual(before.getTime() - 1000); - expect(new Date((sensor.lastMeasurement as any).createdAt).getTime()) - .toBeLessThanOrEqual(after.getTime() + 1000 * 60 * 4); // within ~4 min - } - }); - - it("should accept multiple measurements with timestamps as JSON object via POST", async () => { - const submitData = jsonSubmitData.jsonObj(sensors); - - const request = new Request( - `${BASE_URL}/api/boxes/${deviceId}/data`, - { - method: "POST", - headers: { - Authorization: mockAccessToken, - // TODO: remove header here - "Content-Type": "application/json", - }, - body: JSON.stringify(submitData), - } - ); - - const before = new Date(); - const response: any = await postMeasurementsAction({ - request, - params: { deviceId }, - context: {} as AppLoadContext, - } satisfies ActionFunctionArgs); - const after = new Date(); - - expect(response.status).toBe(201); - expect(await response.text()).toContain("Measurements saved in box"); - - const updatedDevice = await getDevice({ id: deviceId }); - for (const sensor of updatedDevice?.sensors || []) { - expect(sensor.lastMeasurement).toBeTruthy(); - const createdAt = new Date((sensor.lastMeasurement as any).createdAt); - expect(createdAt.getTime()).toBeGreaterThanOrEqual(before.getTime() - 1000); - expect(createdAt.getTime()).toBeLessThanOrEqual(after.getTime() + 1000 * 60 * 4); - } - }); - - it("should accept multiple measurements with timestamps as JSON array via POST", async () => { - const submitData = jsonSubmitData.jsonArr(sensors); - - const request = new Request( - `${BASE_URL}/api/boxes/${deviceId}/data`, - { - method: "POST", - headers: { - "Content-Type": "application/json", - Authorization: mockAccessToken, - }, - body: JSON.stringify(submitData), - } - ); - - const before = new Date(); - const response: any = await postMeasurementsAction({ - request, - params: { deviceId }, - context: {} as AppLoadContext, - } satisfies ActionFunctionArgs); - const after = new Date(); - - expect(response.status).toBe(201); - expect(await response.text()).toContain("Measurements saved in box"); - - const updatedDevice = await getDevice({ id: deviceId }); - for (const sensor of updatedDevice?.sensors || []) { - expect(sensor.lastMeasurement).toBeTruthy(); - const createdAt = new Date((sensor.lastMeasurement as any).createdAt); - expect(createdAt.getTime()).toBeGreaterThanOrEqual(before.getTime() - 1000); - expect(createdAt.getTime()).toBeLessThanOrEqual(after.getTime() + 1000 * 60 * 4); - } - }); -}); - - - afterAll(async () => { - await deleteUserByEmail(TEST_USER.email); - await deleteDevice({ id: deviceId }); - }); -}); + name: `'${TEST_USER.name}'s Box`, + exposure: 'outdoor', + expiresAt: null, + tags: [], + latitude: 0, + longitude: 0, + model: 'luftdaten.info', + mqttEnabled: false, + ttnEnabled: false, + sensors: [ + { title: 'Temperature', unit: '°C', sensorType: 'temperature' }, + { title: 'Humidity', unit: '%', sensorType: 'humidity' }, + ], +} + +describe('openSenseMap API Routes: /boxes', () => { + let userId: string = '' + let deviceId: string = '' + let sensorIds: string[] = [] + let sensors: any[] = [] + + beforeAll(async () => { + const user = await registerUser( + TEST_USER.name, + TEST_USER.email, + TEST_USER.password, + 'en_US', + ) + userId = (user as User).id + const device = await createDevice(TEST_BOX, userId) + deviceId = device.id + + const deviceWithSensors = await getDevice({ id: deviceId }) + sensorIds = + deviceWithSensors?.sensors?.map((sensor: any) => sensor.id) || [] + sensors = deviceWithSensors?.sensors?.map((sensor: any) => sensor) || [] + + await drizzleClient.insert(accessToken).values({ + deviceId: deviceId, + token: 'valid-access-token', + }) + }) + + // --------------------------------------------------- + // Single measurement POST /boxes/:boxId/:sensorId + // --------------------------------------------------- + describe('single measurement POST', () => { + it('should accept a single measurement via POST', async () => { + const request = new Request( + `${BASE_URL}/api/boxes/${deviceId}/${sensorIds[0]}`, + { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Authorization: mockAccessToken, + }, + body: JSON.stringify({ value: 312.1 }), + }, + ) + + const response: any = await postSingleMeasurementAction({ + request, + params: { deviceId: deviceId, sensorId: sensorIds[0] }, + context: {} as AppLoadContext, + } satisfies ActionFunctionArgs) + + expect(response).toBeInstanceOf(Response) + expect(response.status).toBe(201) + expect(await response.text()).toBe('Measurement saved in box') + }) + + it('should reject with wrong access token', async () => { + const request = new Request( + `${BASE_URL}/api/boxes/${deviceId}/${sensorIds[0]}`, + { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Authorization: 'wrongAccessToken', + }, + body: JSON.stringify({ value: 312.1 }), + }, + ) + + const response: any = await postSingleMeasurementAction({ + request, + params: { deviceId: deviceId, sensorId: sensorIds[0] }, + context: {} as AppLoadContext, + } satisfies ActionFunctionArgs) + + expect(response.status).toBe(401) + const body = await response.json() + expect(body.message).toBe('Device access token not valid!') + }) + + it('should accept a single measurement with timestamp', async () => { + const timestamp = new Date().toISOString() + + const request = new Request( + `${BASE_URL}/api/boxes/${deviceId}/${sensorIds[1]}`, + { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Authorization: mockAccessToken, + }, + body: JSON.stringify({ value: 123.4, createdAt: timestamp }), + }, + ) + + const response: any = await postSingleMeasurementAction({ + request, + params: { deviceId: deviceId, sensorId: sensorIds[1] }, + context: {} as AppLoadContext, + } satisfies ActionFunctionArgs) + + expect(response.status).toBe(201) + expect(await response.text()).toBe('Measurement saved in box') + }) + + it('should reject measurement with timestamp too far into the future', async () => { + const future = new Date(Date.now() + 90_000).toISOString() // 1.5 min future + + const request = new Request( + `${BASE_URL}/api/boxes/${deviceId}/${sensorIds[1]}`, + { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Authorization: mockAccessToken, + }, + body: JSON.stringify({ value: 123.4, createdAt: future }), + }, + ) + + const response: any = await postSingleMeasurementAction({ + request, + params: { deviceId: deviceId, sensorId: sensorIds[1] }, + context: {} as AppLoadContext, + } satisfies ActionFunctionArgs) + + expect(response.status).toBe(422) + }) + }) + + // --------------------------------------------------- + // Multiple CSV POST + // --------------------------------------------------- + describe('multiple CSV POST /boxes/:id/data', () => { + it('should accept multiple measurements as CSV via POST (no timestamps)', async () => { + const csvPayload = csvExampleData.noTimestamps(sensors) + + const request = new Request(`${BASE_URL}/api/boxes/${deviceId}/data`, { + method: 'POST', + headers: { + 'Content-Type': 'text/csv', + Authorization: mockAccessToken, + }, + body: csvPayload, + }) + + const response: any = await postMeasurementsAction({ + request, + params: { deviceId: deviceId }, + context: {} as AppLoadContext, + } satisfies ActionFunctionArgs) + + expect(response.status).toBe(201) + expect(await response.text()).toContain('Measurements saved in box') + }) + + it('should accept multiple measurements as CSV via POST (with timestamps)', async () => { + const csvPayload = csvExampleData.withTimestamps(sensors) + + const request = new Request(`${BASE_URL}/api/boxes/${deviceId}/data`, { + method: 'POST', + headers: { + 'Content-Type': 'text/csv', + Authorization: mockAccessToken, + }, + body: csvPayload, + }) + + const response: any = await postMeasurementsAction({ + request, + params: { deviceId: deviceId }, + context: {} as AppLoadContext, + } satisfies ActionFunctionArgs) + + expect(response.status).toBe(201) + }) + + it('should reject CSV with future timestamps', async () => { + const csvPayload = csvExampleData.withTimestampsFuture(sensors) + + const request = new Request(`${BASE_URL}/api/boxes/${deviceId}/data`, { + method: 'POST', + headers: { + 'Content-Type': 'text/csv', + Authorization: mockAccessToken, + }, + body: csvPayload, + }) + + const response: any = await postMeasurementsAction({ + request, + params: { deviceId: deviceId }, + context: {} as AppLoadContext, + } satisfies ActionFunctionArgs) + + expect(response.status).toBe(422) + }) + }) + + // --------------------------------------------------- + // Multiple bytes POST + // --------------------------------------------------- + describe('multiple bytes POST /boxes/:id/data', () => { + it('should accept multiple measurements as bytes via POST', async () => { + const submitTime = new Date() + const request = new Request(`${BASE_URL}/api/boxes/${deviceId}/data`, { + method: 'POST', + headers: { + 'Content-Type': 'application/sbx-bytes', + Authorization: mockAccessToken, + }, + body: byteSubmitData(sensors) as unknown as BodyInit, + }) + + const response: any = await postMeasurementsAction({ + request, + params: { deviceId: deviceId }, + context: {} as AppLoadContext, + } as ActionFunctionArgs) + + expect(response.status).toBe(201) + expect(await response.text()).toContain('Measurements saved in box') + + const updatedDevice = await getDevice({ id: deviceId }) + + expect(updatedDevice?.sensors).toBeDefined() + updatedDevice?.sensors?.forEach((sensor: any) => { + expect(sensor.lastMeasurement).toBeDefined() + expect(sensor.lastMeasurement).not.toBeNull() + + // Verify the measurement timestamp is recent + if (sensor.lastMeasurement?.createdAt) { + const createdAt = new Date(sensor.lastMeasurement.createdAt) + const diffMinutes = + Math.abs(submitTime.getTime() - createdAt.getTime()) / (1000 * 60) + expect(diffMinutes).toBeLessThan(4) + } + }) + }) + + it('should accept multiple measurements as bytes with timestamps', async () => { + const submitTime = new Date() + + const request = new Request(`${BASE_URL}/api/boxes/${deviceId}/data`, { + method: 'POST', + headers: { + 'Content-Type': 'application/sbx-bytes-ts', + Authorization: mockAccessToken, + }, + body: byteSubmitData(sensors, true) as unknown as BodyInit, + }) + + const response: any = await postMeasurementsAction({ + request, + params: { deviceId: deviceId }, + context: {} as AppLoadContext, + } as ActionFunctionArgs) + + expect(response.status).toBe(201) + expect(await response.text()).toBe('Measurements saved in box') + + const updatedDevice = await getDevice({ id: deviceId }) + + expect(updatedDevice?.sensors).toBeDefined() + expect(updatedDevice?.sensors?.length).toBeGreaterThan(0) + + updatedDevice?.sensors?.forEach((sensor: any) => { + expect(sensor.lastMeasurement).toBeDefined() + expect(sensor.lastMeasurement).not.toBeNull() + + expect(sensor.lastMeasurement.createdAt).toBeDefined() + + // Verify the timestamp is within 5 minutes of submission + const createdAt = new Date(sensor.lastMeasurement.createdAt) + const diffMinutes = + Math.abs(submitTime.getTime() - createdAt.getTime()) / (1000 * 60) + expect(diffMinutes).toBeLessThan(5) + }) + }) + }) + + it('should reject measurements with invalid sensor IDs', async () => { + // Create byte data with a non-existent sensor ID + const fakeSensorId = 'fakeid123456' + const bytesPerSensor = 16 + const buffer = new ArrayBuffer(bytesPerSensor) + const view = new DataView(buffer) + const bytes = new Uint8Array(buffer) + + function stringToHex(str: string): string { + let hex = '' + for (let i = 0; i < str.length; i++) { + const charCode = str.charCodeAt(i) + hex += charCode.toString(16).padStart(2, '0') + } + return hex + } + + // Encode fake sensor ID + const fakeIdHex = stringToHex(fakeSensorId).slice(0, 24) + for (let j = 0; j < 12; j++) { + const hexByteStart = j * 2 + const hexByte = fakeIdHex.slice(hexByteStart, hexByteStart + 2) + bytes[j] = parseInt(hexByte, 16) || 0 + } + view.setFloat32(12, 25.5, true) + + const request = new Request(`${BASE_URL}/api/boxes/${deviceId}/data`, { + method: 'POST', + headers: { + 'Content-Type': 'application/sbx-bytes', + Authorization: mockAccessToken, + }, + body: bytes, + }) + + const response: any = await postMeasurementsAction({ + request, + params: { deviceId: deviceId }, + context: {} as AppLoadContext, + } as ActionFunctionArgs) + + console.log('response invalid sensor', response) + + // Should either reject or silently skip invalid sensors + expect(response.status).toBeGreaterThanOrEqual(200) + }) + + it('should handle empty measurements', async () => { + const request = new Request(`${BASE_URL}/api/boxes/${deviceId}/data`, { + method: 'POST', + headers: { + 'Content-Type': 'application/sbx-bytes', + Authorization: mockAccessToken, + }, + body: new Uint8Array(0), + }) + + const response: any = await postMeasurementsAction({ + request, + params: { deviceId: deviceId }, + context: {} as AppLoadContext, + } as ActionFunctionArgs) + + expect(response.status).toBe(422) // Unprocessable Entity + }) + + // --------------------------------------------------- + // MQTT publishing + // --------------------------------------------------- + // describe("MQTT submission", () => { + // it("should accept measurements through mqtt", async () => { + // // NOTE: You’ll need to wire up a real or mock MQTT client. + // // Example: use `mqtt` npm package and connect to a local broker in test env. + // // Here we just stub: + + // const fakePublishMqttMessage = async ( + // topic: string, + // payload: string + // ) => { + // // call your app’s MQTT ingestion handler directly instead of broker + // const request = new Request(`${BASE_URL}/api/mqtt`, { + // method: "POST", + // headers: { "Content-Type": "application/json" }, + // body: payload, + // }); + // return postMeasurementsAction({ + // request, + // params: { deviceId: deviceId }, + // context: {} as AppLoadContext + + // } as ActionFunctionArgs); + // }; + + // const payload = JSON.stringify(jsonSubmitData.jsonArr(sensors)); + // const mqttResponse: any = await fakePublishMqttMessage("mytopic", payload); + + // expect(mqttResponse.status).toBe(201); + // }); + // }); + + describe('multiple JSON POST /boxes/:id/data', () => { + it('should accept multiple measurements with timestamps as JSON object via POST (content-type: json)', async () => { + const submitData = jsonSubmitData.jsonObj(sensors) + + const request = new Request(`${BASE_URL}/api/boxes/${deviceId}/data`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Authorization: mockAccessToken, + }, + body: JSON.stringify(submitData), + }) + + const before = new Date() + + const response: any = await postMeasurementsAction({ + request, + params: { deviceId }, + context: {} as AppLoadContext, + } satisfies ActionFunctionArgs) + + const after = new Date() + + expect(response.status).toBe(201) + expect(await response.text()).toContain('Measurements saved in box') + + // Verify sensors got updated + const updatedDevice = await getDevice({ id: deviceId }) + for (const sensor of updatedDevice?.sensors || []) { + expect(sensor.lastMeasurement).toBeTruthy() + expect( + new Date((sensor.lastMeasurement as any).createdAt).getTime(), + ).toBeGreaterThanOrEqual(before.getTime() - 1000) + expect( + new Date((sensor.lastMeasurement as any).createdAt).getTime(), + ).toBeLessThanOrEqual(after.getTime() + 1000 * 60 * 4) // within ~4 min + } + }) + + it('should accept multiple measurements with timestamps as JSON object via POST', async () => { + const submitData = jsonSubmitData.jsonObj(sensors) + + const request = new Request(`${BASE_URL}/api/boxes/${deviceId}/data`, { + method: 'POST', + headers: { + Authorization: mockAccessToken, + // TODO: remove header here + 'Content-Type': 'application/json', + }, + body: JSON.stringify(submitData), + }) + + const before = new Date() + const response: any = await postMeasurementsAction({ + request, + params: { deviceId }, + context: {} as AppLoadContext, + } satisfies ActionFunctionArgs) + const after = new Date() + + expect(response.status).toBe(201) + expect(await response.text()).toContain('Measurements saved in box') + + const updatedDevice = await getDevice({ id: deviceId }) + for (const sensor of updatedDevice?.sensors || []) { + expect(sensor.lastMeasurement).toBeTruthy() + const createdAt = new Date((sensor.lastMeasurement as any).createdAt) + expect(createdAt.getTime()).toBeGreaterThanOrEqual( + before.getTime() - 1000, + ) + expect(createdAt.getTime()).toBeLessThanOrEqual( + after.getTime() + 1000 * 60 * 4, + ) + } + }) + + it('should accept multiple measurements with timestamps as JSON array via POST', async () => { + const submitData = jsonSubmitData.jsonArr(sensors) + + const request = new Request(`${BASE_URL}/api/boxes/${deviceId}/data`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Authorization: mockAccessToken, + }, + body: JSON.stringify(submitData), + }) + + const before = new Date() + const response: any = await postMeasurementsAction({ + request, + params: { deviceId }, + context: {} as AppLoadContext, + } satisfies ActionFunctionArgs) + const after = new Date() + + expect(response.status).toBe(201) + expect(await response.text()).toContain('Measurements saved in box') + + const updatedDevice = await getDevice({ id: deviceId }) + for (const sensor of updatedDevice?.sensors || []) { + expect(sensor.lastMeasurement).toBeTruthy() + const createdAt = new Date((sensor.lastMeasurement as any).createdAt) + expect(createdAt.getTime()).toBeGreaterThanOrEqual( + before.getTime() - 1000, + ) + expect(createdAt.getTime()).toBeLessThanOrEqual( + after.getTime() + 1000 * 60 * 4, + ) + } + }) + }) + + afterAll(async () => { + await deleteUserByEmail(TEST_USER.email) + await deleteDevice({ id: deviceId }) + }) +}) diff --git a/tests/routes/api.sign-out.spec.ts b/tests/routes/api.sign-out.spec.ts index d48d4de5..1d03875c 100644 --- a/tests/routes/api.sign-out.spec.ts +++ b/tests/routes/api.sign-out.spec.ts @@ -1,60 +1,57 @@ -import { type ActionFunctionArgs } from "react-router"; -import { BASE_URL } from "vitest.setup"; -import { createToken } from "~/lib/jwt"; -import { registerUser } from "~/lib/user-service.server"; -import { deleteUserByEmail } from "~/models/user.server"; -import { action } from "~/routes/api.sign-out"; -import { type User } from "~/schema"; +import { type ActionFunctionArgs } from 'react-router' +import { generateTestUserCredentials } from 'tests/data/generate_test_user' +import { BASE_URL } from 'vitest.setup' +import { createToken } from '~/lib/jwt' +import { registerUser } from '~/lib/user-service.server' +import { deleteUserByEmail } from '~/models/user.server' +import { action } from '~/routes/api.sign-out' +import { type User } from '~/schema' -const VALID_SIGN_OUT_TEST_USER = { - name: "sign out", - email: "test@sign.out", - password: "some secure password", -}; +const VALID_SIGN_OUT_TEST_USER = generateTestUserCredentials() -describe("openSenseMap API Routes: /users", () => { - describe("/sign-out", () => { - let jwt: string = ""; - beforeAll(async () => { - const user = await registerUser( - VALID_SIGN_OUT_TEST_USER.name, - VALID_SIGN_OUT_TEST_USER.email, - VALID_SIGN_OUT_TEST_USER.password, - "en_US", - ); - ({ token: jwt } = await createToken(user as User)); - }); +describe('openSenseMap API Routes: /users', () => { + describe('/sign-out', () => { + let jwt: string = '' + beforeAll(async () => { + const user = await registerUser( + VALID_SIGN_OUT_TEST_USER.name, + VALID_SIGN_OUT_TEST_USER.email, + VALID_SIGN_OUT_TEST_USER.password, + 'en_US', + ) + ;({ token: jwt } = await createToken(user as User)) + }) - describe("/POST", () => { - it("should allow to sign out with jwt", async () => { - // Arrange - const request = new Request(`${BASE_URL}/users/sign-out`, { - method: "POST", - headers: { - Authorization: `Bearer ${jwt}`, - "Content-Type": "application/x-www-form-urlencoded", - }, - body: "", // No body needed, but must be present for POST - }); + describe('/POST', () => { + it('should allow to sign out with jwt', async () => { + // Arrange + const request = new Request(`${BASE_URL}/users/sign-out`, { + method: 'POST', + headers: { + Authorization: `Bearer ${jwt}`, + 'Content-Type': 'application/x-www-form-urlencoded', + }, + body: '', // No body needed, but must be present for POST + }) - // Act - const dataFunctionValue = await action({ - request, - } as ActionFunctionArgs); - const response = dataFunctionValue as Response; + // Act + const dataFunctionValue = await action({ + request, + } as ActionFunctionArgs) + const response = dataFunctionValue as Response - // Assert - expect(dataFunctionValue).toBeInstanceOf(Response); - expect(response.status).toBe(200); - expect(response.headers.get("content-type")).toBe( - "application/json; charset=utf-8", - ); - }); - }); + // Assert + expect(dataFunctionValue).toBeInstanceOf(Response) + expect(response.status).toBe(200) + expect(response.headers.get('content-type')).toBe( + 'application/json; charset=utf-8', + ) + }) + }) - afterAll(async () => { - // delete the valid test user - await deleteUserByEmail(VALID_SIGN_OUT_TEST_USER.email); - }); - }); -}); + afterAll(async () => { + // delete the valid test user + await deleteUserByEmail(VALID_SIGN_OUT_TEST_USER.email) + }) + }) +}) diff --git a/tests/routes/api.tags.spec.ts b/tests/routes/api.tags.spec.ts index fa5e63fb..6617bcb9 100644 --- a/tests/routes/api.tags.spec.ts +++ b/tests/routes/api.tags.spec.ts @@ -1,93 +1,90 @@ -import { 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.tags"; -import { type User } from "~/schema"; +import { type LoaderFunctionArgs } from 'react-router' +import { generateTestUserCredentials } from 'tests/data/generate_test_user' +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.tags' +import { type User } from '~/schema' -const TAGS_TEST_USER = { - name: "testing all my tags", - email: "test@tags.me", - password: "some secure password", -}; +const TAGS_TEST_USER = generateTestUserCredentials() const TEST_TAG_BOX = { - name: `'${TAGS_TEST_USER.name}'s Box`, - exposure: "outdoor", - expiresAt: null, - tags: ["tag1", "tag2"], - latitude: 0, - longitude: 0, - model: "luftdaten.info", - mqttEnabled: false, - ttnEnabled: false, -}; + name: `'${TAGS_TEST_USER.name}'s Box`, + exposure: 'outdoor', + expiresAt: null, + tags: ['tag1', 'tag2'], + latitude: 0, + longitude: 0, + model: 'luftdaten.info', + mqttEnabled: false, + ttnEnabled: false, +} -describe("openSenseMap API Routes: /tags", () => { - let userId: string = ""; - let deviceId: string = ""; +describe('openSenseMap API Routes: /tags', () => { + let userId: string = '' + let deviceId: string = '' - beforeAll(async () => { - const user = await registerUser( - TAGS_TEST_USER.name, - TAGS_TEST_USER.email, - TAGS_TEST_USER.password, - "en_US", - ); - userId = (user as User).id; - }); + beforeAll(async () => { + const user = await registerUser( + TAGS_TEST_USER.name, + TAGS_TEST_USER.email, + TAGS_TEST_USER.password, + 'en_US', + ) + userId = (user as User).id + }) - it("should return empty array of tags when none are there", async () => { - // Arrange - const request = new Request(`${BASE_URL}/tags`, { - method: "GET", - headers: { Accept: "application/json" }, - }); + it('should return empty array of tags when none are there', async () => { + // Arrange + const request = new Request(`${BASE_URL}/tags`, { + method: 'GET', + headers: { Accept: 'application/json' }, + }) - // Act - const dataFunctionValue = await loader({ - request: request, - } as LoaderFunctionArgs); - const response = dataFunctionValue as Response; - const body = await response.json(); + // Act + const dataFunctionValue = await loader({ + request: request, + } 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(Array.isArray(body.data)).toBe(true); - expect(body.data).toHaveLength(0); - }); + // Assert + expect(response.status).toBe(200) + expect(response.headers.get('content-type')).toBe( + 'application/json; charset=utf-8', + ) + expect(Array.isArray(body.data)).toBe(true) + expect(body.data).toHaveLength(0) + }) - it("should return distinct grouptags of boxes", async () => { - // Arrange - const request = new Request(`${BASE_URL}/tags`, { - method: "GET", - headers: { Accept: "application/json" }, - }); - const device = await createDevice(TEST_TAG_BOX, userId); - deviceId = device.id; + it('should return distinct grouptags of boxes', async () => { + // Arrange + const request = new Request(`${BASE_URL}/tags`, { + method: 'GET', + headers: { Accept: 'application/json' }, + }) + const device = await createDevice(TEST_TAG_BOX, userId) + deviceId = device.id - // Act - const dataFunctionValue = await loader({ - request: request, - } as LoaderFunctionArgs); - const response = dataFunctionValue as Response; - const body = await response.json(); + // Act + const dataFunctionValue = await loader({ + request: request, + } 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(Array.isArray(body.data)).toBe(true); - expect(body.data).toHaveLength(2); - }); + // Assert + expect(response.status).toBe(200) + expect(response.headers.get('content-type')).toBe( + 'application/json; charset=utf-8', + ) + expect(Array.isArray(body.data)).toBe(true) + expect(body.data).toHaveLength(2) + }) - afterAll(async () => { - // delete the valid test user - await deleteUserByEmail(TAGS_TEST_USER.email); - await deleteDevice({ id: deviceId }); - }); -}); + afterAll(async () => { + // delete the valid test user + await deleteUserByEmail(TAGS_TEST_USER.email) + await deleteDevice({ id: deviceId }) + }) +}) diff --git a/tests/routes/api.transfers.spec.ts b/tests/routes/api.transfers.spec.ts index 830e4772..dbe7c0db 100644 --- a/tests/routes/api.transfers.spec.ts +++ b/tests/routes/api.transfers.spec.ts @@ -1,12 +1,15 @@ -import { type LoaderFunctionArgs, type ActionFunctionArgs } from "react-router"; -import { BASE_URL } from "vitest.setup"; -import { createToken } from "~/lib/jwt"; +import { 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 } from "~/models/device.server"; -import { deleteUserByEmail } from "~/models/user.server"; -import {action as transferAction} from "~/routes/api.transfer" -import {action as transferUpdateAction, loader as transferLoader} from "~/routes/api.transfer.$deviceId" -import { type Device, type User } from "~/schema"; +import { createDevice } from '~/models/device.server' +import { deleteUserByEmail } from '~/models/user.server' +import { action as transferAction } from '~/routes/api.transfer' +import { + action as transferUpdateAction, + loader as transferLoader, +} from '~/routes/api.transfer.$deviceId' +import { type Device, type User } from '~/schema' const TRANSFER_TEST_USER = { name: 'asdfhwerskdfsdfnxmcv', @@ -14,21 +17,6 @@ const TRANSFER_TEST_USER = { password: 'highlySecurePasswordForTesting', } -const createTestUser = async (suffix: string): Promise => { - const result = await registerUser( - "testuser" + suffix, - `test${suffix}@test.com`, - "password123", - "en_US" - ); - - if (!result || (typeof result === 'object' && 'isValid' in result)) { - throw new Error("Failed to create test user"); - } - - return result as User; -}; - const generateMinimalDevice = ( location: number[] | {} = [123, 12, 34], exposure = 'mobile', @@ -40,345 +28,345 @@ const generateMinimalDevice = ( model: 'homeV2Ethernet', }) -describe("openSenseMap API Routes: /boxes/transfer and /boxes/claim", () => { - - let user: User | null = null - let jwt: string = '' - let queryableDevice: Device | null = null - - let transferToken: string = '' - let transferClaimId: 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 /boxes/transfer', () => { - 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; - transferClaimId = body.data.id; - - // Assertions - expect(response.status).toBe(201); - 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(/^[0-9a-f]{12}$/.test(body.data.token)).toBe(true); // Hex format check - - 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); - }); - - it("should reject if boxId is missing", 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({}), - }); - - const response = (await transferAction({ - request, - } as ActionFunctionArgs)) as Response; - - expect(response.status).toBe(400); - const body = await response.json(); - expect(body.error).toContain("required"); - }); - - it("should reject if device does not exist", 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: "nonexistent-device-id" }), - }); - - const response = (await transferAction({ - request, - } as ActionFunctionArgs)) as Response; - - expect(response.status).toBe(404); - const body = await response.json(); - expect(body.error).toContain("not found"); - }); - - it("should reject if user does not own the device", async () => { - // Create another user - const otherUser = await registerUser( - "other" + Date.now(), - `other${Date.now()}@test.com`, - "password123", - "en_US" - ); - const { token: otherJwt } = await createToken(otherUser as User); - - const request = new Request(`${BASE_URL}/boxes/transfer`, { - method: "POST", - headers: { - "Content-Type": "application/json", - "Authorization": `Bearer ${otherJwt}` - }, - body: JSON.stringify({ boxId: queryableDevice!.id }), - }); - - const response = (await transferAction({ - request, - } as ActionFunctionArgs)) as Response; - - expect(response.status).toBe(403); - const body = await response.json(); - expect(body.error).toContain("permission"); - - // Cleanup - await deleteUserByEmail((otherUser as User).email); - }); - }) - - describe('GET /boxes/transfer/:deviceId', () => { - 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); - }); - - it("should reject if user does not own the device", async () => { - const otherUser = await registerUser( - "other" + Date.now(), - `other${Date.now()}@test.com`, - "password123", - "en_US" - ); - const { token: otherJwt } = await createToken(otherUser as User); - - const request = new Request( - `${BASE_URL}/boxes/transfer/${queryableDevice!.id}`, - { - method: "GET", - headers: { - Authorization: `Bearer ${otherJwt}`, - }, - }, - ); - - const response = (await transferLoader({ - request, - params: { deviceId: queryableDevice!.id }, - } as unknown as LoaderFunctionArgs)) as Response; - - expect(response.status).toBe(403); - const body = await response.json(); - expect(body.error).toContain("permission"); - - // Cleanup - await deleteUserByEmail((otherUser as User).email); - }); - }) - - describe('PUT /boxes/transfer/:deviceId', () => { - 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/${queryableDevice!.id}`, - { - 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: { deviceId: queryableDevice!.id }, - } 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); - }); - - it("should reject with invalid token", async () => { - const newExpiry = new Date(); - newExpiry.setDate(newExpiry.getDate() + 2); - - const request = new Request( - `${BASE_URL}/boxes/transfer/${queryableDevice!.id}`, - { - method: "PUT", - headers: { - "Content-Type": "application/json", - Authorization: `Bearer ${jwt}`, - }, - body: JSON.stringify({ - token: "invalid-token-12345", - expiresAt: newExpiry.toISOString(), - }), - }, - ); - - const response = (await transferUpdateAction({ - request, - params: { deviceId: queryableDevice!.id }, - } as unknown as ActionFunctionArgs)) as Response; - - expect(response.status).toBe(400); - const body = await response.json(); - expect(body.error).toContain("Invalid"); - }); - - it("should reject with past expiration date", async () => { - const pastExpiry = new Date(); - pastExpiry.setDate(pastExpiry.getDate() - 1); - - const request = new Request( - `${BASE_URL}/boxes/transfer/${queryableDevice!.id}`, - { - method: "PUT", - headers: { - "Content-Type": "application/json", - Authorization: `Bearer ${jwt}`, - }, - body: JSON.stringify({ - token: transferToken, - expiresAt: pastExpiry.toISOString(), - }), - }, - ); - - const response = (await transferUpdateAction({ - request, - params: { deviceId: queryableDevice!.id }, - } as unknown as ActionFunctionArgs)) as Response; - - expect(response.status).toBe(400); - const body = await response.json(); - expect(body.error).toContain("future"); - }); - }) - - describe('DELETE /boxes/transfer', () => { - it('should revoke and delete a transfer token', async () => { - 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/${queryableDevice!.id}`, - { - 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: { deviceId: queryableDevice!.id }, - } as unknown as ActionFunctionArgs)) as Response; - - expect(verifyResponse.status).toBe(404); - const verifyBody = await verifyResponse.json(); - expect(verifyBody.error).toContain("not found"); - }); - }); - afterAll(async () => { - await deleteUserByEmail(TRANSFER_TEST_USER.email); - }); -}) \ No newline at end of file +describe('openSenseMap API Routes: /boxes/transfer and /boxes/claim', () => { + let user: User | null = null + let jwt: string = '' + let queryableDevice: Device | null = null + + let transferToken: string = '' + let transferClaimId: 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 /boxes/transfer', () => { + 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 + transferClaimId = body.data.id + + // Assertions + expect(response.status).toBe(201) + 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(/^[0-9a-f]{12}$/.test(body.data.token)).toBe(true) // Hex format check + + 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) + }) + + it('should reject if boxId is missing', 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({}), + }) + + const response = (await transferAction({ + request, + } as ActionFunctionArgs)) as Response + + expect(response.status).toBe(400) + const body = await response.json() + expect(body.error).toContain('required') + }) + + it('should reject if device does not exist', 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: 'nonexistent-device-id' }), + }) + + const response = (await transferAction({ + request, + } as ActionFunctionArgs)) as Response + + expect(response.status).toBe(404) + const body = await response.json() + expect(body.error).toContain('not found') + }) + + it('should reject if user does not own the device', async () => { + // Create another user + const otherUser = await registerUser( + 'other' + Date.now(), + `other${Date.now()}@test.com`, + 'password123', + 'en_US', + ) + const { token: otherJwt } = await createToken(otherUser as User) + + const request = new Request(`${BASE_URL}/boxes/transfer`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${otherJwt}`, + }, + body: JSON.stringify({ boxId: queryableDevice!.id }), + }) + + const response = (await transferAction({ + request, + } as ActionFunctionArgs)) as Response + + expect(response.status).toBe(403) + const body = await response.json() + expect(body.error).toContain('permission') + + // Cleanup + await deleteUserByEmail((otherUser as User).email) + }) + }) + + describe('GET /boxes/transfer/:deviceId', () => { + 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) + }) + + it('should reject if user does not own the device', async () => { + const otherUser = await registerUser( + 'other' + Date.now(), + `other${Date.now()}@test.com`, + 'password123', + 'en_US', + ) + const { token: otherJwt } = await createToken(otherUser as User) + + const request = new Request( + `${BASE_URL}/boxes/transfer/${queryableDevice!.id}`, + { + method: 'GET', + headers: { + Authorization: `Bearer ${otherJwt}`, + }, + }, + ) + + const response = (await transferLoader({ + request, + params: { deviceId: queryableDevice!.id }, + } as unknown as LoaderFunctionArgs)) as Response + + expect(response.status).toBe(403) + const body = await response.json() + expect(body.error).toContain('permission') + + // Cleanup + await deleteUserByEmail((otherUser as User).email) + }) + }) + + describe('PUT /boxes/transfer/:deviceId', () => { + 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/${queryableDevice!.id}`, + { + 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: { deviceId: queryableDevice!.id }, + } 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) + }) + + it('should reject with invalid token', async () => { + const newExpiry = new Date() + newExpiry.setDate(newExpiry.getDate() + 2) + + const request = new Request( + `${BASE_URL}/boxes/transfer/${queryableDevice!.id}`, + { + method: 'PUT', + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${jwt}`, + }, + body: JSON.stringify({ + token: 'invalid-token-12345', + expiresAt: newExpiry.toISOString(), + }), + }, + ) + + const response = (await transferUpdateAction({ + request, + params: { deviceId: queryableDevice!.id }, + } as unknown as ActionFunctionArgs)) as Response + + expect(response.status).toBe(400) + const body = await response.json() + expect(body.error).toContain('Invalid') + }) + + it('should reject with past expiration date', async () => { + const pastExpiry = new Date() + pastExpiry.setDate(pastExpiry.getDate() - 1) + + const request = new Request( + `${BASE_URL}/boxes/transfer/${queryableDevice!.id}`, + { + method: 'PUT', + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${jwt}`, + }, + body: JSON.stringify({ + token: transferToken, + expiresAt: pastExpiry.toISOString(), + }), + }, + ) + + const response = (await transferUpdateAction({ + request, + params: { deviceId: queryableDevice!.id }, + } as unknown as ActionFunctionArgs)) as Response + + expect(response.status).toBe(400) + const body = await response.json() + expect(body.error).toContain('future') + }) + }) + + describe('DELETE /boxes/transfer', () => { + it('should revoke and delete a transfer token', async () => { + 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/${queryableDevice!.id}`, + { + 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: { deviceId: queryableDevice!.id }, + } as unknown as ActionFunctionArgs)) as Response + + expect(verifyResponse.status).toBe(404) + const verifyBody = await verifyResponse.json() + expect(verifyBody.error).toContain('not found') + }) + }) + afterAll(async () => { + await deleteUserByEmail(TRANSFER_TEST_USER.email) + }) +}) diff --git a/tests/routes/api.users.me.boxes.$deviceId.spec.ts b/tests/routes/api.users.me.boxes.$deviceId.spec.ts index afee96f8..11569b2e 100644 --- a/tests/routes/api.users.me.boxes.$deviceId.spec.ts +++ b/tests/routes/api.users.me.boxes.$deviceId.spec.ts @@ -1,109 +1,102 @@ -import { type Params, type LoaderFunctionArgs } from "react-router"; -import { BASE_URL } from "vitest.setup"; -import { createToken } from "~/lib/jwt"; -import { registerUser } from "~/lib/user-service.server"; -import { createDevice } from "~/models/device.server"; -import { deleteUserByEmail } from "~/models/user.server"; -import { loader } from "~/routes/api.users.me.boxes.$deviceId"; -import { device, type User } from "~/schema"; +import { type Params, type LoaderFunctionArgs } from 'react-router' +import { generateTestUserCredentials } from 'tests/data/generate_test_user' +import { BASE_URL } from 'vitest.setup' +import { createToken } from '~/lib/jwt' +import { registerUser } from '~/lib/user-service.server' +import { createDevice } from '~/models/device.server' +import { deleteUserByEmail } from '~/models/user.server' +import { loader } from '~/routes/api.users.me.boxes.$deviceId' +import { type User } from '~/schema' -const BOX_TEST_USER = { - name: "testing my individual box", - email: "testing@box.me", - password: "some secure password", -}; +const BOX_TEST_USER = generateTestUserCredentials() const BOX_TEST_USER_BOX = { - name: `${BOX_TEST_USER}s Box`, - exposure: "outdoor", - expiresAt: null, - tags: [], - latitude: 0, - longitude: 0, - model: "luftdaten.info", - mqttEnabled: false, - ttnEnabled: false, -}; + name: `${BOX_TEST_USER}s Box`, + exposure: 'outdoor', + expiresAt: null, + tags: [], + latitude: 0, + longitude: 0, + model: 'luftdaten.info', + mqttEnabled: false, + ttnEnabled: false, +} -const OTHER_TEST_USER = { - name: "dont steal my box", - email: "stealing@boxes.me", - password: "some secure password", -}; +const OTHER_TEST_USER = generateTestUserCredentials() // TODO Give the users some boxes to test with -describe("openSenseMap API Routes: /users", () => { - describe("/me/boxes/:deviceId", () => { - describe("GET", async () => { - let jwt: string = ""; - let otherJwt: string = ""; - let deviceId: string = ""; +describe('openSenseMap API Routes: /users', () => { + describe('/me/boxes/:deviceId', () => { + describe('GET', async () => { + let jwt: string = '' + let otherJwt: string = '' + let deviceId: string = '' - beforeAll(async () => { - const user = await registerUser( - BOX_TEST_USER.name, - BOX_TEST_USER.email, - BOX_TEST_USER.password, - "en_US", - ); - const { token: t } = await createToken(user as User); - jwt = t; + beforeAll(async () => { + const user = await registerUser( + BOX_TEST_USER.name, + BOX_TEST_USER.email, + BOX_TEST_USER.password, + 'en_US', + ) + const { token: t } = await createToken(user as User) + jwt = t - const otherUser = await registerUser( - OTHER_TEST_USER.name, - OTHER_TEST_USER.email, - OTHER_TEST_USER.password, - "en_US", - ); - const { token: t2 } = await createToken(otherUser as User); - otherJwt = t2; + const otherUser = await registerUser( + OTHER_TEST_USER.name, + OTHER_TEST_USER.email, + OTHER_TEST_USER.password, + 'en_US', + ) + const { token: t2 } = await createToken(otherUser as User) + otherJwt = t2 - const device = await createDevice(BOX_TEST_USER_BOX, (user as User).id); - deviceId = device.id; - }); + const device = await createDevice(BOX_TEST_USER_BOX, (user as User).id) + deviceId = device.id + }) - it("should let users retrieve one of their boxes with all fields", async () => { - // Act: Get single box - const singleBoxRequest = new Request( - `${BASE_URL}/users/me/boxes/${deviceId}`, - { method: "GET", headers: { Authorization: `Bearer ${jwt}` } }, - ); - const params: Params = { deviceId: deviceId }; - const singleBoxResponse = (await loader({ - request: singleBoxRequest, - params, - } as LoaderFunctionArgs)) as Response; - await singleBoxResponse.json(); - // Assert: Response for single box - expect(singleBoxResponse.status).toBe(200); - }); - it("should deny to retrieve a box of other user", async () => { - // Arrange - const forbiddenRequest = new Request( - `${BASE_URL}/users/me/boxes/${deviceId}`, - { - headers: { Authorization: `Bearer ${otherJwt}` }, - }, - ); - const params: Params = { deviceId: deviceId }; + it('should let users retrieve one of their boxes with all fields', async () => { + // Act: Get single box + const singleBoxRequest = new Request( + `${BASE_URL}/users/me/boxes/${deviceId}`, + { method: 'GET', headers: { Authorization: `Bearer ${jwt}` } }, + ) + const params: Params = { deviceId: deviceId } + const singleBoxResponse = (await loader({ + request: singleBoxRequest, + params, + } as LoaderFunctionArgs)) as Response + await singleBoxResponse.json() + // Assert: Response for single box + expect(singleBoxResponse.status).toBe(200) + }) + it('should deny to retrieve a box of other user', async () => { + // Arrange + const forbiddenRequest = new Request( + `${BASE_URL}/users/me/boxes/${deviceId}`, + { + headers: { Authorization: `Bearer ${otherJwt}` }, + }, + ) + const params: Params = { deviceId: deviceId } - // Act: Try to get the original users box with the other user's JWT - const forbiddenResponse = (await loader({ - request: forbiddenRequest, - params, - } as LoaderFunctionArgs)) as Response; - const forbiddenBody = await forbiddenResponse.json(); - // Assert: Forbidden response - expect(forbiddenResponse.status).toBe(403); - expect(forbiddenBody.code).toBe("Forbidden"); - expect(forbiddenBody.message).toBe("User does not own this senseBox"); - }); + // Act: Try to get the original users box with the other user's JWT + const forbiddenResponse = (await loader({ + request: forbiddenRequest, + params, + } as LoaderFunctionArgs)) as Response + const forbiddenBody = await forbiddenResponse.json() + // Assert: Forbidden response + expect(forbiddenResponse.status).toBe(403) + expect(forbiddenBody.code).toBe('Forbidden') + expect(forbiddenBody.message).toBe('User does not own this senseBox') + }) - afterAll(async () => { - // delete the valid test user - await deleteUserByEmail(BOX_TEST_USER.email); - await deleteUserByEmail(OTHER_TEST_USER.email); - }); - }); - }); -}); + afterAll(async () => { + // delete the valid test user + await deleteUserByEmail(BOX_TEST_USER.email) + await deleteUserByEmail(OTHER_TEST_USER.email) + }) + }) + }) +}) diff --git a/tests/routes/api.users.me.boxes.spec.ts b/tests/routes/api.users.me.boxes.spec.ts index 8a6eb4aa..6bf064df 100644 --- a/tests/routes/api.users.me.boxes.spec.ts +++ b/tests/routes/api.users.me.boxes.spec.ts @@ -1,168 +1,169 @@ -import { type LoaderFunctionArgs } 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 } from "~/routes/api.users.me.boxes"; -import { type User } from "~/schema"; - -const BOXES_TEST_USER = { - name: "testing all my boxes", - email: "test@boxes.me", - password: "some secure password", -}; +import { type LoaderFunctionArgs } from 'react-router' +import { generateTestUserCredentials } from 'tests/data/generate_test_user' +import { BASE_URL } from 'vitest.setup' +import { createToken } from '~/lib/jwt' +import { registerUser } from '~/lib/user-service.server' +import { createDevice, deleteDevice } from '~/models/device.server' +import { deleteUserByEmail } from '~/models/user.server' +import { loader } from '~/routes/api.users.me.boxes' +import { type User } from '~/schema' + +const BOXES_TEST_USER = generateTestUserCredentials() const TEST_BOX = { - name: `'${BOXES_TEST_USER.name}'s Box`, - exposure: "outdoor", - expiresAt: null, - tags: [], - latitude: 0, - longitude: 0, - model: "luftdaten.info", - mqttEnabled: false, - ttnEnabled: false, -}; - -describe("openSenseMap API Routes: /users", () => { - let jwt: string = ""; - let deviceId = ""; - - describe("/me/boxes", () => { - describe("GET", async () => { - beforeAll(async () => { - const user = await registerUser( - BOXES_TEST_USER.name, - BOXES_TEST_USER.email, - BOXES_TEST_USER.password, - "en_US", - ); - const { token } = await createToken(user as User); - jwt = token; - const device = await createDevice(TEST_BOX, (user as User).id); - deviceId = device.id; - }); - it("should let users retrieve their boxes and sharedBoxes with all fields", async () => { - // Arrange - const request = new Request(`${BASE_URL}/users/me/boxes`, { - method: "GET", - headers: { Authorization: `Bearer ${jwt}` }, - }); - - // Act - const response = (await loader({ - request, - } as LoaderFunctionArgs)) as Response; - const body = await response?.json(); - - expect(response.status).toBe(200); - expect(response.headers.get("content-type")).toBe("application/json; charset=utf-8"); - - expect(body).toHaveProperty("code", "Ok"); - expect(body).toHaveProperty("data"); - expect(body.data).toHaveProperty("boxes"); - expect(body.data).toHaveProperty("boxes_count"); - expect(body.data).toHaveProperty("sharedBoxes"); - - expect(Array.isArray(body.data.boxes)).toBe(true); - expect(body.data.boxes_count).toBe(body.data.boxes.length); - expect(Array.isArray(body.data.sharedBoxes)).toBe(true); - - if (body.data.boxes.length > 0) { - const box = body.data.boxes[0]; - - expect(box).toHaveProperty("_id"); - expect(box).toHaveProperty("name"); - expect(box).toHaveProperty("exposure"); - expect(box).toHaveProperty("model"); - expect(box).toHaveProperty("grouptag"); - expect(box).toHaveProperty("createdAt"); - expect(box).toHaveProperty("updatedAt"); - expect(box).toHaveProperty("useAuth"); - - expect(box).toHaveProperty("currentLocation"); - expect(box.currentLocation).toHaveProperty("type", "Point"); - expect(box.currentLocation).toHaveProperty("coordinates"); - expect(box.currentLocation).toHaveProperty("timestamp"); - expect(Array.isArray(box.currentLocation.coordinates)).toBe(true); - expect(box.currentLocation.coordinates).toHaveLength(2); - - expect(box).toHaveProperty("lastMeasurementAt"); - expect(box).toHaveProperty("loc"); - expect(Array.isArray(box.loc)).toBe(true); - expect(box.loc[0]).toHaveProperty("geometry"); - expect(box.loc[0]).toHaveProperty("type", "Feature"); - - expect(box).toHaveProperty("integrations"); - expect(box.integrations).toHaveProperty("mqtt"); - expect(box.integrations.mqtt).toHaveProperty("enabled", false); - - expect(box).toHaveProperty("sensors"); - expect(Array.isArray(box.sensors)).toBe(true); - } - }); - - it("should return empty boxes array for user with no devices", async () => { - const userWithNoDevices = await registerUser( - "No Devices User", - "nodevices@test.com", - "password123", - "en_US", - ); - const { token: noDevicesJwt } = await createToken(userWithNoDevices as User); - - const request = new Request(`${BASE_URL}/users/me/boxes`, { - method: "GET", - headers: { Authorization: `Bearer ${noDevicesJwt}` }, - }); - - const response = (await loader({ - request, - } as LoaderFunctionArgs)) as Response; - const body = await response?.json(); - - expect(response.status).toBe(200); - expect(body.data.boxes).toHaveLength(0); - expect(body.data.boxes_count).toBe(0); - expect(body.data.sharedBoxes).toHaveLength(0); - - await deleteUserByEmail("nodevices@test.com"); - }); - - it("should handle invalid JWT token", async () => { - const request = new Request(`${BASE_URL}/users/me/boxes`, { - method: "GET", - headers: { Authorization: `Bearer invalid-token` }, - }); - - const response = (await loader({ - request, - } as LoaderFunctionArgs)) as Response; - const body = await response?.json(); - - expect(response.status).toBe(403); - expect(body.code).toBe("Forbidden"); - expect(body.message).toContain("Invalid JWT authorization"); - }); - - it("should handle missing authorization header", async () => { - const request = new Request(`${BASE_URL}/users/me/boxes`, { - method: "GET", - }); - - const response = (await loader({ - request, - } as LoaderFunctionArgs)) as Response; - const body = await response?.json(); - - expect(response.status).toBe(403); - expect(body.code).toBe("Forbidden"); - }); - - afterAll(async () => { - await deleteUserByEmail(BOXES_TEST_USER.email); - await deleteDevice({ id: deviceId }); - }); - }); - }); -}); + name: `'${BOXES_TEST_USER.name}'s Box`, + exposure: 'outdoor', + expiresAt: null, + tags: [], + latitude: 0, + longitude: 0, + model: 'luftdaten.info', + mqttEnabled: false, + ttnEnabled: false, +} + +describe('openSenseMap API Routes: /users', () => { + let jwt: string = '' + let deviceId = '' + + describe('/me/boxes', () => { + describe('GET', async () => { + beforeAll(async () => { + const user = await registerUser( + BOXES_TEST_USER.name, + BOXES_TEST_USER.email, + BOXES_TEST_USER.password, + 'en_US', + ) + const { token } = await createToken(user as User) + jwt = token + const device = await createDevice(TEST_BOX, (user as User).id) + deviceId = device.id + }) + it('should let users retrieve their boxes and sharedBoxes with all fields', async () => { + // Arrange + const request = new Request(`${BASE_URL}/users/me/boxes`, { + method: 'GET', + headers: { Authorization: `Bearer ${jwt}` }, + }) + + // Act + const response = (await loader({ + request, + } as LoaderFunctionArgs)) as Response + const body = await response?.json() + + expect(response.status).toBe(200) + expect(response.headers.get('content-type')).toBe( + 'application/json; charset=utf-8', + ) + + expect(body).toHaveProperty('code', 'Ok') + expect(body).toHaveProperty('data') + expect(body.data).toHaveProperty('boxes') + expect(body.data).toHaveProperty('boxes_count') + expect(body.data).toHaveProperty('sharedBoxes') + + expect(Array.isArray(body.data.boxes)).toBe(true) + expect(body.data.boxes_count).toBe(body.data.boxes.length) + expect(Array.isArray(body.data.sharedBoxes)).toBe(true) + + if (body.data.boxes.length > 0) { + const box = body.data.boxes[0] + + expect(box).toHaveProperty('_id') + expect(box).toHaveProperty('name') + expect(box).toHaveProperty('exposure') + expect(box).toHaveProperty('model') + expect(box).toHaveProperty('grouptag') + expect(box).toHaveProperty('createdAt') + expect(box).toHaveProperty('updatedAt') + expect(box).toHaveProperty('useAuth') + + expect(box).toHaveProperty('currentLocation') + expect(box.currentLocation).toHaveProperty('type', 'Point') + expect(box.currentLocation).toHaveProperty('coordinates') + expect(box.currentLocation).toHaveProperty('timestamp') + expect(Array.isArray(box.currentLocation.coordinates)).toBe(true) + expect(box.currentLocation.coordinates).toHaveLength(2) + + expect(box).toHaveProperty('lastMeasurementAt') + expect(box).toHaveProperty('loc') + expect(Array.isArray(box.loc)).toBe(true) + expect(box.loc[0]).toHaveProperty('geometry') + expect(box.loc[0]).toHaveProperty('type', 'Feature') + + expect(box).toHaveProperty('integrations') + expect(box.integrations).toHaveProperty('mqtt') + expect(box.integrations.mqtt).toHaveProperty('enabled', false) + + expect(box).toHaveProperty('sensors') + expect(Array.isArray(box.sensors)).toBe(true) + } + }) + + it('should return empty boxes array for user with no devices', async () => { + const userWithNoDevices = await registerUser( + 'No Devices User', + 'nodevices@test.com', + 'password123', + 'en_US', + ) + const { token: noDevicesJwt } = await createToken( + userWithNoDevices as User, + ) + + const request = new Request(`${BASE_URL}/users/me/boxes`, { + method: 'GET', + headers: { Authorization: `Bearer ${noDevicesJwt}` }, + }) + + const response = (await loader({ + request, + } as LoaderFunctionArgs)) as Response + const body = await response?.json() + + expect(response.status).toBe(200) + expect(body.data.boxes).toHaveLength(0) + expect(body.data.boxes_count).toBe(0) + expect(body.data.sharedBoxes).toHaveLength(0) + + await deleteUserByEmail('nodevices@test.com') + }) + + it('should handle invalid JWT token', async () => { + const request = new Request(`${BASE_URL}/users/me/boxes`, { + method: 'GET', + headers: { Authorization: `Bearer invalid-token` }, + }) + + const response = (await loader({ + request, + } as LoaderFunctionArgs)) as Response + const body = await response?.json() + + expect(response.status).toBe(403) + expect(body.code).toBe('Forbidden') + expect(body.message).toContain('Invalid JWT authorization') + }) + + it('should handle missing authorization header', async () => { + const request = new Request(`${BASE_URL}/users/me/boxes`, { + method: 'GET', + }) + + const response = (await loader({ + request, + } as LoaderFunctionArgs)) as Response + const body = await response?.json() + + expect(response.status).toBe(403) + expect(body.code).toBe('Forbidden') + }) + + afterAll(async () => { + await deleteUserByEmail(BOXES_TEST_USER.email) + await deleteDevice({ id: deviceId }) + }) + }) + }) +}) diff --git a/tests/routes/api.users.me.resend-email-confirmation.spec.ts b/tests/routes/api.users.me.resend-email-confirmation.spec.ts index 41df26c4..c9e13aec 100644 --- a/tests/routes/api.users.me.resend-email-confirmation.spec.ts +++ b/tests/routes/api.users.me.resend-email-confirmation.spec.ts @@ -1,66 +1,63 @@ -import { type ActionFunctionArgs } from "react-router"; -import { BASE_URL } from "vitest.setup"; -import { createToken } from "~/lib/jwt"; -import { registerUser } from "~/lib/user-service.server"; -import { deleteUserByEmail } from "~/models/user.server"; -import { action } from "~/routes/api.users.me.resend-email-confirmation"; -import { type User } from "~/schema"; +import { type ActionFunctionArgs } from 'react-router' +import { generateTestUserCredentials } from 'tests/data/generate_test_user' +import { BASE_URL } from 'vitest.setup' +import { createToken } from '~/lib/jwt' +import { registerUser } from '~/lib/user-service.server' +import { deleteUserByEmail } from '~/models/user.server' +import { action } from '~/routes/api.users.me.resend-email-confirmation' +import { type User } from '~/schema' -const RESEND_EMAIL_USER = { - name: "resend some mails", - email: "test@resend.email", - password: "highlySecurePasswordForTesting", -}; +const RESEND_EMAIL_USER = generateTestUserCredentials() -describe("openSenseMap API Routes: /users", () => { - describe("/resend-email-confirmation", () => { - describe("POST", () => { - let jwt: string = ""; +describe('openSenseMap API Routes: /users', () => { + describe('/resend-email-confirmation', () => { + describe('POST', () => { + let jwt: string = '' - beforeAll(async () => { - const user = await registerUser( - RESEND_EMAIL_USER.name, - RESEND_EMAIL_USER.email, - RESEND_EMAIL_USER.password, - "en_US", - ); - const { token: t } = await createToken(user as User); - jwt = t; - }); + beforeAll(async () => { + const user = await registerUser( + RESEND_EMAIL_USER.name, + RESEND_EMAIL_USER.email, + RESEND_EMAIL_USER.password, + 'en_US', + ) + const { token: t } = await createToken(user as User) + jwt = t + }) - it("should allow users to request a resend of the email confirmation", async () => { - // Request resend email confirmation - const resendRequest = new Request( - `${BASE_URL}/users/me/resend-email-confirmation`, - { - method: "POST", - headers: { - Authorization: `Bearer ${jwt}`, - "Content-Type": "application/x-www-form-urlencoded", - }, - body: "", // No body required - }, - ); + it('should allow users to request a resend of the email confirmation', async () => { + // Request resend email confirmation + const resendRequest = new Request( + `${BASE_URL}/users/me/resend-email-confirmation`, + { + method: 'POST', + headers: { + Authorization: `Bearer ${jwt}`, + 'Content-Type': 'application/x-www-form-urlencoded', + }, + body: '', // No body required + }, + ) - const resendResponse = (await action({ - request: resendRequest, - } as ActionFunctionArgs)) as Response; + const resendResponse = (await action({ + request: resendRequest, + } as ActionFunctionArgs)) as Response - expect(resendResponse.status).toBe(200); - expect(resendResponse.headers.get("content-type")).toBe( - "application/json; charset=utf-8", - ); - const resendBody = await resendResponse.json(); - expect(resendBody).toMatchObject({ - code: "Ok", - message: `Email confirmation has been sent to ${RESEND_EMAIL_USER.email}`, - }); - }); + expect(resendResponse.status).toBe(200) + expect(resendResponse.headers.get('content-type')).toBe( + 'application/json; charset=utf-8', + ) + const resendBody = await resendResponse.json() + expect(resendBody).toMatchObject({ + code: 'Ok', + message: `Email confirmation has been sent to ${RESEND_EMAIL_USER.email}`, + }) + }) - afterAll(async () => { - // delete the valid test user - await deleteUserByEmail(RESEND_EMAIL_USER.email); - }); - }); - }); -}); + afterAll(async () => { + // delete the valid test user + await deleteUserByEmail(RESEND_EMAIL_USER.email) + }) + }) + }) +}) diff --git a/tests/routes/api.users.me.spec.ts b/tests/routes/api.users.me.spec.ts index 7c28bc14..4f2f6468 100644 --- a/tests/routes/api.users.me.spec.ts +++ b/tests/routes/api.users.me.spec.ts @@ -1,428 +1,425 @@ -import { type ActionFunctionArgs, type LoaderFunctionArgs } from "react-router"; -import { BASE_URL } from "vitest.setup"; -import { createToken } from "~/lib/jwt"; -import { registerUser } from "~/lib/user-service.server"; -import { deleteUserByEmail } from "~/models/user.server"; -import { loader as meLoader, action as meAction } from "~/routes/api.users.me"; -import { type User } from "~/schema"; - -const ME_TEST_USER = { - name: "meTest", - email: "test@me.endpoint", - password: "highlySecurePasswordForTesting", -}; - -const ME_UPDATE_EMAIL = "test.updated@me.endpoint"; -const ME_UPDATE_NAME = "me2Test"; - -describe("openSenseMap API Routes: /users", () => { - let jwt: string = ""; - - beforeAll(async () => { - const user = await registerUser( - ME_TEST_USER.name, - ME_TEST_USER.email, - ME_TEST_USER.password, - "en_US", - ); - const { token: t } = await createToken(user as User); - jwt = t; - }); - - describe("/me", () => { - describe("GET", () => { - it("should allow users to request their details", async () => { - // Arrange - const request = new Request(`${BASE_URL}/users/me`, { - method: "GET", - headers: { Authorization: `Bearer ${jwt}` }, - }); - - // Act - const dataFunctionValue = await meLoader({ - request: request, - } 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).toMatchObject({ - code: "Ok", - data: { me: { email: ME_TEST_USER.email } }, - }); - }); - }); - - describe("PUT", () => { - it("should deny to change email and password at the same time", async () => { - const request = new Request(`${BASE_URL}/users/me`, { - method: "PUT", - headers: { - Authorization: `Bearer ${jwt}`, - "Content-Type": "application/json", - }, - body: JSON.stringify({ - email: "new-email@email.www", - newPassword: "87654321", - }), - }); - - const response = (await meAction({ - request, - } as ActionFunctionArgs)) as Response; - const body = await response.json(); - - expect(response.status).toBe(400); - expect(body).toHaveProperty( - "message", - "You cannot change your email address and password in the same request.", - ); - }); - - it("should deny to change email without current passsword", async () => { - const request = new Request(`${BASE_URL}/users/me`, { - method: "PUT", - headers: { - Authorization: `Bearer ${jwt}`, - "Content-Type": "application/json", - }, - body: JSON.stringify({ email: "new-email@email.www" }), - }); - - const response = (await meAction({ - request, - } as ActionFunctionArgs)) as Response; - const body = await response.json(); - - expect(response.status).toBe(400); - expect(body).toHaveProperty( - "message", - "To change your password or email address, please supply your current password.", - ); - }); - - it("should deny to change email with wrong current passsword", async () => { - const request = new Request(`${BASE_URL}/users/me`, { - method: "PUT", - headers: { - Authorization: `Bearer ${jwt}`, - "Content-Type": "application/json", - }, - body: JSON.stringify({ - email: "new-email@email.www", - currentPassword: "wrongpassword", - }), - }); - - const response = (await meAction({ - request, - } as ActionFunctionArgs)) as Response; - const body = await response.json(); - - expect(response.status).toBe(400); - expect(response.headers.get("content-type")).toBe( - "application/json; charset=utf-8", - ); - expect(body).toHaveProperty("message", "Password incorrect"); - }); - - it("should allow to change email with correct current passsword", async () => { - // Change email - const putRequest = new Request(`${BASE_URL}/users/me`, { - method: "PUT", - headers: { - Authorization: `Bearer ${jwt}`, - "Content-Type": "application/json", - }, - body: JSON.stringify({ - email: ME_UPDATE_EMAIL, - currentPassword: ME_TEST_USER.password, - }), - }); - const putResponse = (await meAction({ - request: putRequest, - } as ActionFunctionArgs)) as Response; - const putBody = await putResponse.json(); - - expect(putResponse.status).toBe(200); - expect(putBody).toHaveProperty( - "message", - "User successfully saved. E-Mail changed. Please confirm your new address. Until confirmation, sign in using your old address", - ); - - // Fetch updated user - const getRequest = new Request(`${BASE_URL}/users/me`, { - method: "GET", - headers: { Authorization: `Bearer ${jwt}` }, - }); - const getResponse = (await meLoader({ - request: getRequest, - } as ActionFunctionArgs)) as Response; - const getBody = await getResponse.json(); - - expect(getResponse.status).toBe(200); - expect(getBody.data.me.email).toBe(ME_TEST_USER.email); - }); - - it("should allow to change name", async () => { - // Change name - const putRequest = new Request(`${BASE_URL}/users/me`, { - method: "PUT", - headers: { - Authorization: `Bearer ${jwt}`, - "Content-Type": "application/json", - }, - body: JSON.stringify({ name: ME_UPDATE_NAME }), - }); - const putResponse = (await meAction({ - request: putRequest, - } as ActionFunctionArgs)) as Response; - const putBody = await putResponse.json(); - - expect(putResponse.status).toBe(200); - expect(putBody).toHaveProperty( - "message", - "User successfully saved. Name changed.", - ); - - // Fetch updated user - const getRequest = new Request(`${BASE_URL}/users/me`, { - method: "GET", - headers: { Authorization: `Bearer ${jwt}` }, - }); - const getResponse = (await meLoader({ - request: getRequest, - } as ActionFunctionArgs)) as Response; - const getBody = await getResponse.json(); - - expect(getResponse.status).toBe(200); - expect(getBody.data.me.name).toBe(ME_UPDATE_NAME); - }); - - it("should return that no changed properties are applied and user remains unchanged", async () => { - const request = new Request(`${BASE_URL}/users/me`, { - method: "PUT", - headers: { - Authorization: `Bearer ${jwt}`, - "Content-Type": "application/json", - }, - body: JSON.stringify({ name: ME_UPDATE_NAME }), - }); - - const response = (await meAction({ - request, - } as ActionFunctionArgs)) as Response; - const body = await response.json(); - - expect(response.status).toBe(200); - expect(body).toHaveProperty( - "message", - "No changed properties supplied. User remains unchanged.", - ); - }); - - it("should deny to change name to existing name", async () => { - const request = new Request(`${BASE_URL}/users/me`, { - method: "PUT", - headers: { - Authorization: `Bearer ${jwt}`, - "Content-Type": "application/json", - }, - body: JSON.stringify({ - name: ME_UPDATE_NAME, - currentPassword: ME_TEST_USER.password, - }), - }); - - const response = (await meAction({ - request, - } as ActionFunctionArgs)) as Response; - const body = await response.json(); - - expect(response.status).toBe(200); - expect(body).toHaveProperty( - "message", - "No changed properties supplied. User remains unchanged.", - ); - }); - - it("should deny to change password with too short new password", async () => { - const request = new Request(`${BASE_URL}/users/me`, { - method: "PUT", - headers: { - Authorization: `Bearer ${jwt}`, - "Content-Type": "application/json", - }, - body: JSON.stringify({ - newPassword: "short", - currentPassword: ME_TEST_USER.password, - }), - }); - - const response = (await meAction({ - request, - } as ActionFunctionArgs)) as Response; - const body = await response.json(); - - expect(response.status).toBe(400); - expect(body).toHaveProperty( - "message", - "New password should have at least 8 characters", - ); - }); - - it("should deny to change email to invalid email", async () => { - const request = new Request(`${BASE_URL}/users/me`, { - method: "PUT", - headers: { - Authorization: `Bearer ${jwt}`, - "Content-Type": "application/json", - }, - body: JSON.stringify({ - email: "invalid email", - currentPassword: ME_TEST_USER.password, - }), - }); - - const response = (await meAction({ - request, - } as ActionFunctionArgs)) as Response; - - expect(response.status).toBe(400); - }); - - it("should deny to change name to invalid name", async () => { - const request = new Request(`${BASE_URL}/users/me`, { - method: "PUT", - headers: { - Authorization: `Bearer ${jwt}`, - "Content-Type": "application/json", - }, - body: JSON.stringify({ - name: " invalid name", - currentPassword: ME_TEST_USER.password, - }), - }); - - const response = (await meAction({ - request, - } as ActionFunctionArgs)) as Response; - - expect(response.status).toBe(400); - }); - }); - - describe("DELETE", () => { - it("should deny to delete user without jwt", async () => { - // Arrange - const deleteRequest = new Request(`${BASE_URL}/users/me`, { - method: "DELETE", - }); - - // Act - const deleteResponse = (await meAction({ - request: deleteRequest, - } as ActionFunctionArgs)) as Response; - - // Assert - expect(deleteResponse.status).toBe(403); - }); - - it("should deny to delete user without password parameter", async () => { - // Attempt to delete user without password parameter - const deleteRequest = new Request(`${BASE_URL}/users/me`, { - method: "DELETE", - headers: { Authorization: `Bearer ${jwt}` }, - // No body - }); - const deleteResponse = (await meAction({ - request: deleteRequest, - } as ActionFunctionArgs)) as Response; - - // Assert: Should return 400 Bad Request - expect(deleteResponse.status).toBe(400); - }); - - it("should deny to delete user with empty password parameter", async () => { - // Prepare the body with an empty password - const deleteParams = new URLSearchParams(); - deleteParams.append("password", ""); - - // Attempt to delete user with empty password - const deleteRequest = new Request(`${BASE_URL}/users/me`, { - method: "DELETE", - headers: { - "Content-Type": "application/x-www-form-urlencoded", - Authorization: `Bearer ${jwt}`, - }, - body: deleteParams.toString(), - }); - - const deleteResponse = (await meAction({ - request: deleteRequest, - } as ActionFunctionArgs)) as Response; - - expect(deleteResponse.status).toBe(400); - }); - - it("should deny to delete user with wrong password parameter", async () => { - // Prepare the body with an incorrect password - const deleteParams = new URLSearchParams(); - deleteParams.append("password", `${ME_TEST_USER.password}hallo`); - - // Attempt to delete user with wrong password - const deleteRequest = new Request(`${BASE_URL}/users/me`, { - method: "DELETE", - headers: { - "Content-Type": "application/x-www-form-urlencoded", - Authorization: `Bearer ${jwt}`, - }, - body: deleteParams.toString(), - }); - - const deleteResponse = (await meAction({ - request: deleteRequest, - } as ActionFunctionArgs)) as Response; - const deleteBody = await deleteResponse.json(); - - // Assertions - expect(deleteResponse.status).toBe(401); - expect(deleteResponse.headers.get("content-type")).toBe( - "application/json; charset=utf-8", - ); - expect(deleteBody).toHaveProperty("message", "Password incorrect"); - }); - - it("should allow to delete user with correct password parameter", async () => { - // Prepare the body with the correct password - const deleteParams = new URLSearchParams(); - deleteParams.append("password", ME_TEST_USER.password); - - // Attempt to delete user with correct password - const deleteRequest = new Request(`${BASE_URL}/users/me`, { - method: "DELETE", - headers: { - "Content-Type": "application/x-www-form-urlencoded", - Authorization: `Bearer ${jwt}`, - }, - body: deleteParams.toString(), - }); - - const deleteResponse = (await meAction({ - request: deleteRequest, - } as ActionFunctionArgs)) as Response; - expect(deleteResponse.status).toBe(200); - }); - }); - - afterAll(async () => { - // delete the valid test user - await deleteUserByEmail(ME_TEST_USER.email); - await deleteUserByEmail(ME_UPDATE_EMAIL); - }); - }); -}); +import { type ActionFunctionArgs, type LoaderFunctionArgs } from 'react-router' +import { generateTestUserCredentials } from 'tests/data/generate_test_user' +import { BASE_URL } from 'vitest.setup' +import { createToken } from '~/lib/jwt' +import { registerUser } from '~/lib/user-service.server' +import { deleteUserByEmail } from '~/models/user.server' +import { loader as meLoader, action as meAction } from '~/routes/api.users.me' +import { type User } from '~/schema' + +const ME_TEST_USER = generateTestUserCredentials() + +const ME_UPDATE_EMAIL = 'test.updated@me.endpoint' +const ME_UPDATE_NAME = 'me2Test' + +describe('openSenseMap API Routes: /users', () => { + let jwt: string = '' + + beforeAll(async () => { + const user = await registerUser( + ME_TEST_USER.name, + ME_TEST_USER.email, + ME_TEST_USER.password, + 'en_US', + ) + const { token: t } = await createToken(user as User) + jwt = t + }) + + describe('/me', () => { + describe('GET', () => { + it('should allow users to request their details', async () => { + // Arrange + const request = new Request(`${BASE_URL}/users/me`, { + method: 'GET', + headers: { Authorization: `Bearer ${jwt}` }, + }) + + // Act + const dataFunctionValue = await meLoader({ + request: request, + } 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).toMatchObject({ + code: 'Ok', + data: { me: { email: ME_TEST_USER.email } }, + }) + }) + }) + + describe('PUT', () => { + it('should deny to change email and password at the same time', async () => { + const request = new Request(`${BASE_URL}/users/me`, { + method: 'PUT', + headers: { + Authorization: `Bearer ${jwt}`, + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + email: 'new-email@email.www', + newPassword: '87654321', + }), + }) + + const response = (await meAction({ + request, + } as ActionFunctionArgs)) as Response + const body = await response.json() + + expect(response.status).toBe(400) + expect(body).toHaveProperty( + 'message', + 'You cannot change your email address and password in the same request.', + ) + }) + + it('should deny to change email without current passsword', async () => { + const request = new Request(`${BASE_URL}/users/me`, { + method: 'PUT', + headers: { + Authorization: `Bearer ${jwt}`, + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ email: 'new-email@email.www' }), + }) + + const response = (await meAction({ + request, + } as ActionFunctionArgs)) as Response + const body = await response.json() + + expect(response.status).toBe(400) + expect(body).toHaveProperty( + 'message', + 'To change your password or email address, please supply your current password.', + ) + }) + + it('should deny to change email with wrong current passsword', async () => { + const request = new Request(`${BASE_URL}/users/me`, { + method: 'PUT', + headers: { + Authorization: `Bearer ${jwt}`, + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + email: 'new-email@email.www', + currentPassword: 'wrongpassword', + }), + }) + + const response = (await meAction({ + request, + } as ActionFunctionArgs)) as Response + const body = await response.json() + + expect(response.status).toBe(400) + expect(response.headers.get('content-type')).toBe( + 'application/json; charset=utf-8', + ) + expect(body).toHaveProperty('message', 'Password incorrect') + }) + + it('should allow to change email with correct current passsword', async () => { + // Change email + const putRequest = new Request(`${BASE_URL}/users/me`, { + method: 'PUT', + headers: { + Authorization: `Bearer ${jwt}`, + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + email: ME_UPDATE_EMAIL, + currentPassword: ME_TEST_USER.password, + }), + }) + const putResponse = (await meAction({ + request: putRequest, + } as ActionFunctionArgs)) as Response + const putBody = await putResponse.json() + + expect(putResponse.status).toBe(200) + expect(putBody).toHaveProperty( + 'message', + 'User successfully saved. E-Mail changed. Please confirm your new address. Until confirmation, sign in using your old address', + ) + + // Fetch updated user + const getRequest = new Request(`${BASE_URL}/users/me`, { + method: 'GET', + headers: { Authorization: `Bearer ${jwt}` }, + }) + const getResponse = (await meLoader({ + request: getRequest, + } as ActionFunctionArgs)) as Response + const getBody = await getResponse.json() + + expect(getResponse.status).toBe(200) + expect(getBody.data.me.email).toBe(ME_TEST_USER.email) + }) + + it('should allow to change name', async () => { + // Change name + const putRequest = new Request(`${BASE_URL}/users/me`, { + method: 'PUT', + headers: { + Authorization: `Bearer ${jwt}`, + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ name: ME_UPDATE_NAME }), + }) + const putResponse = (await meAction({ + request: putRequest, + } as ActionFunctionArgs)) as Response + const putBody = await putResponse.json() + + expect(putResponse.status).toBe(200) + expect(putBody).toHaveProperty( + 'message', + 'User successfully saved. Name changed.', + ) + + // Fetch updated user + const getRequest = new Request(`${BASE_URL}/users/me`, { + method: 'GET', + headers: { Authorization: `Bearer ${jwt}` }, + }) + const getResponse = (await meLoader({ + request: getRequest, + } as ActionFunctionArgs)) as Response + const getBody = await getResponse.json() + + expect(getResponse.status).toBe(200) + expect(getBody.data.me.name).toBe(ME_UPDATE_NAME) + }) + + it('should return that no changed properties are applied and user remains unchanged', async () => { + const request = new Request(`${BASE_URL}/users/me`, { + method: 'PUT', + headers: { + Authorization: `Bearer ${jwt}`, + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ name: ME_UPDATE_NAME }), + }) + + const response = (await meAction({ + request, + } as ActionFunctionArgs)) as Response + const body = await response.json() + + expect(response.status).toBe(200) + expect(body).toHaveProperty( + 'message', + 'No changed properties supplied. User remains unchanged.', + ) + }) + + it('should deny to change name to existing name', async () => { + const request = new Request(`${BASE_URL}/users/me`, { + method: 'PUT', + headers: { + Authorization: `Bearer ${jwt}`, + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + name: ME_UPDATE_NAME, + currentPassword: ME_TEST_USER.password, + }), + }) + + const response = (await meAction({ + request, + } as ActionFunctionArgs)) as Response + const body = await response.json() + + expect(response.status).toBe(200) + expect(body).toHaveProperty( + 'message', + 'No changed properties supplied. User remains unchanged.', + ) + }) + + it('should deny to change password with too short new password', async () => { + const request = new Request(`${BASE_URL}/users/me`, { + method: 'PUT', + headers: { + Authorization: `Bearer ${jwt}`, + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + newPassword: 'short', + currentPassword: ME_TEST_USER.password, + }), + }) + + const response = (await meAction({ + request, + } as ActionFunctionArgs)) as Response + const body = await response.json() + + expect(response.status).toBe(400) + expect(body).toHaveProperty( + 'message', + 'New password should have at least 8 characters', + ) + }) + + it('should deny to change email to invalid email', async () => { + const request = new Request(`${BASE_URL}/users/me`, { + method: 'PUT', + headers: { + Authorization: `Bearer ${jwt}`, + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + email: 'invalid email', + currentPassword: ME_TEST_USER.password, + }), + }) + + const response = (await meAction({ + request, + } as ActionFunctionArgs)) as Response + + expect(response.status).toBe(400) + }) + + it('should deny to change name to invalid name', async () => { + const request = new Request(`${BASE_URL}/users/me`, { + method: 'PUT', + headers: { + Authorization: `Bearer ${jwt}`, + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + name: ' invalid name', + currentPassword: ME_TEST_USER.password, + }), + }) + + const response = (await meAction({ + request, + } as ActionFunctionArgs)) as Response + + expect(response.status).toBe(400) + }) + }) + + describe('DELETE', () => { + it('should deny to delete user without jwt', async () => { + // Arrange + const deleteRequest = new Request(`${BASE_URL}/users/me`, { + method: 'DELETE', + }) + + // Act + const deleteResponse = (await meAction({ + request: deleteRequest, + } as ActionFunctionArgs)) as Response + + // Assert + expect(deleteResponse.status).toBe(403) + }) + + it('should deny to delete user without password parameter', async () => { + // Attempt to delete user without password parameter + const deleteRequest = new Request(`${BASE_URL}/users/me`, { + method: 'DELETE', + headers: { Authorization: `Bearer ${jwt}` }, + // No body + }) + const deleteResponse = (await meAction({ + request: deleteRequest, + } as ActionFunctionArgs)) as Response + + // Assert: Should return 400 Bad Request + expect(deleteResponse.status).toBe(400) + }) + + it('should deny to delete user with empty password parameter', async () => { + // Prepare the body with an empty password + const deleteParams = new URLSearchParams() + deleteParams.append('password', '') + + // Attempt to delete user with empty password + const deleteRequest = new Request(`${BASE_URL}/users/me`, { + method: 'DELETE', + headers: { + 'Content-Type': 'application/x-www-form-urlencoded', + Authorization: `Bearer ${jwt}`, + }, + body: deleteParams.toString(), + }) + + const deleteResponse = (await meAction({ + request: deleteRequest, + } as ActionFunctionArgs)) as Response + + expect(deleteResponse.status).toBe(400) + }) + + it('should deny to delete user with wrong password parameter', async () => { + // Prepare the body with an incorrect password + const deleteParams = new URLSearchParams() + deleteParams.append('password', `${ME_TEST_USER.password}hallo`) + + // Attempt to delete user with wrong password + const deleteRequest = new Request(`${BASE_URL}/users/me`, { + method: 'DELETE', + headers: { + 'Content-Type': 'application/x-www-form-urlencoded', + Authorization: `Bearer ${jwt}`, + }, + body: deleteParams.toString(), + }) + + const deleteResponse = (await meAction({ + request: deleteRequest, + } as ActionFunctionArgs)) as Response + const deleteBody = await deleteResponse.json() + + // Assertions + expect(deleteResponse.status).toBe(401) + expect(deleteResponse.headers.get('content-type')).toBe( + 'application/json; charset=utf-8', + ) + expect(deleteBody).toHaveProperty('message', 'Password incorrect') + }) + + it('should allow to delete user with correct password parameter', async () => { + // Prepare the body with the correct password + const deleteParams = new URLSearchParams() + deleteParams.append('password', ME_TEST_USER.password) + + // Attempt to delete user with correct password + const deleteRequest = new Request(`${BASE_URL}/users/me`, { + method: 'DELETE', + headers: { + 'Content-Type': 'application/x-www-form-urlencoded', + Authorization: `Bearer ${jwt}`, + }, + body: deleteParams.toString(), + }) + + const deleteResponse = (await meAction({ + request: deleteRequest, + } as ActionFunctionArgs)) as Response + expect(deleteResponse.status).toBe(200) + }) + }) + + afterAll(async () => { + // delete the valid test user + await deleteUserByEmail(ME_TEST_USER.email) + await deleteUserByEmail(ME_UPDATE_EMAIL) + }) + }) +}) diff --git a/tests/routes/api.users.refresh-auth.spec.ts b/tests/routes/api.users.refresh-auth.spec.ts index 6133eef2..af54b0fb 100644 --- a/tests/routes/api.users.refresh-auth.spec.ts +++ b/tests/routes/api.users.refresh-auth.spec.ts @@ -1,281 +1,278 @@ -import { type ActionFunctionArgs } from "react-router"; -import { BASE_URL } from "vitest.setup"; -import { createToken } from "~/lib/jwt"; -import { registerUser } from "~/lib/user-service.server"; -import { deleteUserByEmail } from "~/models/user.server"; -import { action as signOutAction } from "~/routes/api.sign-out"; -import { action as meAction, loader as meLoader } from "~/routes/api.users.me"; -import { action } from "~/routes/api.users.refresh-auth"; -import { action as signInAction } from "~/routes/api.users.sign-in"; -import { type User } from "~/schema"; +import { type ActionFunctionArgs } from 'react-router' +import { generateTestUserCredentials } from 'tests/data/generate_test_user' +import { BASE_URL } from 'vitest.setup' +import { createToken } from '~/lib/jwt' +import { registerUser } from '~/lib/user-service.server' +import { deleteUserByEmail } from '~/models/user.server' +import { action as signOutAction } from '~/routes/api.sign-out' +import { action as meAction, loader as meLoader } from '~/routes/api.users.me' +import { action } from '~/routes/api.users.refresh-auth' +import { action as signInAction } from '~/routes/api.users.sign-in' +import { type User } from '~/schema' -const VALID_REFRESH_AUTH_TEST_USER = { - name: "refreshing auth", - email: "test@refresh-auth", - password: "some secure password", -}; -const CHANGED_PW_TO = "some other very secure password"; +const VALID_REFRESH_AUTH_TEST_USER = generateTestUserCredentials() +const CHANGED_PW_TO = 'some other very secure password' -describe("openSenseMap API Routes: /users", () => { - describe("/refresh-auth", () => { - let jwt: string = ""; - let newJwt: string = ""; - let refreshToken: string = ""; - beforeAll(async () => { - const user = await registerUser( - VALID_REFRESH_AUTH_TEST_USER.name, - VALID_REFRESH_AUTH_TEST_USER.email, - VALID_REFRESH_AUTH_TEST_USER.password, - "en_US", - ); - ({ token: jwt, refreshToken } = await createToken(user as User)); - }); +describe('openSenseMap API Routes: /users', () => { + describe('/refresh-auth', () => { + let jwt: string = '' + let newJwt: string = '' + let refreshToken: string = '' + beforeAll(async () => { + const user = await registerUser( + VALID_REFRESH_AUTH_TEST_USER.name, + VALID_REFRESH_AUTH_TEST_USER.email, + VALID_REFRESH_AUTH_TEST_USER.password, + 'en_US', + ) + ;({ token: jwt, refreshToken } = await createToken(user as User)) + }) - describe("/POST", () => { - it("should allow to refresh jwt using the refresh token", async () => { - // Arrange - const params = new URLSearchParams(); - params.append("token", refreshToken); - const request = new Request(`${BASE_URL}/users/refresh-auth`, { - method: "POST", - headers: { - "Content-Type": "application/x-www-form-urlencoded", - Authorization: `Bearer ${jwt}`, - }, - body: params.toString(), - }); + describe('/POST', () => { + it('should allow to refresh jwt using the refresh token', async () => { + // Arrange + const params = new URLSearchParams() + params.append('token', refreshToken) + const request = new Request(`${BASE_URL}/users/refresh-auth`, { + method: 'POST', + headers: { + 'Content-Type': 'application/x-www-form-urlencoded', + Authorization: `Bearer ${jwt}`, + }, + body: params.toString(), + }) - // Act - const dataFunctionValue = await action({ - request, - } as ActionFunctionArgs); - const response = dataFunctionValue as Response; - const body = await response?.json(); + // Act + const dataFunctionValue = await action({ + request, + } as ActionFunctionArgs) + const response = dataFunctionValue as Response + const body = await response?.json() - // Use the new JWT to get user info - newJwt = body.token; - const meRequest = new Request(`${BASE_URL}/users/me`, { - method: "GET", - headers: { Authorization: `Bearer ${newJwt}` }, - }); - const meResponse = (await meLoader({ - request: meRequest, - } as ActionFunctionArgs)) as Response; - const meBody = await meResponse?.json(); + // Use the new JWT to get user info + newJwt = body.token + const meRequest = new Request(`${BASE_URL}/users/me`, { + method: 'GET', + headers: { Authorization: `Bearer ${newJwt}` }, + }) + const meResponse = (await meLoader({ + request: meRequest, + } as ActionFunctionArgs)) as Response + const meBody = await meResponse?.json() - // Assert - expect(dataFunctionValue).toBeInstanceOf(Response); - expect(response.status).toBe(200); - expect(response.headers.get("content-type")).toBe( - "application/json; charset=utf-8", - ); - expect(body).toHaveProperty("token"); - expect(body).toHaveProperty("refreshToken"); + // Assert + expect(dataFunctionValue).toBeInstanceOf(Response) + expect(response.status).toBe(200) + expect(response.headers.get('content-type')).toBe( + 'application/json; charset=utf-8', + ) + expect(body).toHaveProperty('token') + expect(body).toHaveProperty('refreshToken') - expect(meResponse).toBeInstanceOf(Response); - expect(meResponse.status).toBe(200); - expect(meResponse.headers.get("content-type")).toBe( - "application/json; charset=utf-8", - ); - expect(meBody).toMatchObject({ - code: "Ok", - data: { me: { email: VALID_REFRESH_AUTH_TEST_USER.email } }, - }); - }); + expect(meResponse).toBeInstanceOf(Response) + expect(meResponse.status).toBe(200) + expect(meResponse.headers.get('content-type')).toBe( + 'application/json; charset=utf-8', + ) + expect(meBody).toMatchObject({ + code: 'Ok', + data: { me: { email: VALID_REFRESH_AUTH_TEST_USER.email } }, + }) + }) - it("should allow to refresh jwt using JSON data", async () => { - // Arrange - First sign in to get a fresh refresh token - const signInParams = new URLSearchParams(); - signInParams.append("email", VALID_REFRESH_AUTH_TEST_USER.email); - signInParams.append("password", VALID_REFRESH_AUTH_TEST_USER.password); - const signInRequest = new Request(`${BASE_URL}/users/sign-in`, { - method: "POST", - headers: { "Content-Type": "application/x-www-form-urlencoded" }, - body: signInParams.toString(), - }); + it('should allow to refresh jwt using JSON data', async () => { + // Arrange - First sign in to get a fresh refresh token + const signInParams = new URLSearchParams() + signInParams.append('email', VALID_REFRESH_AUTH_TEST_USER.email) + signInParams.append('password', VALID_REFRESH_AUTH_TEST_USER.password) + const signInRequest = new Request(`${BASE_URL}/users/sign-in`, { + method: 'POST', + headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, + body: signInParams.toString(), + }) - const signInResponse = (await signInAction({ - request: signInRequest, - } as ActionFunctionArgs)) as Response; - const signInBody = await signInResponse?.json(); - const freshJwt = signInBody.token; - const freshRefreshToken = signInBody.refreshToken; + const signInResponse = (await signInAction({ + request: signInRequest, + } as ActionFunctionArgs)) as Response + const signInBody = await signInResponse?.json() + const freshJwt = signInBody.token + const freshRefreshToken = signInBody.refreshToken - // Now test JSON refresh - const request = new Request(`${BASE_URL}/users/refresh-auth`, { - method: "POST", - headers: { - "Content-Type": "application/json", - Authorization: `Bearer ${freshJwt}`, - }, - body: JSON.stringify({ token: freshRefreshToken }), - }); + // Now test JSON refresh + const request = new Request(`${BASE_URL}/users/refresh-auth`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${freshJwt}`, + }, + body: JSON.stringify({ token: freshRefreshToken }), + }) - // Act - const dataFunctionValue = await action({ - request, - } as ActionFunctionArgs); - const response = dataFunctionValue as Response; - const body = await response?.json(); + // Act + const dataFunctionValue = await action({ + request, + } as ActionFunctionArgs) + const response = dataFunctionValue as Response + const body = await response?.json() - // Assert - expect(dataFunctionValue).toBeInstanceOf(Response); - expect(response.status).toBe(200); - expect(response.headers.get("content-type")).toBe( - "application/json; charset=utf-8", - ); - expect(body).toHaveProperty("token"); - expect(body).toHaveProperty("refreshToken"); - }); + // Assert + expect(dataFunctionValue).toBeInstanceOf(Response) + expect(response.status).toBe(200) + expect(response.headers.get('content-type')).toBe( + 'application/json; charset=utf-8', + ) + expect(body).toHaveProperty('token') + expect(body).toHaveProperty('refreshToken') + }) - it("should deny to use a refresh token twice", async () => { - // Arrange - const params = new URLSearchParams(); - params.append("token", refreshToken); - const request = new Request(`${BASE_URL}/users/refresh-auth`, { - method: "POST", - headers: { - "Content-Type": "application/x-www-form-urlencoded", - Authorization: `Bearer ${jwt}`, - }, - body: params.toString(), - }); + it('should deny to use a refresh token twice', async () => { + // Arrange + const params = new URLSearchParams() + params.append('token', refreshToken) + const request = new Request(`${BASE_URL}/users/refresh-auth`, { + method: 'POST', + headers: { + 'Content-Type': 'application/x-www-form-urlencoded', + Authorization: `Bearer ${jwt}`, + }, + body: params.toString(), + }) - // Act - const dataFunctionValue = await action({ - request, - } as ActionFunctionArgs); - const response = dataFunctionValue as Response; + // Act + const dataFunctionValue = await action({ + request, + } as ActionFunctionArgs) + const response = dataFunctionValue as Response - // Assert - expect(dataFunctionValue).toBeInstanceOf(Response); - expect(response.status).toBe(403); - }); + // Assert + expect(dataFunctionValue).toBeInstanceOf(Response) + expect(response.status).toBe(403) + }) - it("should deny to request a fresh jwt using refresh token after changing the password", async () => { - // Arrange - const changePasswordRequest = new Request(`${BASE_URL}/users/me`, { - method: "PUT", - headers: { - "Content-Type": "application/json; charset=utf-8", - Authorization: `Bearer ${newJwt}`, - }, - body: JSON.stringify({ - currentPassword: VALID_REFRESH_AUTH_TEST_USER.password, - newPassword: CHANGED_PW_TO, - }), - }); + it('should deny to request a fresh jwt using refresh token after changing the password', async () => { + // Arrange + const changePasswordRequest = new Request(`${BASE_URL}/users/me`, { + method: 'PUT', + headers: { + 'Content-Type': 'application/json; charset=utf-8', + Authorization: `Bearer ${newJwt}`, + }, + body: JSON.stringify({ + currentPassword: VALID_REFRESH_AUTH_TEST_USER.password, + newPassword: CHANGED_PW_TO, + }), + }) - const params = new URLSearchParams(); - params.append("token", refreshToken); - const request = new Request(`${BASE_URL}/users/refresh-auth`, { - method: "POST", - headers: { - "Content-Type": "application/x-www-form-urlencoded", - Authorization: `Bearer ${newJwt}`, - }, - body: params.toString(), - }); + const params = new URLSearchParams() + params.append('token', refreshToken) + const request = new Request(`${BASE_URL}/users/refresh-auth`, { + method: 'POST', + headers: { + 'Content-Type': 'application/x-www-form-urlencoded', + Authorization: `Bearer ${newJwt}`, + }, + body: params.toString(), + }) - // Act - // Change password first - const changePwFunctionValue = await meAction({ - request: changePasswordRequest, - } as ActionFunctionArgs); - const changePwResponse = changePwFunctionValue as Response; - const changePwJson = await changePwResponse.json(); + // Act + // Change password first + const changePwFunctionValue = await meAction({ + request: changePasswordRequest, + } as ActionFunctionArgs) + const changePwResponse = changePwFunctionValue as Response + const changePwJson = await changePwResponse.json() - // Then try refreshing - const dataFunctionValue = await action({ - request, - } as ActionFunctionArgs); - const response = dataFunctionValue as Response; + // Then try refreshing + const dataFunctionValue = await action({ + request, + } as ActionFunctionArgs) + const response = dataFunctionValue as Response - // Assert - expect(changePwFunctionValue).toBeInstanceOf(Response); - expect(changePwResponse.status).toBe(200); - expect(changePwJson).toHaveProperty( - "message", - "User successfully saved. Password changed. Please sign in with your new password", - ); - expect(dataFunctionValue).toBeInstanceOf(Response); - expect(response.status).toBe(403); - }); + // Assert + expect(changePwFunctionValue).toBeInstanceOf(Response) + expect(changePwResponse.status).toBe(200) + expect(changePwJson).toHaveProperty( + 'message', + 'User successfully saved. Password changed. Please sign in with your new password', + ) + expect(dataFunctionValue).toBeInstanceOf(Response) + expect(response.status).toBe(403) + }) - it("should deny to use the refreshToken after signing out", async () => { - // Arrange - const signInParams = new URLSearchParams(); - signInParams.append("email", VALID_REFRESH_AUTH_TEST_USER.email); - signInParams.append("password", CHANGED_PW_TO); - const signInRequest = new Request(`${BASE_URL}/users/sign-in`, { - method: "POST", - headers: { "Content-Type": "application/x-www-form-urlencoded" }, - body: signInParams.toString(), - }); + it('should deny to use the refreshToken after signing out', async () => { + // Arrange + const signInParams = new URLSearchParams() + signInParams.append('email', VALID_REFRESH_AUTH_TEST_USER.email) + signInParams.append('password', CHANGED_PW_TO) + const signInRequest = new Request(`${BASE_URL}/users/sign-in`, { + method: 'POST', + headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, + body: signInParams.toString(), + }) - const signOutParams = new URLSearchParams(); - const signOutRequest = new Request(`${BASE_URL}/users/sign-out`, { - method: "POST", - headers: { - "Content-Type": "application/x-www-form-urlencoded", - }, - body: signOutParams.toString(), - }); + const signOutParams = new URLSearchParams() + const signOutRequest = new Request(`${BASE_URL}/users/sign-out`, { + method: 'POST', + headers: { + 'Content-Type': 'application/x-www-form-urlencoded', + }, + body: signOutParams.toString(), + }) - const params = new URLSearchParams(); - const request = new Request(`${BASE_URL}/users/refresh-auth`, { - method: "POST", - headers: { - "Content-Type": "application/x-www-form-urlencoded", - }, - body: params.toString(), - }); + const params = new URLSearchParams() + const request = new Request(`${BASE_URL}/users/refresh-auth`, { + method: 'POST', + headers: { + 'Content-Type': 'application/x-www-form-urlencoded', + }, + body: params.toString(), + }) - // Act - // Make sure to be signed in - const signInFunctionValue = await signInAction({ - request: signInRequest, - } as ActionFunctionArgs); - const signInResponse = signInFunctionValue as Response; - const body = await signInResponse?.json(); - const localJwt = body.token; - const localRefreshToken = body.refreshToken; + // Act + // Make sure to be signed in + const signInFunctionValue = await signInAction({ + request: signInRequest, + } as ActionFunctionArgs) + const signInResponse = signInFunctionValue as Response + const body = await signInResponse?.json() + const localJwt = body.token + const localRefreshToken = body.refreshToken - signOutRequest.headers.append("Authorization", `Bearer ${localJwt}`); - request.headers.append("Authorization", `Bearer ${localJwt}`); - params.append("token", localRefreshToken); + signOutRequest.headers.append('Authorization', `Bearer ${localJwt}`) + request.headers.append('Authorization', `Bearer ${localJwt}`) + params.append('token', localRefreshToken) - // Sign out - const signOutFunctionValue = await signOutAction({ - request: signOutRequest, - } as ActionFunctionArgs); - const signOutResponse = signOutFunctionValue as Response; + // Sign out + const signOutFunctionValue = await signOutAction({ + request: signOutRequest, + } as ActionFunctionArgs) + const signOutResponse = signOutFunctionValue as Response - // Then try refreshing - const dataFunctionValue = await action({ - request, - } as ActionFunctionArgs); - const response = dataFunctionValue as Response; + // Then try refreshing + const dataFunctionValue = await action({ + request, + } as ActionFunctionArgs) + const response = dataFunctionValue as Response - // Assert - expect(signInFunctionValue).toBeInstanceOf(Response); - expect(signInResponse.status).toBe(200); - expect(signInResponse.headers.get("content-type")).toBe( - "application/json; charset=utf-8", - ); - expect(body).toHaveProperty("token"); - expect(body).toHaveProperty("refreshToken"); + // Assert + expect(signInFunctionValue).toBeInstanceOf(Response) + expect(signInResponse.status).toBe(200) + expect(signInResponse.headers.get('content-type')).toBe( + 'application/json; charset=utf-8', + ) + expect(body).toHaveProperty('token') + expect(body).toHaveProperty('refreshToken') - expect(signOutFunctionValue).toBeInstanceOf(Response); - expect(signOutResponse.status).toBe(200); - expect(dataFunctionValue).toBeInstanceOf(Response); - expect(response.status).toBe(403); - }); - }); + expect(signOutFunctionValue).toBeInstanceOf(Response) + expect(signOutResponse.status).toBe(200) + expect(dataFunctionValue).toBeInstanceOf(Response) + expect(response.status).toBe(403) + }) + }) - afterAll(async () => { - // delete the valid test user - await deleteUserByEmail(VALID_REFRESH_AUTH_TEST_USER.email); - }); - }); -}); + afterAll(async () => { + // delete the valid test user + await deleteUserByEmail(VALID_REFRESH_AUTH_TEST_USER.email) + }) + }) +}) diff --git a/tests/routes/api.users.register.spec.ts b/tests/routes/api.users.register.spec.ts index 7674ec17..9b399725 100644 --- a/tests/routes/api.users.register.spec.ts +++ b/tests/routes/api.users.register.spec.ts @@ -1,320 +1,307 @@ -import { type ActionFunctionArgs } from "react-router"; -import { BASE_URL } from "vitest.setup"; -import { deleteUserByEmail } from "~/models/user.server"; -import { action as registerAction } from "~/routes/api.users.register"; - -const VALID_USER = { - name: "this is just a nickname", - email: "tester@test.test", - password: "some secure password", -}; -const VALID_SECOND_USER = { - name: "mrtest", - email: "tester2@test.test", - password: "12345678", -}; - -describe("openSenseMap API Routes: /users/register", () => { - describe("/POST", () => { - it("should allow to register an user via POST", async () => { - // Arrange - const params = new URLSearchParams(); - for (const [key, value] of Object.entries(VALID_USER)) - params.append(key, value); - const request = new Request(`${BASE_URL}/users/register`, { - method: "POST", - headers: { "Content-Type": "application/x-www-form-urlencoded" }, - body: params.toString(), - }); - - // Act - const dataFunctionValue = await registerAction({ - request: request, - } as ActionFunctionArgs); - const response = dataFunctionValue as Response; - const body = await response?.json(); - - // Assert - expect(dataFunctionValue).toBeInstanceOf(Response); - expect(body).toHaveProperty( - "message", - "Successfully registered new user", - ); - expect(response.status).toBe(201); - expect(response.headers.get("content-type")).toBe( - "application/json; charset=utf-8", - ); - expect(body).toHaveProperty("token"); - expect(body).toHaveProperty("refreshToken"); - }); - - it("should deny registering a user with the same email", async () => { - // Arrange - const params = new URLSearchParams(); - for (const [key, value] of Object.entries(VALID_USER)) - params.append(key, value); - const request = new Request(`${BASE_URL}/users/register`, { - method: "POST", - headers: { "Content-Type": "application/x-www-form-urlencoded" }, - body: params.toString(), - }); - - // Act - const response = (await registerAction({ - request, - } as ActionFunctionArgs)) as Response; - const body = await response.json(); - - // Assert - expect(response.status).toBe(400); - expect(response.headers.get("content-type")).toBe( - "application/json; charset=utf-8", - ); - expect(body).toHaveProperty("message", "User already exists."); - }); - - it("should deny registering a user with too short password", async () => { - const params = new URLSearchParams({ - name: "tester", - password: "short", - email: "address@email.com", - }); - const request = new Request(`${BASE_URL}/users/register`, { - method: "POST", - headers: { "Content-Type": "application/x-www-form-urlencoded" }, - body: params.toString(), - }); - - const response = (await registerAction({ - request, - } as ActionFunctionArgs)) as Response; - const body = await response.json(); - - expect(response.status).toBe(400); - expect(response.headers.get("content-type")).toBe( - "application/json; charset=utf-8", - ); - expect(body).toHaveProperty( - "message", - "Password must be at least 8 characters long.", - ); - }); - - it("should deny registering a user with no name", async () => { - const params = new URLSearchParams({ - name: "", - password: "longenough", - email: "address@email.com", - }); - const request = new Request(`${BASE_URL}/users/register`, { - method: "POST", - headers: { "Content-Type": "application/x-www-form-urlencoded" }, - body: params.toString(), - }); - - const response = (await registerAction({ - request, - } as ActionFunctionArgs)) as Response; - const body = await response.json(); - - expect(response.status).toBe(400); - expect(response.headers.get("content-type")).toBe( - "application/json; charset=utf-8", - ); - expect(body).toHaveProperty("message", "Username is required."); - }); - - it("should deny registering a user with missing name parameter", async () => { - const params = new URLSearchParams({ - password: "longenough", - email: "address@email.com", - }); - const request = new Request(`${BASE_URL}/users/register`, { - method: "POST", - headers: { "Content-Type": "application/x-www-form-urlencoded" }, - body: params.toString(), - }); - - const response = (await registerAction({ - request, - } as ActionFunctionArgs)) as Response; - const body = await response.json(); - - expect(response.status).toBe(400); - expect(response.headers.get("content-type")).toBe( - "application/json; charset=utf-8", - ); - expect(body).toHaveProperty("message", "Username is required."); - }); - - it("should deny registering a user with invalid email address", async () => { - const params = new URLSearchParams({ - name: "tester mc testmann", - password: "longenough", - email: "invalid", - }); - const request = new Request(`${BASE_URL}/users/register`, { - method: "POST", - headers: { "Content-Type": "application/x-www-form-urlencoded" }, - body: params.toString(), - }); - - const response = (await registerAction({ - request, - } as ActionFunctionArgs)) as Response; - const body = await response.json(); - - expect(response.status).toBe(400); - expect(response.headers.get("content-type")).toBe( - "application/json; charset=utf-8", - ); - expect(body).toHaveProperty("message", "Invalid email format."); - }); - - it("should deny registering a too short username", async () => { - const params = new URLSearchParams({ - name: "t", - password: "longenough", - email: "address@email.com", - }); - const request = new Request(`${BASE_URL}/users/register`, { - method: "POST", - headers: { "Content-Type": "application/x-www-form-urlencoded" }, - body: params.toString(), - }); - - const response = (await registerAction({ - request, - } as ActionFunctionArgs)) as Response; - const body = await response.json(); - - expect(response.status).toBe(400); - expect(response.headers.get("content-type")).toBe( - "application/json; charset=utf-8", - ); - expect(body).toHaveProperty( - "message", - "Username must be at least 3 characters long and not more than 40.", - ); - }); - - it("should deny registering a user with username not starting with a letter or number", async () => { - const params = new URLSearchParams({ - name: " username", - password: "longenough", - email: "address@email.com", - }); - const request = new Request(`${BASE_URL}/users/register`, { - method: "POST", - headers: { "Content-Type": "application/x-www-form-urlencoded" }, - body: params.toString(), - }); - - const response = (await registerAction({ - request, - } as ActionFunctionArgs)) as Response; - const body = await response.json(); - - expect(response.status).toBe(400); - expect(response.headers.get("content-type")).toBe( - "application/json; charset=utf-8", - ); - expect(body).toHaveProperty( - "message", - "Username may only contain alphanumerics (a-zA-Z0-9), dots (.), dashes (-), underscores (_) and spaces, and has to start with either a number or a letter.", - ); - }); - - it("should deny registering a user with username with invalid characters", async () => { - const params = new URLSearchParams({ - name: "user () name", - password: "longenough", - email: "address@email.com", - }); - const request = new Request(`${BASE_URL}/users/register`, { - method: "POST", - headers: { "Content-Type": "application/x-www-form-urlencoded" }, - body: params.toString(), - }); - - const response = (await registerAction({ - request, - } as ActionFunctionArgs)) as Response; - const body = await response.json(); - - expect(response.status).toBe(400); - expect(response.headers.get("content-type")).toBe( - "application/json; charset=utf-8", - ); - expect(body).toHaveProperty( - "message", - "Username may only contain alphanumerics (a-zA-Z0-9), dots (.), dashes (-), underscores (_) and spaces, and has to start with either a number or a letter.", - ); - }); - - it("should deny registering a too long username", async () => { - const params = new URLSearchParams({ - name: "Really Long User Name which is definetely too long to be accepted", - password: "longenough", - email: "address@email.com", - }); - const request = new Request(`${BASE_URL}/users/register`, { - method: "POST", - headers: { "Content-Type": "application/x-www-form-urlencoded" }, - body: params.toString(), - }); - - const response = (await registerAction({ - request, - } as ActionFunctionArgs)) as Response; - const body = await response.json(); - - expect(response.status).toBe(400); - expect(response.headers.get("content-type")).toBe( - "application/json; charset=utf-8", - ); - expect(body).toHaveProperty( - "message", - "Username must be at least 3 characters long and not more than 40.", - ); - }); - - it("should allow registering a second user via POST", async () => { - // Arrange - const params = new URLSearchParams(); - for (const [key, value] of Object.entries(VALID_SECOND_USER)) - params.append(key, value); - const request = new Request(`${BASE_URL}/users/register`, { - method: "POST", - headers: { "Content-Type": "application/x-www-form-urlencoded" }, - body: params.toString(), - }); - - // Act - const dataFunctionValue = await registerAction({ - request: request, - } as ActionFunctionArgs); - const response = dataFunctionValue as Response; - const body = await response?.json(); - - // Assert - expect(dataFunctionValue).toBeInstanceOf(Response); - expect(body).toHaveProperty( - "message", - "Successfully registered new user", - ); - expect(response.status).toBe(201); - expect(response.headers.get("content-type")).toBe( - "application/json; charset=utf-8", - ); - expect(body).toHaveProperty("token"); - expect(body).toHaveProperty("refreshToken"); - }); - }); - - afterAll(async () => { - // delete the valid test user - await deleteUserByEmail(VALID_USER.email); - await deleteUserByEmail(VALID_SECOND_USER.email); - }); -}); +import { type ActionFunctionArgs } from 'react-router' +import { generateTestUserCredentials } from 'tests/data/generate_test_user' +import { BASE_URL } from 'vitest.setup' +import { deleteUserByEmail } from '~/models/user.server' +import { action as registerAction } from '~/routes/api.users.register' + +const VALID_USER = generateTestUserCredentials() +const VALID_SECOND_USER = generateTestUserCredentials() + +describe('openSenseMap API Routes: /users/register', () => { + describe('/POST', () => { + it('should allow to register an user via POST', async () => { + // Arrange + const params = new URLSearchParams() + for (const [key, value] of Object.entries(VALID_USER)) + params.append(key, value) + const request = new Request(`${BASE_URL}/users/register`, { + method: 'POST', + headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, + body: params.toString(), + }) + + // Act + const dataFunctionValue = await registerAction({ + request: request, + } as ActionFunctionArgs) + const response = dataFunctionValue as Response + const body = await response?.json() + + // Assert + expect(dataFunctionValue).toBeInstanceOf(Response) + expect(body).toHaveProperty('message', 'Successfully registered new user') + expect(response.status).toBe(201) + expect(response.headers.get('content-type')).toBe( + 'application/json; charset=utf-8', + ) + expect(body).toHaveProperty('token') + expect(body).toHaveProperty('refreshToken') + }) + + it('should deny registering a user with the same email', async () => { + // Arrange + const params = new URLSearchParams() + for (const [key, value] of Object.entries(VALID_USER)) + params.append(key, value) + const request = new Request(`${BASE_URL}/users/register`, { + method: 'POST', + headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, + body: params.toString(), + }) + + // Act + const response = (await registerAction({ + request, + } as ActionFunctionArgs)) as Response + const body = await response.json() + + // Assert + expect(response.status).toBe(400) + expect(response.headers.get('content-type')).toBe( + 'application/json; charset=utf-8', + ) + expect(body).toHaveProperty('message', 'User already exists.') + }) + + it('should deny registering a user with too short password', async () => { + const params = new URLSearchParams({ + name: 'tester', + password: 'short', + email: 'address@email.com', + }) + const request = new Request(`${BASE_URL}/users/register`, { + method: 'POST', + headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, + body: params.toString(), + }) + + const response = (await registerAction({ + request, + } as ActionFunctionArgs)) as Response + const body = await response.json() + + expect(response.status).toBe(400) + expect(response.headers.get('content-type')).toBe( + 'application/json; charset=utf-8', + ) + expect(body).toHaveProperty( + 'message', + 'Password must be at least 8 characters long.', + ) + }) + + it('should deny registering a user with no name', async () => { + const params = new URLSearchParams({ + name: '', + password: 'longenough', + email: 'address@email.com', + }) + const request = new Request(`${BASE_URL}/users/register`, { + method: 'POST', + headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, + body: params.toString(), + }) + + const response = (await registerAction({ + request, + } as ActionFunctionArgs)) as Response + const body = await response.json() + + expect(response.status).toBe(400) + expect(response.headers.get('content-type')).toBe( + 'application/json; charset=utf-8', + ) + expect(body).toHaveProperty('message', 'Username is required.') + }) + + it('should deny registering a user with missing name parameter', async () => { + const params = new URLSearchParams({ + password: 'longenough', + email: 'address@email.com', + }) + const request = new Request(`${BASE_URL}/users/register`, { + method: 'POST', + headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, + body: params.toString(), + }) + + const response = (await registerAction({ + request, + } as ActionFunctionArgs)) as Response + const body = await response.json() + + expect(response.status).toBe(400) + expect(response.headers.get('content-type')).toBe( + 'application/json; charset=utf-8', + ) + expect(body).toHaveProperty('message', 'Username is required.') + }) + + it('should deny registering a user with invalid email address', async () => { + const params = new URLSearchParams({ + name: 'tester mc testmann', + password: 'longenough', + email: 'invalid', + }) + const request = new Request(`${BASE_URL}/users/register`, { + method: 'POST', + headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, + body: params.toString(), + }) + + const response = (await registerAction({ + request, + } as ActionFunctionArgs)) as Response + const body = await response.json() + + expect(response.status).toBe(400) + expect(response.headers.get('content-type')).toBe( + 'application/json; charset=utf-8', + ) + expect(body).toHaveProperty('message', 'Invalid email format.') + }) + + it('should deny registering a too short username', async () => { + const params = new URLSearchParams({ + name: 't', + password: 'longenough', + email: 'address@email.com', + }) + const request = new Request(`${BASE_URL}/users/register`, { + method: 'POST', + headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, + body: params.toString(), + }) + + const response = (await registerAction({ + request, + } as ActionFunctionArgs)) as Response + const body = await response.json() + + expect(response.status).toBe(400) + expect(response.headers.get('content-type')).toBe( + 'application/json; charset=utf-8', + ) + expect(body).toHaveProperty( + 'message', + 'Username must be at least 3 characters long and not more than 40.', + ) + }) + + it('should deny registering a user with username not starting with a letter or number', async () => { + const params = new URLSearchParams({ + name: ' username', + password: 'longenough', + email: 'address@email.com', + }) + const request = new Request(`${BASE_URL}/users/register`, { + method: 'POST', + headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, + body: params.toString(), + }) + + const response = (await registerAction({ + request, + } as ActionFunctionArgs)) as Response + const body = await response.json() + + expect(response.status).toBe(400) + expect(response.headers.get('content-type')).toBe( + 'application/json; charset=utf-8', + ) + expect(body).toHaveProperty( + 'message', + 'Username may only contain alphanumerics (a-zA-Z0-9), dots (.), dashes (-), underscores (_) and spaces, and has to start with either a number or a letter.', + ) + }) + + it('should deny registering a user with username with invalid characters', async () => { + const params = new URLSearchParams({ + name: 'user () name', + password: 'longenough', + email: 'address@email.com', + }) + const request = new Request(`${BASE_URL}/users/register`, { + method: 'POST', + headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, + body: params.toString(), + }) + + const response = (await registerAction({ + request, + } as ActionFunctionArgs)) as Response + const body = await response.json() + + expect(response.status).toBe(400) + expect(response.headers.get('content-type')).toBe( + 'application/json; charset=utf-8', + ) + expect(body).toHaveProperty( + 'message', + 'Username may only contain alphanumerics (a-zA-Z0-9), dots (.), dashes (-), underscores (_) and spaces, and has to start with either a number or a letter.', + ) + }) + + it('should deny registering a too long username', async () => { + const params = new URLSearchParams({ + name: 'Really Long User Name which is definetely too long to be accepted', + password: 'longenough', + email: 'address@email.com', + }) + const request = new Request(`${BASE_URL}/users/register`, { + method: 'POST', + headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, + body: params.toString(), + }) + + const response = (await registerAction({ + request, + } as ActionFunctionArgs)) as Response + const body = await response.json() + + expect(response.status).toBe(400) + expect(response.headers.get('content-type')).toBe( + 'application/json; charset=utf-8', + ) + expect(body).toHaveProperty( + 'message', + 'Username must be at least 3 characters long and not more than 40.', + ) + }) + + it('should allow registering a second user via POST', async () => { + // Arrange + const params = new URLSearchParams() + for (const [key, value] of Object.entries(VALID_SECOND_USER)) + params.append(key, value) + const request = new Request(`${BASE_URL}/users/register`, { + method: 'POST', + headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, + body: params.toString(), + }) + + // Act + const dataFunctionValue = await registerAction({ + request: request, + } as ActionFunctionArgs) + const response = dataFunctionValue as Response + const body = await response?.json() + + // Assert + expect(dataFunctionValue).toBeInstanceOf(Response) + expect(body).toHaveProperty('message', 'Successfully registered new user') + expect(response.status).toBe(201) + expect(response.headers.get('content-type')).toBe( + 'application/json; charset=utf-8', + ) + expect(body).toHaveProperty('token') + expect(body).toHaveProperty('refreshToken') + }) + }) + + afterAll(async () => { + // delete the valid test user + await deleteUserByEmail(VALID_USER.email) + await deleteUserByEmail(VALID_SECOND_USER.email) + }) +}) diff --git a/tests/routes/api.users.request-password-reset.spec.ts b/tests/routes/api.users.request-password-reset.spec.ts index 12952ca1..a4b93ba4 100644 --- a/tests/routes/api.users.request-password-reset.spec.ts +++ b/tests/routes/api.users.request-password-reset.spec.ts @@ -1,50 +1,47 @@ -import { type ActionFunctionArgs } from "react-router"; -import { BASE_URL } from "vitest.setup"; -import { registerUser } from "~/lib/user-service.server"; -import { deleteUserByEmail } from "~/models/user.server"; -import { action } from "~/routes/api.users.request-password-reset"; +import { type ActionFunctionArgs } from 'react-router' +import { generateTestUserCredentials } from 'tests/data/generate_test_user' +import { BASE_URL } from 'vitest.setup' +import { registerUser } from '~/lib/user-service.server' +import { deleteUserByEmail } from '~/models/user.server' +import { action } from '~/routes/api.users.request-password-reset' -const VALID_USER = { - name: "password reset", - email: "password@reset.test", - password: "some super secure password", -}; +const VALID_USER = generateTestUserCredentials() -describe("openSenseMap API Routes: /users", () => { - describe("/request-password-reset", () => { - beforeAll(async () => { - await registerUser( - VALID_USER.name, - VALID_USER.email, - VALID_USER.password, - "en_US", - ); - }); +describe('openSenseMap API Routes: /users', () => { + describe('/request-password-reset', () => { + beforeAll(async () => { + await registerUser( + VALID_USER.name, + VALID_USER.email, + VALID_USER.password, + 'en_US', + ) + }) - describe("POST", () => { - it("should allow to request a password reset token", async () => { - const params = new URLSearchParams(VALID_USER); + describe('POST', () => { + it('should allow to request a password reset token', async () => { + const params = new URLSearchParams(VALID_USER) - const request = new Request( - `${BASE_URL}/users/request-password-reset`, - { - method: "POST", - headers: { "Content-Type": "application/x-www-form-urlencoded" }, - body: params.toString(), - }, - ); + const request = new Request( + `${BASE_URL}/users/request-password-reset`, + { + method: 'POST', + headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, + body: params.toString(), + }, + ) - const response = (await action({ - request, - } as ActionFunctionArgs)) as Response; + const response = (await action({ + request, + } as ActionFunctionArgs)) as Response - expect(response.status).toBe(200); - }); - }); + expect(response.status).toBe(200) + }) + }) - afterAll(async () => { - // delete the valid test user - await deleteUserByEmail(VALID_USER.email); - }); - }); -}); + afterAll(async () => { + // delete the valid test user + await deleteUserByEmail(VALID_USER.email) + }) + }) +})