diff --git a/backend/run_tests.sh b/backend/run_tests.sh new file mode 100755 index 0000000..0b38b2d --- /dev/null +++ b/backend/run_tests.sh @@ -0,0 +1,3 @@ +#!/bin/sh +rm -f oott.db +cargo test -- --show-output diff --git a/backend/src/db/devices.rs b/backend/src/db/devices.rs index cddb6b4..fbd7d09 100644 --- a/backend/src/db/devices.rs +++ b/backend/src/db/devices.rs @@ -1,11 +1,95 @@ +use chrono::{DateTime, Utc}; use log::{debug, error}; -use rusqlite::params; +use rusqlite::{ToSql, params, params_from_iter}; use crate::{ db::{self, error::DbError}, model::devices::Device, }; +pub fn list_devices( + is_registered: Option, + last_seen_from: Option>, + last_seen_to: Option>, + owner: Option, + device_type: Option, + vendor: Option, +) -> Result, DbError> { + debug!("Listing devices"); + let conn = db::get_db_connection(); + + // Prepare SQL and parameters + let mut sql_statement = "SELECT mac_address, ipv4_address, vendor, last_seen, is_registered, owner, device_type FROM devices WHERE 1=1 ".to_string(); + let mut params: Vec = Vec::new(); + if is_registered.is_some() { + debug!("Adding filter is_registered={}", is_registered.unwrap()); + sql_statement.push_str("AND is_registered=? "); + + params.push(is_registered.unwrap().into()); + }; + if last_seen_from.is_some() { + debug!( + "Adding filter last_seen>={}", + last_seen_from.unwrap().format("%Y-%m-%d %H:%M:%S") + ); + sql_statement.push_str("AND last_seen>=? "); + params.push( + last_seen_from + .unwrap() + .format("%Y-%m-%d %H:%M:%S") + .to_string() + .into(), + ); + }; + if last_seen_to.is_some() { + debug!( + "Adding filter last_seen<={}", + last_seen_to.unwrap().format("%Y-%m-%d %H:%M:%S") + ); + sql_statement.push_str("AND last_seen<=? "); + params.push( + last_seen_to + .unwrap() + .format("%Y-%m-%d %H:%M:%S") + .to_string() + .into(), + ); + }; + if owner.is_some() { + debug!("Adding filter owner={}", owner.clone().unwrap()); + sql_statement.push_str("AND owner=? "); + params.push(owner.unwrap().into()); + }; + if device_type.is_some() { + debug!("Adding filter device_type={}", device_type.clone().unwrap()); + sql_statement.push_str("AND device_type=? "); + params.push(device_type.unwrap().into()); + } + if vendor.is_some() { + debug!("Adding filter vendor={}", vendor.clone().unwrap()); + sql_statement.push_str("AND vendor=? "); + params.push(vendor.unwrap().into()); + } + + let mut stmt = conn.prepare(sql_statement.as_str())?; + + let devices: Vec = stmt + .query_map(params_from_iter(params.iter()), |row| { + Ok(Device { + mac_address: row.get(0)?, + ipv4_address: row.get(1)?, + vendor: row.get(2)?, + last_seen: row.get(3)?, + is_registered: row.get(4)?, + owner: row.get(5)?, + device_type: row.get(6)?, + }) + })? + .collect::>()?; + + Ok(devices) +} + // Read device from its MAC address pub fn read(mac_address: String) -> Option { let conn = db::get_db_connection(); @@ -84,11 +168,218 @@ pub fn update(device: Device) -> Result<(), DbError> { #[cfg(test)] mod tests { - use chrono::{TimeZone, Utc}; + use chrono::{DateTime, TimeZone, Utc}; use super::*; use crate::tests_common; + #[tokio::test] + async fn test_list() { + tests_common::setup().await; + + // List all devices + let devices: Vec = list_devices(None, None, None, None, None, None).unwrap(); + + assert!(devices.len() >= 3, "There should be at least 3 devices"); + // Validate 1 device data + let device = devices + .iter() + .filter(|item| item.mac_address == "bb:bb:bb:bb:bb:bb") + .next() + .unwrap(); + + validate_device( + device.clone(), + "bb:bb:bb:bb:bb:bb".to_string(), + "Phone".to_string(), + "192.168.0.2".to_string(), + true, + Utc.with_ymd_and_hms(2026, 2, 3, 13, 14, 15).unwrap(), + "John".to_string(), + "Vendor 2".to_string(), + ); + + // List registered devices + let devices: Vec = list_devices(Some(true), None, None, None, None, None).unwrap(); + + assert!( + devices.len() >= 2, + "There should be at least 2 registered devices" + ); + // Device aa:aa:aa:aa:aa:aa should not be present + assert!( + devices + .iter() + .filter(|item| item.mac_address == "aa:aa:aa:aa:aa:aa") + .next() + .is_none(), + "Device aa:aa:aa:aa:aa:aa should not be present" + ); + + // All devices should be registered + assert!( + devices + .iter() + .filter(|item| !item.is_registered) + .next() + .is_none(), + "There should not be any non-registered devices" + ); + + // Validate 1 device + let device = devices + .iter() + .filter(|item| item.mac_address == "bb:bb:bb:bb:bb:bb") + .next() + .unwrap(); + + validate_device( + device.clone(), + "bb:bb:bb:bb:bb:bb".to_string(), + "Phone".to_string(), + "192.168.0.2".to_string(), + true, + Utc.with_ymd_and_hms(2026, 2, 3, 13, 14, 15).unwrap(), + "John".to_string(), + "Vendor 2".to_string(), + ); + + // List devices seen in a time period - 2026-02-03 13:14:15 + let devices = list_devices( + None, + Some(Utc.with_ymd_and_hms(2026, 2, 2, 13, 13, 13).unwrap()), + Some(Utc.with_ymd_and_hms(2026, 2, 4, 11, 11, 11).unwrap()), + None, + None, + None, + ) + .unwrap(); + + assert!(devices.len() >= 1, "There should be at least one device"); + assert!( + devices + .iter() + .filter(|item| item.mac_address == "bb:bb:bb:bb:bb:bb") + .next() + .is_some(), + "Device bb:bb:bb:bb:bb:bb should be present" + ); + } + + #[tokio::test] + async fn test_update() { + tests_common::setup().await; + + // Insert a device, update it and validate it + + let last_seen = Utc::now(); + insert(Device::new( + "uu:tt:tt:tt:tt:aa".to_string(), + "192.168.200.1".to_string(), + "Test vendor".to_string(), + last_seen, + )) + .unwrap(); + + let mut device = read("uu:tt:tt:tt:tt:aa".to_string()).unwrap(); + device.is_registered = true; + device.owner = "Grace".to_string(); + device.device_type = "Phone".to_string(); + + update(device).unwrap(); + + let device = read("uu:tt:tt:tt:tt:aa".to_string()).unwrap(); + + validate_device( + device, + "uu:tt:tt:tt:tt:aa".to_string(), + "Phone".to_string(), + "192.168.200.1".to_string(), + true, + last_seen, + "Grace".to_string(), + "Test vendor".to_string(), + ); + + // Insert a device, change its MAC address and validate it hasnt changed (it cannot) + let last_seen = Utc::now(); + insert(Device::new( + "uu:tt:tt:tt:tt:bb".to_string(), + "192.168.200.3".to_string(), + "Test vendor".to_string(), + last_seen, + )) + .unwrap(); + + let mut device = read("uu:tt:tt:tt:tt:bb".to_string()).unwrap(); + device.mac_address = "uu:tt:tt:tt:tt:cc".to_string(); + + update(device).unwrap(); + + assert!( + read("uu:tt:tt:tt:tt:cc".to_string()).is_none(), + "Device should not exist" + ); + + assert!( + read("uu:tt:tt:tt:tt:bb".to_string()).is_some(), + "Device should exist" + ) + } + + #[tokio::test] + async fn test_insert() { + tests_common::setup().await; + + // Insert constructed device and validate it + let last_seen = Utc::now(); + insert(Device::new( + "tt:tt:tt:tt:tt:aa".to_string(), + "192.168.100.1".to_string(), + "Test vendor".to_string(), + last_seen, + )) + .unwrap(); + + let device = read("tt:tt:tt:tt:tt:aa".to_string()).unwrap(); + validate_device( + device, + "tt:tt:tt:tt:tt:aa".to_string(), + "".to_string(), + "192.168.100.1".to_string(), + false, + last_seen, + "".to_string(), + "Test vendor".to_string(), + ); + + // Insert complete device and validate it + let last_seen = Utc::now(); + insert(Device { + mac_address: "tt:tt:tt:tt:tt:bb".to_string(), + device_type: "Server".to_string(), + ipv4_address: "192.168.100.2".to_string(), + is_registered: true, + last_seen: last_seen, + owner: "Carl".to_string(), + vendor: "Vendor X".to_string(), + }) + .unwrap(); + + let device = read("tt:tt:tt:tt:tt:bb".to_string()).unwrap(); + + validate_device( + device, + "tt:tt:tt:tt:tt:bb".to_string(), + "Server".to_string(), + "192.168.100.2".to_string(), + true, + last_seen, + "Carl".to_string(), + "Vendor X".to_string(), + ); + } + #[tokio::test] async fn test_read() { tests_common::setup().await; @@ -101,116 +392,88 @@ mod tests { // Read all testing devices and validate their values // 'aa:aa:aa:aa:aa:aa', '192.168.0.1', 'Vendor 1', '2026-01-01 11:11:11', 0, '', '' let device1 = read("aa:aa:aa:aa:aa:aa".to_string()).unwrap(); - assert_eq!( - device1.mac_address, "aa:aa:aa:aa:aa:aa", - "Invalid MAC address for device aa:aa:aa:aa:aa:aa" - ); - assert_eq!( - device1.device_type, "", - "Device type should be empty for device {}", - device1.mac_address - ); - assert_eq!( - device1.ipv4_address, "192.168.0.1", - "Invalid IP address for device {}", - device1.mac_address - ); - assert!( - !device1.is_registered, - "Device {} should not be registered", - device1.mac_address - ); - assert_eq!( - device1.last_seen, + validate_device( + device1, + "aa:aa:aa:aa:aa:aa".to_string(), + "".to_string(), + "192.168.0.1".to_string(), + false, Utc.with_ymd_and_hms(2026, 1, 1, 11, 11, 11).unwrap(), - "Invalid last_seen for device {}", - device1.mac_address - ); - assert_eq!( - device1.owner, "", - "Invalid owner for device {}", - device1.mac_address - ); - assert_eq!( - device1.vendor, "Vendor 1", - "Invalid vendor for device {}", - device1.mac_address + "".to_string(), + "Vendor 1".to_string(), ); // 'bb:bb:bb:bb:bb:bb', '192.168.0.2', 'Vendor 2', '2026-02-03 13:14:15', 1, 'John', 'Phone' let device2 = read("bb:bb:bb:bb:bb:bb".to_string()).unwrap(); - assert_eq!( - device2.mac_address, "bb:bb:bb:bb:bb:bb", - "Invalid MAC address for device bb:bb:bb:bb:bb:bb" - ); - assert_eq!( - device2.device_type, "Phone", - "Device type should be 'Phone' for device {}", - device2.mac_address - ); - assert_eq!( - device2.ipv4_address, "192.168.0.2", - "Invalid IP address for device {}", - device2.mac_address - ); - assert!( - device2.is_registered, - "Device {} should not be registered", - device2.mac_address - ); - assert_eq!( - device2.last_seen, + validate_device( + device2, + "bb:bb:bb:bb:bb:bb".to_string(), + "Phone".to_string(), + "192.168.0.2".to_string(), + true, Utc.with_ymd_and_hms(2026, 2, 3, 13, 14, 15).unwrap(), - "Invalid last_seen for device {}", - device2.mac_address - ); - assert_eq!( - device2.owner, "John", - "Invalid owner for device {}", - device2.mac_address - ); - assert_eq!( - device2.vendor, "Vendor 2", - "Invalid vendor for device {}", - device2.mac_address + "John".to_string(), + "Vendor 2".to_string(), ); // 'cc:cc:cc:cc:cc:cc', '192.168.0.3', 'Vendor 3', '2026-02-17 20:11:00', 1, 'Sarah', 'Laptop' let device3 = read("cc:cc:cc:cc:cc:cc".to_string()).unwrap(); - assert_eq!( - device3.mac_address, "cc:cc:cc:cc:cc:cc", - "Invalid MAC address for device cc:cc:cc:cc:cc:cc" - ); - assert_eq!( - device3.device_type, "Laptop", - "Device type should be 'Laptop' for device {}", - device3.mac_address - ); - assert_eq!( - device3.ipv4_address, "192.168.0.3", - "Invalid IP address for device {}", - device3.mac_address - ); - assert!( - device3.is_registered, - "Device {} should not be registered", - device3.mac_address - ); - assert_eq!( - device3.last_seen, + validate_device( + device3, + "cc:cc:cc:cc:cc:cc".to_string(), + "Laptop".to_string(), + "192.168.0.3".to_string(), + true, Utc.with_ymd_and_hms(2026, 2, 17, 20, 11, 00).unwrap(), + "Sarah".to_string(), + "Vendor 3".to_string(), + ); + } + + fn validate_device( + device: Device, + mac_address: String, + device_type: String, + ipv4_address: String, + is_registered: bool, + last_seen: DateTime, + owner: String, + vendor: String, + ) { + assert_eq!( + device.mac_address, mac_address, + "Invalid MAC address for device {}", + device.mac_address + ); + assert_eq!( + device.device_type, device_type, + "Device type should be empty for device {}", + device.mac_address + ); + assert_eq!( + device.ipv4_address, ipv4_address, + "Invalid IP address for device {}", + device.mac_address + ); + assert_eq!( + device.is_registered, is_registered, + "Invalid registration status for device {}", + device.mac_address + ); + assert_eq!( + device.last_seen, last_seen, "Invalid last_seen for device {}", - device3.mac_address + device.mac_address ); assert_eq!( - device3.owner, "Sarah", + device.owner, owner, "Invalid owner for device {}", - device3.mac_address + device.mac_address ); assert_eq!( - device3.vendor, "Vendor 3", + device.vendor, vendor, "Invalid vendor for device {}", - device3.mac_address + device.mac_address ); } }