diff --git a/tests/e2e.rs b/tests/e2e.rs index e4a6ab2..2a6ae46 100644 --- a/tests/e2e.rs +++ b/tests/e2e.rs @@ -1,14 +1,62 @@ //! End-to-end tests exercising R701's public API against the emulated //! device in `mock`. -#![allow(clippy::unwrap_used)] - mod mock; use r701::R701; +use tokio_stream::StreamExt; #[tokio::test] async fn connects_and_pings_successfully() { let addr = mock::spawn().await; assert!(R701::connect(addr).await.is_ok()); } + +#[tokio::test] +#[allow(clippy::unwrap_used)] +async fn gets_employee_name() { + let addr = mock::spawn().await; + let r701 = R701::connect(addr).await.unwrap(); + let expected_name = mock::fake_users()[&1].clone(); + + match r701.get_name(1).await.unwrap() { + Some(Ok(name)) => assert_eq!(name, expected_name), + other => panic!("expected Some(Ok({expected_name:?})), got {other:?}"), + } +} + +#[tokio::test] +#[allow(clippy::unwrap_used)] +async fn gets_none_for_unknown_employee() { + let addr = mock::spawn().await; + let r701 = R701::connect(addr).await.unwrap(); + + assert_eq!(r701.get_name(999).await.unwrap(), None); +} + +#[tokio::test] +#[allow(clippy::unwrap_used, clippy::cast_possible_truncation)] +async fn gets_total_record_count() { + let addr = mock::spawn().await; + let r701 = R701::connect(addr).await.unwrap(); + let expected_count = mock::fake_records().len() as u16; + + assert_eq!(r701.get_total_record_count().await.unwrap(), expected_count); +} + +#[tokio::test] +#[allow(clippy::unwrap_used)] +async fn streams_records() { + let addr = mock::spawn().await; + let r701 = R701::connect(addr).await.unwrap(); + // The device streams newest first, the reverse of fake_records' order + let expected_records: Vec<_> = mock::fake_records().into_iter().rev().collect(); + + let mut records = r701.into_record_iter().await.unwrap(); + for expected in expected_records { + let record = records.next().await.unwrap().unwrap(); + assert_eq!(record, expected); + } + + assert!(records.next().await.is_none()); +} diff --git a/tests/mock/mod.rs b/tests/mock/mod.rs index af37759..b55492c 100644 --- a/tests/mock/mod.rs +++ b/tests/mock/mod.rs @@ -2,31 +2,59 @@ //! client against it, grounded in the protocol documented at //! . -use chrono::{Datelike, Local, TimeZone, Timelike}; +use chrono::{Datelike, Duration, 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())]) +pub fn fake_users() -> HashMap { + HashMap::from([(1, "alice".to_string()), (2, "bob".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(), - }] +/// The attendance records the fake device reports: a full year of classic +/// workday punches (09:00 - 13:00, 14:00 - 18:00) for the employees in +/// `fake_users()`, staggered by punch time rather than grouped by employee. +#[allow(clippy::unwrap_used)] +pub fn fake_records() -> Vec { + let first_day = Local + .with_ymd_and_hms(2026, 1, 1, 9, 0, 0) + .single() + .unwrap(); + + let mut employee_ids: Vec = fake_users().into_keys().collect(); + employee_ids.sort_unstable(); + + let mut records = Vec::new(); + for day in 0..365 { + let morning_in = first_day + Duration::days(day); + let morning_out = morning_in + Duration::hours(4); + let afternoon_in = morning_out + Duration::hours(1); + let afternoon_out = afternoon_in + Duration::hours(4); + + for (clock, datetime) in [ + (Clock::FirstIn, morning_in), + (Clock::FirstOut, morning_out), + (Clock::SecondIn, afternoon_in), + (Clock::SecondOut, afternoon_out), + ] { + for &employee_id in &employee_ids { + records.push(Record { + employee_id, + clock, + datetime, + }); + } + } + } + + records } /// Starts a fake R701 device in the background and returns the address to /// connect to. +#[allow(clippy::unwrap_used)] pub async fn spawn() -> String { let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); let addr = listener.local_addr().unwrap().to_string(); @@ -70,6 +98,7 @@ pub async fn spawn() -> String { /// 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. +#[allow(clippy::unwrap_used)] 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]; @@ -99,6 +128,7 @@ fn get_total_record_count_response() -> Vec { /// 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. +#[allow(clippy::unwrap_used)] 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();