diff --git a/Cargo.lock b/Cargo.lock index 489ba95..d550d1e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -251,9 +251,21 @@ dependencies = [ "mio", "pin-project-lite", "socket2", + "tokio-macros", "windows-sys", ] +[[package]] +name = "tokio-macros" +version = "2.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6328af13490e73a9b4694030fafd93f8c8c6a9dede33e821c3fc63eddf8042ba" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "tokio-stream" version = "0.1.19" diff --git a/Cargo.toml b/Cargo.toml index 202ea30..49b9c10 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -23,3 +23,6 @@ unwrap_used = "deny" enum_glob_use = { level = "deny", priority = 1 } pedantic = { level = "deny", priority = -1 } nursery = { level = "deny", priority = -1 } + +[dev-dependencies] +tokio = { version = "1.53.1", features = ["rt", "macros", "net"] } diff --git a/tests/e2e.rs b/tests/e2e.rs new file mode 100644 index 0000000..e4a6ab2 --- /dev/null +++ b/tests/e2e.rs @@ -0,0 +1,14 @@ +//! End-to-end tests exercising R701's public API against the emulated +//! device in `mock`. + +#![allow(clippy::unwrap_used)] + +mod mock; + +use r701::R701; + +#[tokio::test] +async fn connects_and_pings_successfully() { + let addr = mock::spawn().await; + assert!(R701::connect(addr).await.is_ok()); +} diff --git a/tests/mock/mod.rs b/tests/mock/mod.rs new file mode 100644 index 0000000..af37759 --- /dev/null +++ b/tests/mock/mod.rs @@ -0,0 +1,144 @@ +//! Emulates an R701 device so `tests/e2e.rs` can exercise the real `R701` +//! client against it, grounded in the protocol documented at +//! . + +use chrono::{Datelike, Local, TimeZone, Timelike}; +use r701::{Clock, Record}; +use std::collections::HashMap; +use tokio::io::{AsyncReadExt, AsyncWriteExt}; +use tokio::net::TcpListener; + +/// The employee names the fake device reports +fn fake_users() -> HashMap { + HashMap::from([(1, "mario".to_string())]) +} + +/// The attendance records the fake device reports +fn fake_records() -> Vec { + vec![Record { + employee_id: 1, + clock: Clock::FirstIn, + datetime: Local + .with_ymd_and_hms(1970, 1, 1, 0, 0, 0) + .single() + .unwrap(), + }] +} + +/// Starts a fake R701 device in the background and returns the address to +/// connect to. +pub async fn spawn() -> String { + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let addr = listener.local_addr().unwrap().to_string(); + + tokio::spawn(async move { + let (mut socket, _) = listener.accept().await.unwrap(); + + loop { + let mut request = [0u8; 16]; + let Ok(bytes_read) = socket.read(&mut request).await else { + return; + }; + if bytes_read == 0 { + return; + } + + let response = if bytes_read < 16 { + vec![0xaa, 0x55, 0x01, 0, 0, 0, 0, 0, 0, 0] + } else if request[..2] != [0x55, 0xaa] { + continue; + } else { + let payload = &request[2..14]; + match (payload[0], payload[1]) { + (0x01, 0x80) => vec![0xaa, 0x55, 0x01, 0x01, 0, 0, 0, 0, 0, 0], + (0x01, 0xc7) => get_name_response(payload), + (0x01, 0xb4) => get_total_record_count_response(), + (0x01, 0xa4) => get_record_bytes_response(payload), + _ => vec![0xaa, 0x55, 0x01, 0, 0, 0, 0, 0, 0, 0], + } + }; + + if socket.write_all(&response).await.is_err() { + return; + } + } + }); + + addr +} + +/// Builds the 34-byte get-name response: a 12-byte header (whether the +/// employee was found), followed by their name (if found) null-padded to +/// 10 bytes, followed by 12 don't-care bytes. +fn get_name_response(payload: &[u8]) -> Vec { + let employee_id = u32::from_le_bytes(payload[2..6].try_into().unwrap()); + let mut response = [0u8; 34]; + + if let Some(name) = fake_users().get(&employee_id) { + response[..12].clone_from_slice(&[0xaa, 0x55, 0x01, 0x01, 0, 0, 0, 0, 0, 0, 0x55, 0xaa]); + let name_bytes = name.as_bytes(); + let len = name_bytes.len().min(10); + response[12..12 + len].clone_from_slice(&name_bytes[..len]); + } else { + response[..12].clone_from_slice(&[0xaa, 0x55, 0x01, 0, 0, 0, 0, 0, 0, 0, 0x55, 0xaa]); + } + + response.to_vec() +} + +/// Builds the 10-byte get-total-record-count response. +#[allow(clippy::cast_possible_truncation)] +fn get_total_record_count_response() -> Vec { + let count = fake_records().len() as u16; + let mut response = [0xaa, 0x55, 0x01, 0x01, 0, 0, 0, 0, 0, 0]; + response[4..6].clone_from_slice(&count.to_le_bytes()); + response.to_vec() +} + +/// Builds the 1038-byte get-record-bytes response: a 12-byte header, the +/// requested 1024-byte block of records (`ff`-padded past the last real +/// one, since records don't align to 1024-byte block boundaries), and 2 +/// trailing bytes. +fn get_record_bytes_response(payload: &[u8]) -> Vec { + let sequence_number = u16::from_le_bytes(payload[8..10].try_into().unwrap()); + let all_bytes: Vec = fake_records().iter().flat_map(encode_record).collect(); + + let start = sequence_number as usize * 1024; + let mut block = [0xffu8; 1024]; + let available = all_bytes.len().saturating_sub(start).min(1024); + block[..available].clone_from_slice(&all_bytes[start..start + available]); + + let mut response = vec![0u8; 1038]; + response[..12].clone_from_slice(&[0xaa, 0x55, 0x01, 0x01, 0, 0, 0, 0, 0, 0, 0x55, 0xaa]); + response[12..1036].clone_from_slice(&block); + response +} + +/// Encodes a [`Record`] back into its 12-byte wire representation (the +/// inverse of `Record`'s `TryFrom<&[u8]>`). The "unknown" bytes that +/// `Record` doesn't decode are left as 0, since they only need to +/// round-trip through our own parsing, not match a real device capture. +fn encode_record(record: &Record) -> [u8; 12] { + let mut bytes = [0u8; 12]; + + let clock_bits: u8 = match record.clock { + Clock::FirstIn => 0, + Clock::FirstOut => 1, + Clock::SecondIn => 2, + Clock::SecondOut => 3, + }; + bytes[1] = clock_bits << 6; + + bytes[4..8].copy_from_slice(&record.employee_id.to_le_bytes()); + + #[allow(clippy::cast_sign_loss)] + let year = record.datetime.year() as u32 & 0x0fff; + let month = record.datetime.month() & 0x0f; + let day = record.datetime.day() & 0x1f; + let hour = record.datetime.hour() & 0x1f; + let minute = record.datetime.minute() & 0x3f; + let packed = year | (month << 12) | (day << 16) | (hour << 21) | (minute << 26); + bytes[8..12].copy_from_slice(&packed.to_le_bytes()); + + bytes +}