commit
bc5fbd6ba9
180 files changed
+63750
No files matched your search
@@ -0,0 +1,832 @@
|
||||
use super::*;
|
||||
use std::sync::Mutex;
|
||||
|
||||
/// A mock IMAP client that returns pre-loaded data.
|
||||
///
|
||||
/// Uses interior mutability (`Mutex`) so it can be shared behind `&self`.
|
||||
/// Build it up with the `.with_*()` builder methods, then pass it wherever
|
||||
/// an `&dyn ImapClient` is needed.
|
||||
pub struct MockImapClient {
|
||||
folders: Mutex<Vec<ImapFolder>>,
|
||||
headers: Mutex<Vec<ImapMessageHeader>>,
|
||||
bodies: Mutex<Vec<ImapMessageBody>>,
|
||||
folder_status: Mutex<Option<FolderStatus>>,
|
||||
folder_status_ext: Mutex<Option<FolderStatusExtended>>,
|
||||
should_fail: Mutex<Option<ImapError>>,
|
||||
pub appended: Mutex<Vec<(String, Vec<u8>)>>,
|
||||
pub header_requests: Mutex<Vec<(String, String)>>,
|
||||
/// One-shot gate after taking a header snapshot, before returning it.
|
||||
pub header_fetch_gate: Option<(tokio::sync::Notify, tokio::sync::Notify)>,
|
||||
}
|
||||
|
||||
impl MockImapClient {
|
||||
/// Create a new empty mock.
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
folders: Mutex::new(Vec::new()),
|
||||
headers: Mutex::new(Vec::new()),
|
||||
bodies: Mutex::new(Vec::new()),
|
||||
folder_status: Mutex::new(None),
|
||||
folder_status_ext: Mutex::new(None),
|
||||
should_fail: Mutex::new(None),
|
||||
appended: Mutex::new(Vec::new()),
|
||||
header_requests: Mutex::new(Vec::new()),
|
||||
header_fetch_gate: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Pre-load folders that `list_folders` will return.
|
||||
pub fn with_folders(self, folders: Vec<ImapFolder>) -> Self {
|
||||
*self.folders.lock().unwrap() = folders;
|
||||
self
|
||||
}
|
||||
|
||||
/// Pre-load message headers that `fetch_headers` will return.
|
||||
pub fn with_headers(self, headers: Vec<ImapMessageHeader>) -> Self {
|
||||
*self.headers.lock().unwrap() = headers;
|
||||
self
|
||||
}
|
||||
|
||||
/// Pre-load a folder status that `folder_status` will return.
|
||||
#[allow(dead_code)]
|
||||
pub fn with_folder_status(self, status: FolderStatus) -> Self {
|
||||
*self.folder_status.lock().unwrap() = Some(status);
|
||||
self
|
||||
}
|
||||
|
||||
/// Pre-load an extended folder status that `folder_status_extended` will return.
|
||||
#[allow(dead_code)]
|
||||
pub fn with_folder_status_extended(self, status: FolderStatusExtended) -> Self {
|
||||
*self.folder_status_ext.lock().unwrap() = Some(status);
|
||||
self
|
||||
}
|
||||
|
||||
/// Pre-load message bodies that `fetch_body` will match against by UID.
|
||||
pub fn with_bodies(self, bodies: Vec<ImapMessageBody>) -> Self {
|
||||
*self.bodies.lock().unwrap() = bodies;
|
||||
self
|
||||
}
|
||||
|
||||
/// Make every subsequent call return this error.
|
||||
pub fn with_error(self, error: ImapError) -> Self {
|
||||
*self.should_fail.lock().unwrap() = Some(error);
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
/// Helper to clone an `ImapError` for the mock (the real errors are not
|
||||
/// `Clone`, so we reconstruct them).
|
||||
fn clone_error(err: &ImapError) -> ImapError {
|
||||
match err {
|
||||
ImapError::ConnectionFailed(msg) => ImapError::ConnectionFailed(msg.clone()),
|
||||
ImapError::AuthenticationFailed => ImapError::AuthenticationFailed,
|
||||
ImapError::FolderNotFound(name) => ImapError::FolderNotFound(name.clone()),
|
||||
ImapError::MessageNotFound { uid, folder } => ImapError::MessageNotFound {
|
||||
uid: *uid,
|
||||
folder: folder.clone(),
|
||||
},
|
||||
ImapError::ProtocolError(msg) => ImapError::ProtocolError(msg.clone()),
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl ImapClient for MockImapClient {
|
||||
async fn capabilities(&self, _creds: &ImapCredentials) -> Result<Vec<String>, ImapError> {
|
||||
if let Some(ref err) = *self.should_fail.lock().unwrap() {
|
||||
return Err(clone_error(err));
|
||||
}
|
||||
Ok(vec![
|
||||
"IMAP4REV1".to_string(),
|
||||
"IDLE".to_string(),
|
||||
"MOVE".to_string(),
|
||||
"UIDPLUS".to_string(),
|
||||
])
|
||||
}
|
||||
|
||||
async fn list_folders(&self, _creds: &ImapCredentials) -> Result<Vec<ImapFolder>, ImapError> {
|
||||
if let Some(ref err) = *self.should_fail.lock().unwrap() {
|
||||
return Err(clone_error(err));
|
||||
}
|
||||
Ok(self.folders.lock().unwrap().clone())
|
||||
}
|
||||
|
||||
async fn folder_status(
|
||||
&self,
|
||||
_creds: &ImapCredentials,
|
||||
_folder: &str,
|
||||
) -> Result<FolderStatus, ImapError> {
|
||||
{
|
||||
let fail = self.should_fail.lock().unwrap();
|
||||
if let Some(ref err) = *fail {
|
||||
return Err(clone_error(err));
|
||||
}
|
||||
}
|
||||
{
|
||||
let status = self.folder_status.lock().unwrap();
|
||||
if let Some(ref s) = *status {
|
||||
return Ok(s.clone());
|
||||
}
|
||||
}
|
||||
// Derive from headers (separate lock scope).
|
||||
let headers = self.headers.lock().unwrap();
|
||||
let exists = headers.len() as u32;
|
||||
let uid_next = headers.iter().map(|h| h.uid).max().unwrap_or(0) + 1;
|
||||
Ok(FolderStatus {
|
||||
uid_validity: 1,
|
||||
exists,
|
||||
uid_next,
|
||||
})
|
||||
}
|
||||
|
||||
async fn fetch_headers(
|
||||
&self,
|
||||
_creds: &ImapCredentials,
|
||||
folder: &str,
|
||||
uid_range: &str,
|
||||
) -> Result<Vec<ImapMessageHeader>, ImapError> {
|
||||
self.header_requests
|
||||
.lock()
|
||||
.unwrap()
|
||||
.push((folder.to_owned(), uid_range.to_owned()));
|
||||
if let Some(ref err) = *self.should_fail.lock().unwrap() {
|
||||
return Err(clone_error(err));
|
||||
}
|
||||
let headers = self.headers.lock().unwrap().clone();
|
||||
if let Some((fetched, resume)) = &self.header_fetch_gate {
|
||||
fetched.notify_one();
|
||||
resume.notified().await;
|
||||
}
|
||||
Ok(headers)
|
||||
}
|
||||
|
||||
async fn fetch_body(
|
||||
&self,
|
||||
_creds: &ImapCredentials,
|
||||
_folder: &str,
|
||||
uid: u32,
|
||||
) -> Result<ImapMessageBody, ImapError> {
|
||||
if let Some(ref err) = *self.should_fail.lock().unwrap() {
|
||||
return Err(clone_error(err));
|
||||
}
|
||||
let bodies = self.bodies.lock().unwrap();
|
||||
bodies
|
||||
.iter()
|
||||
.find(|b| b.uid == uid)
|
||||
.cloned()
|
||||
.ok_or_else(|| ImapError::MessageNotFound {
|
||||
uid,
|
||||
folder: _folder.to_string(),
|
||||
})
|
||||
}
|
||||
|
||||
async fn add_flags(
|
||||
&self,
|
||||
_creds: &ImapCredentials,
|
||||
_folder: &str,
|
||||
_uid: u32,
|
||||
_flags: &[&str],
|
||||
) -> Result<(), ImapError> {
|
||||
if let Some(ref err) = *self.should_fail.lock().unwrap() {
|
||||
return Err(clone_error(err));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn remove_flags(
|
||||
&self,
|
||||
_creds: &ImapCredentials,
|
||||
_folder: &str,
|
||||
_uid: u32,
|
||||
_flags: &[&str],
|
||||
) -> Result<(), ImapError> {
|
||||
if let Some(ref err) = *self.should_fail.lock().unwrap() {
|
||||
return Err(clone_error(err));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn set_flags(
|
||||
&self,
|
||||
_creds: &ImapCredentials,
|
||||
_folder: &str,
|
||||
_uid: u32,
|
||||
_flags: &[&str],
|
||||
) -> Result<(), ImapError> {
|
||||
if let Some(ref err) = *self.should_fail.lock().unwrap() {
|
||||
return Err(clone_error(err));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn move_message(
|
||||
&self,
|
||||
_creds: &ImapCredentials,
|
||||
_from_folder: &str,
|
||||
_uid: u32,
|
||||
_to_folder: &str,
|
||||
) -> Result<(), ImapError> {
|
||||
if let Some(ref err) = *self.should_fail.lock().unwrap() {
|
||||
return Err(clone_error(err));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn copy_message(
|
||||
&self,
|
||||
_creds: &ImapCredentials,
|
||||
_from_folder: &str,
|
||||
_uid: u32,
|
||||
_to_folder: &str,
|
||||
) -> Result<(), ImapError> {
|
||||
if let Some(ref err) = *self.should_fail.lock().unwrap() {
|
||||
return Err(clone_error(err));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn mark_all_read(
|
||||
&self,
|
||||
_creds: &ImapCredentials,
|
||||
_folder: &str,
|
||||
) -> Result<(), ImapError> {
|
||||
if let Some(ref err) = *self.should_fail.lock().unwrap() {
|
||||
return Err(clone_error(err));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn empty_folder(
|
||||
&self,
|
||||
_creds: &ImapCredentials,
|
||||
_folder: &str,
|
||||
) -> Result<(), ImapError> {
|
||||
if let Some(ref err) = *self.should_fail.lock().unwrap() {
|
||||
return Err(clone_error(err));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn expunge_message(
|
||||
&self,
|
||||
_creds: &ImapCredentials,
|
||||
_folder: &str,
|
||||
_uid: u32,
|
||||
) -> Result<(), ImapError> {
|
||||
if let Some(ref err) = *self.should_fail.lock().unwrap() {
|
||||
return Err(clone_error(err));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn append_message(
|
||||
&self,
|
||||
_creds: &ImapCredentials,
|
||||
folder: &str,
|
||||
message_bytes: &[u8],
|
||||
_flags: &[&str],
|
||||
) -> Result<(), ImapError> {
|
||||
if let Some(ref err) = *self.should_fail.lock().unwrap() {
|
||||
return Err(clone_error(err));
|
||||
}
|
||||
self.appended
|
||||
.lock()
|
||||
.unwrap()
|
||||
.push((folder.to_string(), message_bytes.to_vec()));
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn append_message_resolved_uid(
|
||||
&self,
|
||||
creds: &ImapCredentials,
|
||||
folder: &str,
|
||||
message_bytes: &[u8],
|
||||
_flags: &[&str],
|
||||
_identity_header: &str,
|
||||
_identity_value: &str,
|
||||
) -> Result<(u32, u32), ImapError> {
|
||||
if let Some(ref err) = *self.should_fail.lock().unwrap() {
|
||||
return Err(clone_error(err));
|
||||
}
|
||||
let status = self.folder_status(creds, folder).await?;
|
||||
self.appended
|
||||
.lock()
|
||||
.unwrap()
|
||||
.push((folder.to_string(), message_bytes.to_vec()));
|
||||
Ok((status.uid_next.max(1), status.uid_validity))
|
||||
}
|
||||
|
||||
async fn create_folder(
|
||||
&self,
|
||||
_creds: &ImapCredentials,
|
||||
_folder_name: &str,
|
||||
) -> Result<(), ImapError> {
|
||||
if let Some(ref err) = *self.should_fail.lock().unwrap() {
|
||||
return Err(clone_error(err));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn rename_folder(
|
||||
&self,
|
||||
_creds: &ImapCredentials,
|
||||
_from: &str,
|
||||
_to: &str,
|
||||
) -> Result<(), ImapError> {
|
||||
if let Some(ref err) = *self.should_fail.lock().unwrap() {
|
||||
return Err(clone_error(err));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn delete_folder(
|
||||
&self,
|
||||
_creds: &ImapCredentials,
|
||||
_folder_name: &str,
|
||||
) -> Result<(), ImapError> {
|
||||
if let Some(ref err) = *self.should_fail.lock().unwrap() {
|
||||
return Err(clone_error(err));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn subscribe_folder(
|
||||
&self,
|
||||
_creds: &ImapCredentials,
|
||||
_folder_name: &str,
|
||||
_subscribe: bool,
|
||||
) -> Result<(), ImapError> {
|
||||
if let Some(ref err) = *self.should_fail.lock().unwrap() {
|
||||
return Err(clone_error(err));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn fetch_uids_and_flags(
|
||||
&self,
|
||||
_creds: &ImapCredentials,
|
||||
_folder: &str,
|
||||
) -> Result<Vec<(u32, Vec<String>)>, ImapError> {
|
||||
if let Some(ref err) = *self.should_fail.lock().unwrap() {
|
||||
return Err(clone_error(err));
|
||||
}
|
||||
let headers = self.headers.lock().unwrap();
|
||||
Ok(headers.iter().map(|h| (h.uid, h.flags.clone())).collect())
|
||||
}
|
||||
|
||||
async fn folder_status_extended(
|
||||
&self,
|
||||
_creds: &ImapCredentials,
|
||||
_folder: &str,
|
||||
) -> Result<FolderStatusExtended, ImapError> {
|
||||
{
|
||||
let fail = self.should_fail.lock().unwrap();
|
||||
if let Some(ref err) = *fail {
|
||||
return Err(clone_error(err));
|
||||
}
|
||||
}
|
||||
{
|
||||
let status = self.folder_status_ext.lock().unwrap();
|
||||
if let Some(ref s) = *status {
|
||||
return Ok(s.clone());
|
||||
}
|
||||
}
|
||||
// Derive from headers.
|
||||
let headers = self.headers.lock().unwrap();
|
||||
let exists = headers.len() as u32;
|
||||
let uid_next = headers.iter().map(|h| h.uid).max().unwrap_or(0) + 1;
|
||||
Ok(FolderStatusExtended {
|
||||
uid_validity: 1,
|
||||
exists,
|
||||
uid_next,
|
||||
unseen: 0,
|
||||
highest_modseq: 0,
|
||||
})
|
||||
}
|
||||
|
||||
async fn fetch_changed_flags(
|
||||
&self,
|
||||
_creds: &ImapCredentials,
|
||||
_folder: &str,
|
||||
_since_modseq: u64,
|
||||
) -> Result<(Vec<(u32, Vec<String>)>, u64), ImapError> {
|
||||
if let Some(ref err) = *self.should_fail.lock().unwrap() {
|
||||
return Err(clone_error(err));
|
||||
}
|
||||
// In mock, return all headers as "changed" with modseq 0.
|
||||
let headers = self.headers.lock().unwrap();
|
||||
let items: Vec<(u32, Vec<String>)> =
|
||||
headers.iter().map(|h| (h.uid, h.flags.clone())).collect();
|
||||
Ok((items, 0))
|
||||
}
|
||||
|
||||
async fn get_quota(&self, _creds: &ImapCredentials) -> Result<Option<MailboxQuota>, ImapError> {
|
||||
if let Some(ref err) = *self.should_fail.lock().unwrap() {
|
||||
return Err(clone_error(err));
|
||||
}
|
||||
Ok(None)
|
||||
}
|
||||
|
||||
async fn fetch_folder_size(
|
||||
&self,
|
||||
_creds: &ImapCredentials,
|
||||
_folder: &str,
|
||||
) -> Result<u64, ImapError> {
|
||||
if let Some(ref err) = *self.should_fail.lock().unwrap() {
|
||||
return Err(clone_error(err));
|
||||
}
|
||||
Ok(0)
|
||||
}
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Tests
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// Convenience helper to build dummy credentials for tests.
|
||||
fn test_creds() -> ImapCredentials {
|
||||
ImapCredentials {
|
||||
host: "imap.example.com".to_string(),
|
||||
port: 993,
|
||||
tls: true,
|
||||
email: "user@example.com".to_string(),
|
||||
password: "hunter2".to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn mock_list_folders_returns_preloaded_data() {
|
||||
let mock = MockImapClient::new().with_folders(vec![
|
||||
ImapFolder {
|
||||
name: "INBOX".to_string(),
|
||||
delimiter: Some("/".to_string()),
|
||||
attributes: vec!["\\HasNoChildren".to_string()],
|
||||
},
|
||||
ImapFolder {
|
||||
name: "Sent".to_string(),
|
||||
delimiter: Some("/".to_string()),
|
||||
attributes: vec![],
|
||||
},
|
||||
]);
|
||||
|
||||
let folders = mock.list_folders(&test_creds()).await.unwrap();
|
||||
assert_eq!(folders.len(), 2);
|
||||
assert_eq!(folders[0].name, "INBOX");
|
||||
assert_eq!(folders[1].name, "Sent");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn mock_fetch_headers_returns_preloaded_data() {
|
||||
let mock = MockImapClient::new().with_headers(vec![ImapMessageHeader {
|
||||
uid: 42,
|
||||
subject: Some("Hello".to_string()),
|
||||
from: vec![EmailAddress {
|
||||
name: Some("Alice".to_string()),
|
||||
address: "alice@example.com".to_string(),
|
||||
}],
|
||||
to: vec![EmailAddress {
|
||||
name: None,
|
||||
address: "bob@example.com".to_string(),
|
||||
}],
|
||||
date: Some("Mon, 1 Jan 2024 00:00:00 +0000".to_string()),
|
||||
flags: vec!["\\Seen".to_string()],
|
||||
has_attachments: false,
|
||||
size: 1024,
|
||||
message_id: None,
|
||||
in_reply_to: None,
|
||||
references: None,
|
||||
cc: vec![],
|
||||
reaction: None,
|
||||
}]);
|
||||
|
||||
let headers = mock
|
||||
.fetch_headers(&test_creds(), "INBOX", "1:*")
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(headers.len(), 1);
|
||||
assert_eq!(headers[0].uid, 42);
|
||||
assert_eq!(headers[0].subject.as_deref(), Some("Hello"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn mock_fetch_body_returns_matching_uid() {
|
||||
let mock = MockImapClient::new().with_bodies(vec![
|
||||
ImapMessageBody {
|
||||
uid: 1,
|
||||
text_plain: Some("First message".to_string()),
|
||||
text_html: None,
|
||||
attachments: vec![],
|
||||
raw_headers: String::new(),
|
||||
},
|
||||
ImapMessageBody {
|
||||
uid: 2,
|
||||
text_plain: None,
|
||||
text_html: Some("<p>Second</p>".to_string()),
|
||||
attachments: vec![ImapAttachment {
|
||||
filename: Some("doc.pdf".to_string()),
|
||||
content_type: "application/pdf".to_string(),
|
||||
size: 1024,
|
||||
data: vec![0u8; 1024],
|
||||
content_id: None,
|
||||
}],
|
||||
raw_headers: String::new(),
|
||||
},
|
||||
]);
|
||||
|
||||
let body = mock.fetch_body(&test_creds(), "INBOX", 2).await.unwrap();
|
||||
assert_eq!(body.uid, 2);
|
||||
assert!(body.text_html.is_some());
|
||||
assert_eq!(body.attachments.len(), 1);
|
||||
assert_eq!(body.attachments[0].filename.as_deref(), Some("doc.pdf"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn mock_fetch_body_returns_not_found_for_missing_uid() {
|
||||
let mock = MockImapClient::new().with_bodies(vec![ImapMessageBody {
|
||||
uid: 1,
|
||||
text_plain: Some("only message".to_string()),
|
||||
text_html: None,
|
||||
attachments: vec![],
|
||||
raw_headers: String::new(),
|
||||
}]);
|
||||
|
||||
let err = mock
|
||||
.fetch_body(&test_creds(), "INBOX", 999)
|
||||
.await
|
||||
.unwrap_err();
|
||||
match err {
|
||||
ImapError::MessageNotFound { uid, folder } => {
|
||||
assert_eq!(uid, 999);
|
||||
assert_eq!(folder, "INBOX");
|
||||
}
|
||||
other => panic!("Expected MessageNotFound, got: {other}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn mock_with_error_overrides_all_methods() {
|
||||
let mock = MockImapClient::new()
|
||||
.with_folders(vec![ImapFolder {
|
||||
name: "INBOX".to_string(),
|
||||
delimiter: None,
|
||||
attributes: vec![],
|
||||
}])
|
||||
.with_error(ImapError::AuthenticationFailed);
|
||||
|
||||
let err = mock.list_folders(&test_creds()).await.unwrap_err();
|
||||
assert!(matches!(err, ImapError::AuthenticationFailed));
|
||||
|
||||
let err = mock
|
||||
.fetch_headers(&test_creds(), "INBOX", "1:*")
|
||||
.await
|
||||
.unwrap_err();
|
||||
assert!(matches!(err, ImapError::AuthenticationFailed));
|
||||
|
||||
let err = mock
|
||||
.set_flags(&test_creds(), "INBOX", 1, &["\\Seen"])
|
||||
.await
|
||||
.unwrap_err();
|
||||
assert!(matches!(err, ImapError::AuthenticationFailed));
|
||||
|
||||
let err = mock
|
||||
.move_message(&test_creds(), "INBOX", 1, "Trash")
|
||||
.await
|
||||
.unwrap_err();
|
||||
assert!(matches!(err, ImapError::AuthenticationFailed));
|
||||
|
||||
let err = mock
|
||||
.expunge_message(&test_creds(), "INBOX", 1)
|
||||
.await
|
||||
.unwrap_err();
|
||||
assert!(matches!(err, ImapError::AuthenticationFailed));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn mock_set_flags_succeeds_without_error() {
|
||||
let mock = MockImapClient::new();
|
||||
let result = mock
|
||||
.set_flags(&test_creds(), "INBOX", 1, &["\\Seen", "\\Flagged"])
|
||||
.await;
|
||||
assert!(result.is_ok());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn mock_move_message_succeeds_without_error() {
|
||||
let mock = MockImapClient::new();
|
||||
let result = mock
|
||||
.move_message(&test_creds(), "INBOX", 1, "Archive")
|
||||
.await;
|
||||
assert!(result.is_ok());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn mock_expunge_message_succeeds_without_error() {
|
||||
let mock = MockImapClient::new();
|
||||
let result = mock.expunge_message(&test_creds(), "Trash", 5).await;
|
||||
assert!(result.is_ok());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn real_imap_client_connection_fails_with_bad_host() {
|
||||
let client = RealImapClient::default();
|
||||
let creds = test_creds();
|
||||
|
||||
let err = client.list_folders(&creds).await.unwrap_err();
|
||||
// With a fake host the connection should fail.
|
||||
assert!(
|
||||
matches!(err, ImapError::ConnectionFailed(_)),
|
||||
"Expected ConnectionFailed, got: {err}"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn imap_error_display_formats_correctly() {
|
||||
let cases: Vec<(ImapError, &str)> = vec![
|
||||
(
|
||||
ImapError::ConnectionFailed("timeout".to_string()),
|
||||
"Connection failed: timeout",
|
||||
),
|
||||
(ImapError::AuthenticationFailed, "Authentication failed"),
|
||||
(
|
||||
ImapError::FolderNotFound("Drafts".to_string()),
|
||||
"Folder not found: Drafts",
|
||||
),
|
||||
(
|
||||
ImapError::MessageNotFound {
|
||||
uid: 7,
|
||||
folder: "INBOX".to_string(),
|
||||
},
|
||||
"Message UID 7 not found in folder INBOX",
|
||||
),
|
||||
(
|
||||
ImapError::ProtocolError("unexpected EOF".to_string()),
|
||||
"Protocol error: unexpected EOF",
|
||||
),
|
||||
];
|
||||
|
||||
for (err, expected) in cases {
|
||||
assert_eq!(err.to_string(), expected);
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn email_address_serializes_and_deserializes() {
|
||||
let addr = EmailAddress {
|
||||
name: Some("Test User".to_string()),
|
||||
address: "test@example.com".to_string(),
|
||||
};
|
||||
|
||||
let json = serde_json::to_string(&addr).unwrap();
|
||||
let deserialized: EmailAddress = serde_json::from_str(&json).unwrap();
|
||||
assert_eq!(addr, deserialized);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn mock_append_message_succeeds() {
|
||||
let mock = MockImapClient::new();
|
||||
let result = mock
|
||||
.append_message(
|
||||
&test_creds(),
|
||||
"Sent",
|
||||
b"From: test\r\n\r\nBody",
|
||||
&["\\Seen"],
|
||||
)
|
||||
.await;
|
||||
assert!(result.is_ok());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn mock_append_captures_data() {
|
||||
let mock = MockImapClient::new();
|
||||
mock.append_message(&test_creds(), "Sent", b"test message", &["\\Seen"])
|
||||
.await
|
||||
.unwrap();
|
||||
let appended = mock.appended.lock().unwrap();
|
||||
assert_eq!(appended.len(), 1);
|
||||
assert_eq!(appended[0].0, "Sent");
|
||||
assert_eq!(appended[0].1, b"test message");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn mock_empty_returns_empty_collections() {
|
||||
let mock = MockImapClient::new();
|
||||
let creds = test_creds();
|
||||
|
||||
let folders = mock.list_folders(&creds).await.unwrap();
|
||||
assert!(folders.is_empty());
|
||||
|
||||
let headers = mock.fetch_headers(&creds, "INBOX", "1:*").await.unwrap();
|
||||
assert!(headers.is_empty());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn mock_create_folder_succeeds() {
|
||||
let mock = MockImapClient::new();
|
||||
let result = mock.create_folder(&test_creds(), "NewFolder").await;
|
||||
assert!(result.is_ok());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn mock_rename_folder_succeeds() {
|
||||
let mock = MockImapClient::new();
|
||||
let result = mock
|
||||
.rename_folder(&test_creds(), "OldName", "NewName")
|
||||
.await;
|
||||
assert!(result.is_ok());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn mock_delete_folder_succeeds() {
|
||||
let mock = MockImapClient::new();
|
||||
let result = mock.delete_folder(&test_creds(), "OldFolder").await;
|
||||
assert!(result.is_ok());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn mock_subscribe_folder_succeeds() {
|
||||
let mock = MockImapClient::new();
|
||||
let result = mock
|
||||
.subscribe_folder(&test_creds(), "SomeFolder", true)
|
||||
.await;
|
||||
assert!(result.is_ok());
|
||||
|
||||
let result = mock
|
||||
.subscribe_folder(&test_creds(), "SomeFolder", false)
|
||||
.await;
|
||||
assert!(result.is_ok());
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------
|
||||
// Integration tests (run manually against a real IMAP server)
|
||||
// -------------------------------------------------------------------
|
||||
//
|
||||
// cargo test real_imap -- --ignored
|
||||
//
|
||||
// Required env vars:
|
||||
// TEST_IMAP_HOST (e.g. "imap.gmail.com")
|
||||
// TEST_IMAP_PORT (e.g. "993")
|
||||
// TEST_IMAP_EMAIL (e.g. "you@gmail.com")
|
||||
// TEST_IMAP_PASSWORD (e.g. "app-password")
|
||||
// TEST_IMAP_TLS (e.g. "true")
|
||||
|
||||
fn real_creds() -> Option<ImapCredentials> {
|
||||
let host = std::env::var("TEST_IMAP_HOST").ok()?;
|
||||
let port: u16 = std::env::var("TEST_IMAP_PORT").ok()?.parse().ok()?;
|
||||
let email = std::env::var("TEST_IMAP_EMAIL").ok()?;
|
||||
let password = std::env::var("TEST_IMAP_PASSWORD").ok()?;
|
||||
let tls = std::env::var("TEST_IMAP_TLS")
|
||||
.unwrap_or_else(|_| "true".to_string())
|
||||
.parse()
|
||||
.unwrap_or(true);
|
||||
Some(ImapCredentials {
|
||||
host,
|
||||
port,
|
||||
tls,
|
||||
email,
|
||||
password,
|
||||
})
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[ignore] // Run manually: cargo test real_imap_list_folders -- --ignored
|
||||
async fn real_imap_list_folders() {
|
||||
let creds = real_creds().expect("TEST_IMAP_* env vars required");
|
||||
let client = RealImapClient::default();
|
||||
let folders = client.list_folders(&creds).await.unwrap();
|
||||
assert!(!folders.is_empty(), "expected at least one folder");
|
||||
let names: Vec<_> = folders.iter().map(|f| f.name.as_str()).collect();
|
||||
assert!(
|
||||
names.iter().any(|n| n.eq_ignore_ascii_case("INBOX")),
|
||||
"expected INBOX in folder list, got: {names:?}"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[ignore] // Run manually: cargo test real_imap_fetch_headers -- --ignored
|
||||
async fn real_imap_fetch_headers() {
|
||||
let creds = real_creds().expect("TEST_IMAP_* env vars required");
|
||||
let client = RealImapClient::default();
|
||||
let headers = client.fetch_headers(&creds, "INBOX", "1:5").await.unwrap();
|
||||
// The mailbox might be empty, so we just check it doesn't error.
|
||||
for h in &headers {
|
||||
assert!(h.uid > 0);
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[ignore] // Run manually: cargo test real_imap_fetch_body -- --ignored
|
||||
async fn real_imap_fetch_body() {
|
||||
let creds = real_creds().expect("TEST_IMAP_* env vars required");
|
||||
let client = RealImapClient::default();
|
||||
|
||||
// First fetch headers to find a UID.
|
||||
let headers = client.fetch_headers(&creds, "INBOX", "1:1").await.unwrap();
|
||||
if let Some(h) = headers.first() {
|
||||
let body = client.fetch_body(&creds, "INBOX", h.uid).await.unwrap();
|
||||
assert_eq!(body.uid, h.uid);
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user