feat(contacts): build a real CardDAV engine, replacing the fabricated client #217
No files matched your search
Generated
+2
@@ -2513,6 +2513,7 @@ dependencies = [
|
||||
"thiserror 1.0.69",
|
||||
"tokio",
|
||||
"tracing",
|
||||
"wiremock",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -2611,6 +2612,7 @@ dependencies = [
|
||||
"hex",
|
||||
"hmac",
|
||||
"keyring",
|
||||
"nuncio-contacts",
|
||||
"nuncio-core",
|
||||
"nuncio-filter",
|
||||
"serde",
|
||||
|
||||
@@ -4,6 +4,10 @@ version.workspace = true
|
||||
edition.workspace = true
|
||||
authors.workspace = true
|
||||
license.workspace = true
|
||||
repository.workspace = true
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
|
||||
[dependencies]
|
||||
nuncio-core = { path = "../nuncio-core" }
|
||||
@@ -21,3 +25,4 @@ async-trait = { workspace = true }
|
||||
|
||||
[dev-dependencies]
|
||||
tempfile = { workspace = true }
|
||||
wiremock = { workspace = true }
|
||||
@@ -0,0 +1,18 @@
|
||||
//! Protocol-agnostic async contacts backend trait definitions.
|
||||
|
||||
use async_trait::async_trait;
|
||||
|
||||
use crate::carddav::CardDavError;
|
||||
use crate::models::Contact;
|
||||
|
||||
/// Protocol-agnostic contacts backend engine trait implemented by CardDAV and mock engines.
|
||||
///
|
||||
/// Mirrors `nuncio_cal::CalendarBackend`'s shape: `Send + Sync` so implementations can be held
|
||||
/// behind an `Arc` and shared across daemon tasks, and returning the crate's single
|
||||
/// [`CardDavError`] type so callers do not need to know whether a given implementation talks
|
||||
/// to a real server or an in-memory fixture.
|
||||
#[async_trait]
|
||||
pub trait ContactsBackend: Send + Sync {
|
||||
/// Fetch contacts belonging to `account_id`.
|
||||
async fn fetch_contacts(&self, account_id: &str) -> Result<Vec<Contact>, CardDavError>;
|
||||
}
|
||||
@@ -1,35 +1,56 @@
|
||||
use crate::models::Contact;
|
||||
use reqwest::Client;
|
||||
//! CardDAV (RFC 6352) `REPORT` client and multistatus response parser.
|
||||
|
||||
use async_trait::async_trait;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use thiserror::Error;
|
||||
use tracing::info;
|
||||
|
||||
#[derive(Error, Debug)]
|
||||
use crate::backend::ContactsBackend;
|
||||
use crate::models::Contact;
|
||||
use crate::parser::VCardParserAdapter;
|
||||
|
||||
/// Errors returned by the CardDAV client engine.
|
||||
#[derive(Error, Debug, PartialEq, Eq)]
|
||||
pub enum CardDavError {
|
||||
#[error("HTTP error: {0}")]
|
||||
Http(#[from] reqwest::Error),
|
||||
#[error("CardDAV sync error: {0}")]
|
||||
SyncError(String),
|
||||
/// Failed to parse an RFC 6350 vCard payload embedded in a CardDAV response.
|
||||
#[error("failed to parse vCard payload: {0}")]
|
||||
ParseFailed(String),
|
||||
|
||||
/// CardDAV network/transport-layer failure (connection, TLS, or an unexpected HTTP
|
||||
/// status) distinct from a payload that connected fine but failed to parse.
|
||||
#[error("CardDAV transport failure: {0}")]
|
||||
TransportFailed(String),
|
||||
}
|
||||
|
||||
/// Configuration for a specific CardDAV address book collection endpoint, mirroring
|
||||
/// `nuncio_cal::CalDavAccountConfig`.
|
||||
///
|
||||
/// `carddav_url` must already resolve to a specific address book collection (e.g.
|
||||
/// `https://carddav.example.com/dav/addressbooks/user/jmaes/contacts/`) -- PROPFIND-based
|
||||
/// `addressbook-home-set` auto-discovery is out of scope for this client.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct CardDavAccountConfig {
|
||||
/// Nuncio account identifier this address book collection belongs to.
|
||||
pub account_id: String,
|
||||
/// Fully-qualified URL of the CardDAV address book collection to query.
|
||||
pub carddav_url: String,
|
||||
/// Basic-auth username (or app-specific username, per provider).
|
||||
pub username: String,
|
||||
/// Basic-auth secret (password or app-specific token) resolved from the OS keyring by the
|
||||
/// caller -- never stored anywhere else in plaintext.
|
||||
pub auth_token: String,
|
||||
}
|
||||
|
||||
/// CardDAV client protocol engine managing address book `REPORT` queries against a real server.
|
||||
pub struct CardDavClient {
|
||||
#[allow(dead_code)]
|
||||
client: Client,
|
||||
config: CardDavAccountConfig,
|
||||
http: reqwest::Client,
|
||||
}
|
||||
|
||||
impl CardDavClient {
|
||||
/// Create a new `CardDavClient` bound to a specific CardDAV address book collection.
|
||||
pub fn new(config: CardDavAccountConfig) -> Self {
|
||||
Self {
|
||||
client: Client::builder()
|
||||
http: reqwest::Client::builder()
|
||||
.user_agent("Nuncio-Contacts-CardDAV/1.0")
|
||||
.build()
|
||||
.unwrap_or_default(),
|
||||
@@ -37,13 +58,153 @@ impl CardDavClient {
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn fetch_remote_vcards(&self) -> Result<Vec<Contact>, CardDavError> {
|
||||
info!(
|
||||
"Initiating CardDAV PROPFIND fetch from {}",
|
||||
self.config.carddav_url
|
||||
);
|
||||
// Returns mock or parsed remote vcards for CardDAV endpoint
|
||||
let sample_contact = Contact::new("Google CardDAV Sync Contact", "carddav.sync@nuncio.mx");
|
||||
Ok(vec![sample_contact])
|
||||
/// Construct a standard CardDAV `<card:addressbook-query>` XML payload (RFC 6352 Section
|
||||
/// 8.6) requesting the full `address-data` of every card in the collection.
|
||||
pub fn build_report_query() -> String {
|
||||
r#"<?xml version="1.0" encoding="utf-8" ?>
|
||||
<card:addressbook-query xmlns:d="DAV:" xmlns:card="urn:ietf:params:xml:ns:carddav">
|
||||
<d:prop>
|
||||
<d:getetag />
|
||||
<card:address-data />
|
||||
</d:prop>
|
||||
</card:addressbook-query>"#
|
||||
.to_string()
|
||||
}
|
||||
|
||||
/// Parse a CardDAV WebDAV XML `<multistatus>` response containing embedded vCard data.
|
||||
pub fn parse_multistatus_response(
|
||||
&self,
|
||||
account_id: &str,
|
||||
raw_xml: &str,
|
||||
) -> Result<Vec<Contact>, CardDavError> {
|
||||
let mut contacts = Vec::new();
|
||||
|
||||
for block in raw_xml.split("<card:address-data>") {
|
||||
if let Some((vcard_data, _)) = block.split_once("</card:address-data>") {
|
||||
let clean_vcard = vcard_data.trim();
|
||||
if !clean_vcard.is_empty() {
|
||||
let contact_id = format!("carddav-{account_id}-{}", contacts.len() + 1);
|
||||
let contact =
|
||||
VCardParserAdapter::parse_vcard(&contact_id, account_id, clean_vcard)?;
|
||||
contacts.push(contact);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(contacts)
|
||||
}
|
||||
|
||||
/// Issue a live CardDAV `REPORT` addressbook-query (RFC 6352 Section 8.6) against
|
||||
/// `self.config.carddav_url` and parse the `multistatus` response into domain contacts.
|
||||
///
|
||||
/// This performs a real network request -- no canned or fabricated data is ever
|
||||
/// returned. A server or network failure surfaces as [`CardDavError::TransportFailed`]; a
|
||||
/// malformed vCard inside a returned response surfaces as [`CardDavError::ParseFailed`].
|
||||
pub async fn fetch_remote_vcards(
|
||||
&self,
|
||||
account_id: &str,
|
||||
) -> Result<Vec<Contact>, CardDavError> {
|
||||
let body = Self::build_report_query();
|
||||
|
||||
// "REPORT" is a fixed, always-valid HTTP token; `from_bytes` cannot fail for it, but
|
||||
// the error is still propagated rather than unwrapped so no code path here can panic.
|
||||
let report_method = reqwest::Method::from_bytes(b"REPORT")
|
||||
.map_err(|e| CardDavError::TransportFailed(format!("invalid HTTP method: {e}")))?;
|
||||
|
||||
let response = self
|
||||
.http
|
||||
.request(report_method, &self.config.carddav_url)
|
||||
.header("Content-Type", "application/xml; charset=utf-8")
|
||||
.header("Depth", "1")
|
||||
.basic_auth(&self.config.username, Some(&self.config.auth_token))
|
||||
.body(body)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| CardDavError::TransportFailed(e.to_string()))?;
|
||||
|
||||
let status = response.status();
|
||||
// RFC 6352 REPORT responses are conventionally 207 Multi-Status; some servers reply
|
||||
// 200 OK for a single-collection result, so any 2xx/207 status is accepted.
|
||||
if status.as_u16() != 207 && !status.is_success() {
|
||||
return Err(CardDavError::TransportFailed(format!(
|
||||
"CardDAV server returned unexpected status {status}"
|
||||
)));
|
||||
}
|
||||
|
||||
let raw_xml = response
|
||||
.text()
|
||||
.await
|
||||
.map_err(|e| CardDavError::TransportFailed(e.to_string()))?;
|
||||
|
||||
self.parse_multistatus_response(account_id, &raw_xml)
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl ContactsBackend for CardDavClient {
|
||||
async fn fetch_contacts(&self, account_id: &str) -> Result<Vec<Contact>, CardDavError> {
|
||||
self.fetch_remote_vcards(account_id).await
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn test_config() -> CardDavAccountConfig {
|
||||
CardDavAccountConfig {
|
||||
account_id: "acct-1".to_string(),
|
||||
carddav_url: "https://carddav.example.com/addressbooks/contacts/".to_string(),
|
||||
username: "jmaes".to_string(),
|
||||
auth_token: "app-token-secret".to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_report_query_targets_addressbook_query() {
|
||||
let query = CardDavClient::build_report_query();
|
||||
assert!(query.contains("<card:addressbook-query"));
|
||||
assert!(query.contains("<card:address-data"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_multistatus_response_extracts_contacts() {
|
||||
let client = CardDavClient::new(test_config());
|
||||
let xml_response = r#"<?xml version="1.0" encoding="utf-8"?>
|
||||
<d:multistatus xmlns:d="DAV:" xmlns:card="urn:ietf:params:xml:ns:carddav">
|
||||
<d:response>
|
||||
<d:href>/addressbooks/contacts/alice.vcf</d:href>
|
||||
<d:propstat>
|
||||
<d:prop>
|
||||
<card:address-data>BEGIN:VCARD
|
||||
VERSION:4.0
|
||||
FN:Alice Dev
|
||||
EMAIL:alice@nuncio.mx
|
||||
END:VCARD</card:address-data>
|
||||
</d:prop>
|
||||
</d:propstat>
|
||||
</d:response>
|
||||
</d:multistatus>"#;
|
||||
|
||||
let contacts = client
|
||||
.parse_multistatus_response("acct-1", xml_response)
|
||||
.expect("parse succeeds");
|
||||
|
||||
assert_eq!(contacts.len(), 1);
|
||||
assert_eq!(contacts[0].display_name, "Alice Dev");
|
||||
assert_eq!(contacts[0].emails[0].email, "alice@nuncio.mx");
|
||||
assert_eq!(contacts[0].account_id.as_deref(), Some("acct-1"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_multistatus_response_with_no_cards_yields_empty_not_fabricated() {
|
||||
let client = CardDavClient::new(test_config());
|
||||
let xml_response = r#"<?xml version="1.0" encoding="utf-8"?>
|
||||
<d:multistatus xmlns:d="DAV:" xmlns:card="urn:ietf:params:xml:ns:carddav"></d:multistatus>"#;
|
||||
|
||||
let contacts = client
|
||||
.parse_multistatus_response("acct-1", xml_response)
|
||||
.expect("parse succeeds");
|
||||
assert!(contacts.is_empty());
|
||||
}
|
||||
}
|
||||
@@ -1,12 +1,22 @@
|
||||
//! Contacts (CardDAV / RFC 6350 vCard) engine: a real CardDAV `REPORT` client, a vCard
|
||||
//! parser/generator round trip, and a protocol-agnostic [`ContactsBackend`] trait.
|
||||
|
||||
#![forbid(unsafe_code)]
|
||||
#![cfg_attr(test, allow(clippy::unwrap_used, clippy::expect_used, clippy::panic))]
|
||||
|
||||
pub mod backend;
|
||||
pub mod carddav;
|
||||
pub mod db;
|
||||
pub mod mock;
|
||||
pub mod models;
|
||||
pub mod parser;
|
||||
|
||||
pub use backend::ContactsBackend;
|
||||
pub use carddav::{CardDavAccountConfig, CardDavClient, CardDavError};
|
||||
pub use db::{ContactsDatabase, ContactsStoreError};
|
||||
pub use mock::MockContactsBackend;
|
||||
pub use models::{Contact, ContactEmail, ContactGroup, ContactPhone};
|
||||
pub use parser::VCardParserAdapter;
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
|
||||
@@ -0,0 +1,116 @@
|
||||
//! Deterministic mock contacts backend for offline testing and integration verification.
|
||||
|
||||
use async_trait::async_trait;
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
use crate::backend::ContactsBackend;
|
||||
use crate::carddav::CardDavError;
|
||||
use crate::models::Contact;
|
||||
|
||||
/// Thread-safe mock contacts backend for offline testing.
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct MockContactsBackend {
|
||||
contacts: Arc<Mutex<Vec<Contact>>>,
|
||||
should_fail: Arc<Mutex<bool>>,
|
||||
}
|
||||
|
||||
impl MockContactsBackend {
|
||||
/// Create a new `MockContactsBackend`.
|
||||
pub fn new() -> Self {
|
||||
Self::default()
|
||||
}
|
||||
|
||||
/// Configure the mock to simulate CardDAV network failure errors.
|
||||
pub fn set_should_fail(&self, fail: bool) {
|
||||
if let Ok(mut flag) = self.should_fail.lock() {
|
||||
*flag = fail;
|
||||
}
|
||||
}
|
||||
|
||||
/// Add a mock contact to storage.
|
||||
pub fn add_contact(&self, contact: Contact) {
|
||||
if let Ok(mut guard) = self.contacts.lock() {
|
||||
guard.push(contact);
|
||||
}
|
||||
}
|
||||
|
||||
/// Retrieve contacts belonging to a specific account.
|
||||
pub fn list_contacts(&self, account_id: &str) -> Result<Vec<Contact>, CardDavError> {
|
||||
let should_fail = self
|
||||
.should_fail
|
||||
.lock()
|
||||
.map_err(|e| CardDavError::ParseFailed(e.to_string()))?;
|
||||
if *should_fail {
|
||||
return Err(CardDavError::TransportFailed(
|
||||
"simulated CardDAV network failure".to_string(),
|
||||
));
|
||||
}
|
||||
|
||||
let guard = self
|
||||
.contacts
|
||||
.lock()
|
||||
.map_err(|e| CardDavError::ParseFailed(e.to_string()))?;
|
||||
let matches = guard
|
||||
.iter()
|
||||
.filter(|c| c.account_id.as_deref() == Some(account_id))
|
||||
.cloned()
|
||||
.collect();
|
||||
|
||||
Ok(matches)
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl ContactsBackend for MockContactsBackend {
|
||||
async fn fetch_contacts(&self, account_id: &str) -> Result<Vec<Contact>, CardDavError> {
|
||||
self.list_contacts(account_id)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::models::Contact as ModelContact;
|
||||
|
||||
fn sample_contact(account_id: &str, display_name: &str) -> ModelContact {
|
||||
let mut contact = ModelContact::new(display_name, format!("{display_name}@nuncio.mx"));
|
||||
contact.account_id = Some(account_id.to_string());
|
||||
contact
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mock_contacts_backend_operations() {
|
||||
let mock = MockContactsBackend::new();
|
||||
mock.add_contact(sample_contact("acct-1", "Mock Contact"));
|
||||
|
||||
let contacts = mock
|
||||
.list_contacts("acct-1")
|
||||
.expect("list contacts succeeds");
|
||||
assert_eq!(contacts.len(), 1);
|
||||
assert_eq!(contacts[0].display_name, "Mock Contact");
|
||||
|
||||
assert!(mock
|
||||
.list_contacts("acct-other")
|
||||
.expect("list succeeds")
|
||||
.is_empty());
|
||||
|
||||
mock.set_should_fail(true);
|
||||
assert!(mock.list_contacts("acct-1").is_err());
|
||||
}
|
||||
|
||||
/// Proves `MockContactsBackend` is usable behind the `ContactsBackend` trait object, the
|
||||
/// same way the daemon will inject either it or the real CardDAV client.
|
||||
#[tokio::test]
|
||||
async fn mock_contacts_backend_is_usable_as_a_trait_object() {
|
||||
let mock = MockContactsBackend::new();
|
||||
mock.add_contact(sample_contact("acct-1", "Trait Object Contact"));
|
||||
|
||||
let backend: Arc<dyn ContactsBackend> = Arc::new(mock);
|
||||
let contacts = backend
|
||||
.fetch_contacts("acct-1")
|
||||
.await
|
||||
.expect("fetch contacts succeeds");
|
||||
assert_eq!(contacts.len(), 1);
|
||||
assert_eq!(contacts[0].display_name, "Trait Object Contact");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,247 @@
|
||||
//! vCard (RFC 6350) parser adapter converting raw vCard payloads into Nuncio [`Contact`]
|
||||
//! domain entities -- the read half of the round trip whose write half is
|
||||
//! [`Contact::to_vcard`].
|
||||
|
||||
use chrono::Utc;
|
||||
|
||||
use crate::carddav::CardDavError;
|
||||
use crate::models::{Contact, ContactEmail, ContactPhone};
|
||||
|
||||
/// vCard parser adapter converting raw `.vcf`/CardDAV `address-data` payloads into Nuncio
|
||||
/// [`Contact`] domain entities.
|
||||
pub struct VCardParserAdapter;
|
||||
|
||||
impl VCardParserAdapter {
|
||||
/// Maximum allowed single-vCard payload size (1MB -- generous for a single contact
|
||||
/// record, small enough to reject a runaway/malicious response body outright).
|
||||
pub const MAX_PAYLOAD_BYTES: usize = 1024 * 1024;
|
||||
|
||||
/// Parse a raw RFC 6350 vCard string into a [`Contact`] entity.
|
||||
///
|
||||
/// `id` is assigned by the caller (CardDAV responses carry no stable contact ID of their
|
||||
/// own outside the enclosing WebDAV `href`); `account_id` tags which Nuncio account this
|
||||
/// contact was fetched for. Never fabricates a placeholder name or email: a vCard with
|
||||
/// neither `FN` nor any `EMAIL` is a parse failure, not an empty/default contact.
|
||||
pub fn parse_vcard(
|
||||
id: &str,
|
||||
account_id: &str,
|
||||
raw_vcard: &str,
|
||||
) -> Result<Contact, CardDavError> {
|
||||
if raw_vcard.len() > Self::MAX_PAYLOAD_BYTES {
|
||||
return Err(CardDavError::ParseFailed(
|
||||
"vCard payload exceeds maximum allowed limit of 1MB".to_string(),
|
||||
));
|
||||
}
|
||||
if !raw_vcard.to_ascii_uppercase().contains("BEGIN:VCARD") {
|
||||
return Err(CardDavError::ParseFailed(
|
||||
"vCard payload is missing BEGIN:VCARD".to_string(),
|
||||
));
|
||||
}
|
||||
|
||||
let unfolded = unfold_lines(raw_vcard);
|
||||
|
||||
let mut display_name: Option<String> = None;
|
||||
let mut given_name: Option<String> = None;
|
||||
let mut family_name: Option<String> = None;
|
||||
let mut organization: Option<String> = None;
|
||||
let mut job_title: Option<String> = None;
|
||||
let mut emails: Vec<ContactEmail> = Vec::new();
|
||||
let mut phones: Vec<ContactPhone> = Vec::new();
|
||||
|
||||
for line in unfolded.lines() {
|
||||
let line = line.trim();
|
||||
if line.is_empty() {
|
||||
continue;
|
||||
}
|
||||
let upper = line.to_ascii_uppercase();
|
||||
if upper == "BEGIN:VCARD" || upper == "END:VCARD" || upper.starts_with("VERSION:") {
|
||||
continue;
|
||||
}
|
||||
|
||||
let Some((prop, value)) = line.split_once(':') else {
|
||||
continue;
|
||||
};
|
||||
let mut segments = prop.split(';');
|
||||
let name = segments.next().unwrap_or("").to_ascii_uppercase();
|
||||
let params: Vec<&str> = segments.collect();
|
||||
|
||||
match name.as_str() {
|
||||
"FN" => display_name = Some(value.trim().to_string()),
|
||||
"N" => {
|
||||
let comps: Vec<&str> = value.split(';').collect();
|
||||
if let Some(family) = comps.first().map(|s| s.trim()).filter(|s| !s.is_empty())
|
||||
{
|
||||
family_name = Some(family.to_string());
|
||||
}
|
||||
if let Some(given) = comps.get(1).map(|s| s.trim()).filter(|s| !s.is_empty()) {
|
||||
given_name = Some(given.to_string());
|
||||
}
|
||||
}
|
||||
"ORG" => organization = Some(value.trim().to_string()),
|
||||
"TITLE" => job_title = Some(value.trim().to_string()),
|
||||
"EMAIL" => {
|
||||
let label = extract_type(¶ms).unwrap_or_else(|| "other".to_string());
|
||||
let is_primary = emails.is_empty();
|
||||
emails.push(ContactEmail {
|
||||
email: value.trim().to_string(),
|
||||
label: label.to_ascii_lowercase(),
|
||||
is_primary,
|
||||
});
|
||||
}
|
||||
"TEL" => {
|
||||
let label = extract_type(¶ms).unwrap_or_else(|| "other".to_string());
|
||||
let is_primary = phones.is_empty();
|
||||
phones.push(ContactPhone {
|
||||
phone: value.trim().to_string(),
|
||||
label: label.to_ascii_lowercase(),
|
||||
is_primary,
|
||||
});
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
let display_name = display_name
|
||||
.or_else(|| emails.first().map(|e| e.email.clone()))
|
||||
.ok_or_else(|| {
|
||||
CardDavError::ParseFailed(
|
||||
"vCard has neither FN nor EMAIL to derive a display name".to_string(),
|
||||
)
|
||||
})?;
|
||||
|
||||
let now = Utc::now();
|
||||
Ok(Contact {
|
||||
id: id.to_string(),
|
||||
account_id: Some(account_id.to_string()),
|
||||
display_name,
|
||||
given_name,
|
||||
family_name,
|
||||
organization,
|
||||
job_title,
|
||||
notes: None,
|
||||
avatar_url: None,
|
||||
emails,
|
||||
phones,
|
||||
is_favorite: false,
|
||||
interaction_count: 0,
|
||||
last_interacted_at: None,
|
||||
created_at: now,
|
||||
updated_at: now,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// Extract a `TYPE=...` parameter value from a vCard property's parameter list.
|
||||
fn extract_type(params: &[&str]) -> Option<String> {
|
||||
params.iter().find_map(|param| {
|
||||
param
|
||||
.to_ascii_uppercase()
|
||||
.strip_prefix("TYPE=")
|
||||
.map(|v| v.to_string())
|
||||
})
|
||||
}
|
||||
|
||||
/// Unfold RFC 6350 line continuations (a line starting with a space/tab is a continuation of
|
||||
/// the previous line) into single logical lines, tolerating both CRLF and bare LF input.
|
||||
fn unfold_lines(raw: &str) -> String {
|
||||
let mut result = String::new();
|
||||
for line in raw.split("\r\n").flat_map(|l| l.split('\n')) {
|
||||
if (line.starts_with(' ') || line.starts_with('\t')) && !result.is_empty() {
|
||||
result.push_str(line.trim_start());
|
||||
} else {
|
||||
if !result.is_empty() {
|
||||
result.push('\n');
|
||||
}
|
||||
result.push_str(line);
|
||||
}
|
||||
}
|
||||
result
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn parse_vcard_with_all_supported_fields() {
|
||||
let vcard = "BEGIN:VCARD\r\n\
|
||||
VERSION:4.0\r\n\
|
||||
FN:James Maes\r\n\
|
||||
N:Maes;James;;;\r\n\
|
||||
ORG:KofTwentyTwo\r\n\
|
||||
TITLE:Engineer\r\n\
|
||||
EMAIL;TYPE=WORK:james.maes@kof22.com\r\n\
|
||||
TEL;TYPE=MOBILE:+1-555-0199\r\n\
|
||||
END:VCARD";
|
||||
|
||||
let contact =
|
||||
VCardParserAdapter::parse_vcard("c-1", "acct-1", vcard).expect("parse succeeds");
|
||||
|
||||
assert_eq!(contact.display_name, "James Maes");
|
||||
assert_eq!(contact.given_name.as_deref(), Some("James"));
|
||||
assert_eq!(contact.family_name.as_deref(), Some("Maes"));
|
||||
assert_eq!(contact.organization.as_deref(), Some("KofTwentyTwo"));
|
||||
assert_eq!(contact.job_title.as_deref(), Some("Engineer"));
|
||||
assert_eq!(contact.emails.len(), 1);
|
||||
assert_eq!(contact.emails[0].email, "james.maes@kof22.com");
|
||||
assert_eq!(contact.emails[0].label, "work");
|
||||
assert!(contact.emails[0].is_primary);
|
||||
assert_eq!(contact.phones.len(), 1);
|
||||
assert_eq!(contact.phones[0].phone, "+1-555-0199");
|
||||
assert_eq!(contact.phones[0].label, "mobile");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_vcard_without_fn_falls_back_to_primary_email_as_display_name() {
|
||||
let vcard = "BEGIN:VCARD\r\nVERSION:4.0\r\nEMAIL:alice@nuncio.mx\r\nEND:VCARD";
|
||||
let contact =
|
||||
VCardParserAdapter::parse_vcard("c-2", "acct-1", vcard).expect("parse succeeds");
|
||||
assert_eq!(contact.display_name, "alice@nuncio.mx");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_vcard_missing_begin_marker_is_a_real_error() {
|
||||
let err = VCardParserAdapter::parse_vcard("c-3", "acct-1", "FN:No Envelope")
|
||||
.expect_err("must reject a payload with no BEGIN:VCARD");
|
||||
assert!(matches!(err, CardDavError::ParseFailed(_)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_vcard_with_no_name_or_email_is_a_real_error_not_a_fabricated_contact() {
|
||||
let vcard = "BEGIN:VCARD\r\nVERSION:4.0\r\nORG:Ghost Corp\r\nEND:VCARD";
|
||||
let err = VCardParserAdapter::parse_vcard("c-4", "acct-1", vcard)
|
||||
.expect_err("a vCard with no FN and no EMAIL must never become a fabricated contact");
|
||||
assert!(matches!(err, CardDavError::ParseFailed(_)));
|
||||
}
|
||||
|
||||
/// Proves the parser is the true inverse of [`Contact::to_vcard`]: generating a vCard from
|
||||
/// a rich domain [`Contact`] and parsing it back must reproduce the same
|
||||
/// FN/N/ORG/TITLE/primary-EMAIL/primary-TEL fields.
|
||||
#[test]
|
||||
fn vcard_round_trip_preserves_core_fields() {
|
||||
let mut original = Contact::new("Alice Developer", "alice@kof22.com");
|
||||
original.given_name = Some("Alice".to_string());
|
||||
original.family_name = Some("Developer".to_string());
|
||||
original.organization = Some("KofTwentyTwo".to_string());
|
||||
original.job_title = Some("Staff Engineer".to_string());
|
||||
original.phones.push(ContactPhone {
|
||||
phone: "+1-555-0100".to_string(),
|
||||
label: "mobile".to_string(),
|
||||
is_primary: true,
|
||||
});
|
||||
|
||||
let vcard = original.to_vcard();
|
||||
let round_tripped = VCardParserAdapter::parse_vcard("c-rt-1", "acct-1", &vcard)
|
||||
.expect("round-trip parse succeeds");
|
||||
|
||||
assert_eq!(round_tripped.display_name, original.display_name);
|
||||
assert_eq!(round_tripped.given_name, original.given_name);
|
||||
assert_eq!(round_tripped.family_name, original.family_name);
|
||||
assert_eq!(round_tripped.organization, original.organization);
|
||||
assert_eq!(round_tripped.job_title, original.job_title);
|
||||
assert_eq!(round_tripped.emails[0].email, original.emails[0].email);
|
||||
assert_eq!(round_tripped.emails[0].label, original.emails[0].label);
|
||||
assert_eq!(round_tripped.phones[0].phone, original.phones[0].phone);
|
||||
assert_eq!(round_tripped.phones[0].label, original.phones[0].label);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,156 @@
|
||||
//! WireMock integration test suite for the real CardDAV `REPORT` transport.
|
||||
|
||||
#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
|
||||
|
||||
use nuncio_contacts::{CardDavAccountConfig, CardDavClient, CardDavError, ContactsBackend};
|
||||
use wiremock::matchers::{method, path};
|
||||
use wiremock::{Mock, MockServer, ResponseTemplate};
|
||||
|
||||
/// A canned CardDAV `multistatus` response containing two real vCards, exercising the real
|
||||
/// HTTP transport together with the vCard parser.
|
||||
fn multistatus_body() -> String {
|
||||
r#"<?xml version="1.0" encoding="utf-8"?>
|
||||
<d:multistatus xmlns:d="DAV:" xmlns:card="urn:ietf:params:xml:ns:carddav">
|
||||
<d:response>
|
||||
<d:href>/addressbooks/contacts/james.vcf</d:href>
|
||||
<d:propstat>
|
||||
<d:prop>
|
||||
<card:address-data>BEGIN:VCARD
|
||||
VERSION:4.0
|
||||
FN:James Maes
|
||||
N:Maes;James;;;
|
||||
ORG:KofTwentyTwo
|
||||
TITLE:Founder
|
||||
EMAIL;TYPE=WORK:james.maes@kof22.com
|
||||
TEL;TYPE=MOBILE:+1-555-0199
|
||||
END:VCARD</card:address-data>
|
||||
</d:prop>
|
||||
</d:propstat>
|
||||
</d:response>
|
||||
<d:response>
|
||||
<d:href>/addressbooks/contacts/alice.vcf</d:href>
|
||||
<d:propstat>
|
||||
<d:prop>
|
||||
<card:address-data>BEGIN:VCARD
|
||||
VERSION:4.0
|
||||
FN:Alice Dev
|
||||
EMAIL:alice@nuncio.mx
|
||||
END:VCARD</card:address-data>
|
||||
</d:prop>
|
||||
</d:propstat>
|
||||
</d:response>
|
||||
</d:multistatus>"#
|
||||
.to_string()
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn wiremock_carddav_report_fetches_and_parses_vcards() {
|
||||
let mock_server = MockServer::start().await;
|
||||
|
||||
Mock::given(method("REPORT"))
|
||||
.and(path("/addressbooks/contacts/"))
|
||||
.respond_with(ResponseTemplate::new(207).set_body_string(multistatus_body()))
|
||||
.mount(&mock_server)
|
||||
.await;
|
||||
|
||||
let config = CardDavAccountConfig {
|
||||
account_id: "acct-wm-1".to_string(),
|
||||
carddav_url: format!("{}/addressbooks/contacts/", mock_server.uri()),
|
||||
username: "james.maes".to_string(),
|
||||
auth_token: "wiremock-app-token".to_string(),
|
||||
};
|
||||
let client = CardDavClient::new(config);
|
||||
|
||||
// Drive the real client through the `ContactsBackend` trait, exactly how the daemon will
|
||||
// invoke either this real client or `MockContactsBackend` behind the same seam.
|
||||
let contacts: Vec<_> = ContactsBackend::fetch_contacts(&client, "acct-wm-1")
|
||||
.await
|
||||
.expect("fetch_contacts succeeds against the wiremock server");
|
||||
|
||||
assert_eq!(contacts.len(), 2);
|
||||
|
||||
let james = contacts
|
||||
.iter()
|
||||
.find(|c| c.display_name == "James Maes")
|
||||
.expect("James Maes contact present");
|
||||
assert_eq!(james.account_id.as_deref(), Some("acct-wm-1"));
|
||||
assert_eq!(james.given_name.as_deref(), Some("James"));
|
||||
assert_eq!(james.family_name.as_deref(), Some("Maes"));
|
||||
assert_eq!(james.organization.as_deref(), Some("KofTwentyTwo"));
|
||||
assert_eq!(james.job_title.as_deref(), Some("Founder"));
|
||||
assert_eq!(james.emails[0].email, "james.maes@kof22.com");
|
||||
assert_eq!(james.phones[0].phone, "+1-555-0199");
|
||||
|
||||
let alice = contacts
|
||||
.iter()
|
||||
.find(|c| c.display_name == "Alice Dev")
|
||||
.expect("Alice Dev contact present");
|
||||
assert_eq!(alice.emails[0].email, "alice@nuncio.mx");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn wiremock_carddav_report_server_error_surfaces_as_transport_failure() {
|
||||
let mock_server = MockServer::start().await;
|
||||
|
||||
Mock::given(method("REPORT"))
|
||||
.and(path("/addressbooks/broken/"))
|
||||
.respond_with(ResponseTemplate::new(500))
|
||||
.mount(&mock_server)
|
||||
.await;
|
||||
|
||||
let config = CardDavAccountConfig {
|
||||
account_id: "acct-wm-2".to_string(),
|
||||
carddav_url: format!("{}/addressbooks/broken/", mock_server.uri()),
|
||||
username: "james.maes".to_string(),
|
||||
auth_token: "wiremock-app-token".to_string(),
|
||||
};
|
||||
let client = CardDavClient::new(config);
|
||||
|
||||
let err = client
|
||||
.fetch_remote_vcards("acct-wm-2")
|
||||
.await
|
||||
.expect_err("a 500 response must be a real error, not fabricated empty success");
|
||||
|
||||
assert!(matches!(err, CardDavError::TransportFailed(_)));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn wiremock_carddav_report_malformed_vcard_surfaces_as_parse_failure() {
|
||||
let mock_server = MockServer::start().await;
|
||||
|
||||
let malformed_body = r#"<?xml version="1.0" encoding="utf-8"?>
|
||||
<d:multistatus xmlns:d="DAV:" xmlns:card="urn:ietf:params:xml:ns:carddav">
|
||||
<d:response>
|
||||
<d:href>/addressbooks/malformed/ghost.vcf</d:href>
|
||||
<d:propstat>
|
||||
<d:prop>
|
||||
<card:address-data>BEGIN:VCARD
|
||||
VERSION:4.0
|
||||
ORG:No Name Or Email
|
||||
END:VCARD</card:address-data>
|
||||
</d:prop>
|
||||
</d:propstat>
|
||||
</d:response>
|
||||
</d:multistatus>"#;
|
||||
|
||||
Mock::given(method("REPORT"))
|
||||
.and(path("/addressbooks/malformed/"))
|
||||
.respond_with(ResponseTemplate::new(207).set_body_string(malformed_body))
|
||||
.mount(&mock_server)
|
||||
.await;
|
||||
|
||||
let config = CardDavAccountConfig {
|
||||
account_id: "acct-wm-3".to_string(),
|
||||
carddav_url: format!("{}/addressbooks/malformed/", mock_server.uri()),
|
||||
username: "james.maes".to_string(),
|
||||
auth_token: "wiremock-app-token".to_string(),
|
||||
};
|
||||
let client = CardDavClient::new(config);
|
||||
|
||||
let err = client
|
||||
.fetch_remote_vcards("acct-wm-3")
|
||||
.await
|
||||
.expect_err("a vCard with neither FN nor EMAIL must be a real parse error");
|
||||
|
||||
assert!(matches!(err, CardDavError::ParseFailed(_)));
|
||||
}
|
||||
@@ -12,6 +12,7 @@ workspace = true
|
||||
[dependencies]
|
||||
nuncio-core = { path = "../nuncio-core" }
|
||||
nuncio-filter = { path = "../nuncio-filter" }
|
||||
nuncio-contacts = { path = "../nuncio-contacts" }
|
||||
tokio.workspace = true
|
||||
sqlx.workspace = true
|
||||
serde.workspace = true
|
||||
|
||||
@@ -115,6 +115,52 @@ fn resolve_engine_keys(
|
||||
Ok((storage_key, worm_key, ledger_key))
|
||||
}
|
||||
|
||||
/// Reconstruct a [`nuncio_contacts::Contact`] from a `contacts` table row.
|
||||
///
|
||||
/// `created_at`/`updated_at`/`last_interacted_at` are stored as RFC 3339 strings written by
|
||||
/// [`DatabaseEngine::save_contact`] itself, so a parse failure here would indicate corrupted
|
||||
/// storage rather than bad input; falling back to "now" for that edge case (rather than
|
||||
/// propagating a parse error through every read) mirrors the tolerant `unwrap_or_default`
|
||||
/// JSON-decode style already used for `emails_json`/`phones_json` below.
|
||||
fn contact_from_row(row: &sqlx::sqlite::SqliteRow) -> nuncio_contacts::Contact {
|
||||
let emails_json: String = row.get("emails_json");
|
||||
let phones_json: String = row.get("phones_json");
|
||||
let emails: Vec<nuncio_contacts::ContactEmail> =
|
||||
serde_json::from_str(&emails_json).unwrap_or_default();
|
||||
let phones: Vec<nuncio_contacts::ContactPhone> =
|
||||
serde_json::from_str(&phones_json).unwrap_or_default();
|
||||
let is_favorite: i64 = row.get("is_favorite");
|
||||
let interaction_count: i64 = row.get("interaction_count");
|
||||
let last_interacted_at: Option<String> = row.get("last_interacted_at");
|
||||
let created_at: String = row.get("created_at");
|
||||
let updated_at: String = row.get("updated_at");
|
||||
|
||||
let parse_rfc3339 = |s: &str| -> chrono::DateTime<chrono::Utc> {
|
||||
chrono::DateTime::parse_from_rfc3339(s)
|
||||
.map(|dt| dt.with_timezone(&chrono::Utc))
|
||||
.unwrap_or_else(|_| chrono::Utc::now())
|
||||
};
|
||||
|
||||
nuncio_contacts::Contact {
|
||||
id: row.get("id"),
|
||||
account_id: row.get("account_id"),
|
||||
display_name: row.get("display_name"),
|
||||
given_name: row.get("given_name"),
|
||||
family_name: row.get("family_name"),
|
||||
organization: row.get("organization"),
|
||||
job_title: row.get("job_title"),
|
||||
notes: row.get("notes"),
|
||||
avatar_url: row.get("avatar_url"),
|
||||
emails,
|
||||
phones,
|
||||
is_favorite: is_favorite != 0,
|
||||
interaction_count: interaction_count as u64,
|
||||
last_interacted_at: last_interacted_at.as_deref().map(parse_rfc3339),
|
||||
created_at: parse_rfc3339(&created_at),
|
||||
updated_at: parse_rfc3339(&updated_at),
|
||||
}
|
||||
}
|
||||
|
||||
/// Structured result of re-verifying the entire persisted WORM audit
|
||||
/// ledger's hash chain. See
|
||||
/// [`DatabaseEngine::verify_worm_audit_chain_report`].
|
||||
@@ -519,6 +565,54 @@ impl DatabaseEngine {
|
||||
INSERT INTO events_fts(id, summary, location)
|
||||
VALUES (new.id, new.summary, COALESCE(new.location, ''));
|
||||
END;
|
||||
|
||||
-- Contacts (nuncio_contacts::Contact). Emails/phones are stored as JSON columns
|
||||
-- (mirroring the schema already proven in nuncio-contacts's own, now-superseded
|
||||
-- ContactsDatabase) rather than normalized child tables, since a contact's email/
|
||||
-- phone list is always read and written as a whole with its parent record. Like
|
||||
-- calendar summary/location, none of this is encrypted at rest, so trigger-based
|
||||
-- FTS mirroring introduces no confidentiality regression.
|
||||
CREATE TABLE IF NOT EXISTS contacts (
|
||||
id TEXT PRIMARY KEY NOT NULL,
|
||||
account_id TEXT,
|
||||
display_name TEXT NOT NULL,
|
||||
given_name TEXT,
|
||||
family_name TEXT,
|
||||
organization TEXT,
|
||||
job_title TEXT,
|
||||
notes TEXT,
|
||||
avatar_url TEXT,
|
||||
emails_json TEXT NOT NULL DEFAULT '[]',
|
||||
phones_json TEXT NOT NULL DEFAULT '[]',
|
||||
is_favorite INTEGER NOT NULL DEFAULT 0,
|
||||
interaction_count INTEGER NOT NULL DEFAULT 0,
|
||||
last_interacted_at TEXT,
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL
|
||||
);
|
||||
|
||||
CREATE VIRTUAL TABLE IF NOT EXISTS contacts_fts USING fts5(
|
||||
id UNINDEXED,
|
||||
display_name,
|
||||
organization,
|
||||
emails_json,
|
||||
tokenize = 'trigram'
|
||||
);
|
||||
|
||||
CREATE TRIGGER IF NOT EXISTS contacts_ai AFTER INSERT ON contacts BEGIN
|
||||
INSERT INTO contacts_fts(id, display_name, organization, emails_json)
|
||||
VALUES (new.id, new.display_name, COALESCE(new.organization, ''), new.emails_json);
|
||||
END;
|
||||
|
||||
CREATE TRIGGER IF NOT EXISTS contacts_ad AFTER DELETE ON contacts BEGIN
|
||||
DELETE FROM contacts_fts WHERE id = old.id;
|
||||
END;
|
||||
|
||||
CREATE TRIGGER IF NOT EXISTS contacts_au AFTER UPDATE ON contacts BEGIN
|
||||
DELETE FROM contacts_fts WHERE id = old.id;
|
||||
INSERT INTO contacts_fts(id, display_name, organization, emails_json)
|
||||
VALUES (new.id, new.display_name, COALESCE(new.organization, ''), new.emails_json);
|
||||
END;
|
||||
"#,
|
||||
)
|
||||
.execute(&self.pool)
|
||||
@@ -1034,6 +1128,103 @@ impl DatabaseEngine {
|
||||
})
|
||||
}
|
||||
|
||||
/// Save a [`nuncio_contacts::Contact`] to SQLite (INSERT OR REPLACE).
|
||||
///
|
||||
/// `nuncio-store` is otherwise limited to depending only on `nuncio-core` domain types;
|
||||
/// this method (and [`Self::list_contacts`]/[`Self::get_contact`]) is a deliberate,
|
||||
/// narrow exception, taking a direct dependency on `nuncio_contacts::Contact` because that
|
||||
/// type has not (yet) been promoted to `nuncio-core` the way `CalendarEvent`/`Email` have.
|
||||
pub async fn save_contact(
|
||||
&self,
|
||||
contact: &nuncio_contacts::Contact,
|
||||
) -> Result<(), DatabaseError> {
|
||||
let emails_json =
|
||||
serde_json::to_string(&contact.emails).unwrap_or_else(|_| "[]".to_string());
|
||||
let phones_json =
|
||||
serde_json::to_string(&contact.phones).unwrap_or_else(|_| "[]".to_string());
|
||||
let last_interacted_str = contact.last_interacted_at.map(|t| t.to_rfc3339());
|
||||
|
||||
sqlx::query(
|
||||
r#"
|
||||
INSERT OR REPLACE INTO contacts
|
||||
(id, account_id, display_name, given_name, family_name, organization, job_title,
|
||||
notes, avatar_url, emails_json, phones_json, is_favorite, interaction_count,
|
||||
last_interacted_at, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
"#,
|
||||
)
|
||||
.bind(&contact.id)
|
||||
.bind(&contact.account_id)
|
||||
.bind(&contact.display_name)
|
||||
.bind(&contact.given_name)
|
||||
.bind(&contact.family_name)
|
||||
.bind(&contact.organization)
|
||||
.bind(&contact.job_title)
|
||||
.bind(&contact.notes)
|
||||
.bind(&contact.avatar_url)
|
||||
.bind(&emails_json)
|
||||
.bind(&phones_json)
|
||||
.bind(contact.is_favorite)
|
||||
.bind(contact.interaction_count as i64)
|
||||
.bind(&last_interacted_str)
|
||||
.bind(contact.created_at.to_rfc3339())
|
||||
.bind(contact.updated_at.to_rfc3339())
|
||||
.execute(&self.pool)
|
||||
.await
|
||||
.map_err(DatabaseError::Query)?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Query persisted contacts belonging to `account_id`, most-recently-interacted first.
|
||||
///
|
||||
/// See the confidentiality/dependency note on [`Self::save_contact`].
|
||||
pub async fn list_contacts(
|
||||
&self,
|
||||
account_id: &str,
|
||||
) -> Result<Vec<nuncio_contacts::Contact>, DatabaseError> {
|
||||
let rows = sqlx::query(
|
||||
r#"
|
||||
SELECT id, account_id, display_name, given_name, family_name, organization,
|
||||
job_title, notes, avatar_url, emails_json, phones_json, is_favorite,
|
||||
interaction_count, last_interacted_at, created_at, updated_at
|
||||
FROM contacts
|
||||
WHERE account_id = ?
|
||||
ORDER BY interaction_count DESC, display_name ASC
|
||||
"#,
|
||||
)
|
||||
.bind(account_id)
|
||||
.fetch_all(&self.pool)
|
||||
.await
|
||||
.map_err(DatabaseError::Query)?;
|
||||
|
||||
Ok(rows.iter().map(contact_from_row).collect())
|
||||
}
|
||||
|
||||
/// Retrieve a single contact by ID.
|
||||
///
|
||||
/// See the confidentiality/dependency note on [`Self::save_contact`].
|
||||
pub async fn get_contact(
|
||||
&self,
|
||||
contact_id: &str,
|
||||
) -> Result<nuncio_contacts::Contact, DatabaseError> {
|
||||
let row = sqlx::query(
|
||||
r#"
|
||||
SELECT id, account_id, display_name, given_name, family_name, organization,
|
||||
job_title, notes, avatar_url, emails_json, phones_json, is_favorite,
|
||||
interaction_count, last_interacted_at, created_at, updated_at
|
||||
FROM contacts
|
||||
WHERE id = ?
|
||||
"#,
|
||||
)
|
||||
.bind(contact_id)
|
||||
.fetch_one(&self.pool)
|
||||
.await
|
||||
.map_err(DatabaseError::Query)?;
|
||||
|
||||
Ok(contact_from_row(&row))
|
||||
}
|
||||
|
||||
/// Update a single message's read/unread flag in place.
|
||||
///
|
||||
/// Returns `DatabaseError::Query(sqlx::Error::RowNotFound)` if no message
|
||||
@@ -2026,6 +2217,104 @@ mod tests {
|
||||
));
|
||||
}
|
||||
|
||||
fn sample_contact(id: &str, account_id: &str) -> nuncio_contacts::Contact {
|
||||
let mut contact = nuncio_contacts::Contact::new("Architecture Contact", "arch@nuncio.mx");
|
||||
contact.id = id.to_string();
|
||||
contact.account_id = Some(account_id.to_string());
|
||||
contact.organization = Some("KofTwentyTwo".to_string());
|
||||
contact
|
||||
}
|
||||
|
||||
/// Count rows in `contacts_fts` matching a trigram search term, proving the real write
|
||||
/// path (not a raw-SQL test insert) keeps the trigger-populated FTS index in sync -- the
|
||||
/// same proof pattern as `save_get_list_and_search_calendar_event`, without depending on
|
||||
/// `SearchEngine` gaining a contacts-specific method (out of scope for this story).
|
||||
async fn contacts_fts_hit_count(engine: &DatabaseEngine, term: &str) -> i64 {
|
||||
let pattern = format!("%{term}%");
|
||||
let row: (i64,) =
|
||||
sqlx::query_as("SELECT COUNT(*) FROM contacts_fts WHERE display_name LIKE ?")
|
||||
.bind(&pattern)
|
||||
.fetch_one(engine.pool())
|
||||
.await
|
||||
.unwrap();
|
||||
row.0
|
||||
}
|
||||
|
||||
/// A saved contact is retrievable by both `get_contact` and `list_contacts`, and its FTS
|
||||
/// mirror is kept in sync by the real write path (not a raw-SQL test insert).
|
||||
#[tokio::test]
|
||||
async fn save_get_list_and_search_contact() {
|
||||
let (engine, _dir) = DatabaseEngine::connect_ephemeral().await.unwrap();
|
||||
|
||||
let contact = sample_contact("ct-db-100", "acct-1");
|
||||
engine
|
||||
.save_contact(&contact)
|
||||
.await
|
||||
.expect("save contact succeeds");
|
||||
|
||||
let fetched = engine
|
||||
.get_contact("ct-db-100")
|
||||
.await
|
||||
.expect("get contact succeeds");
|
||||
assert_eq!(fetched.display_name, contact.display_name);
|
||||
assert_eq!(fetched.account_id, contact.account_id);
|
||||
assert_eq!(fetched.emails[0].email, contact.emails[0].email);
|
||||
|
||||
let listed = engine
|
||||
.list_contacts("acct-1")
|
||||
.await
|
||||
.expect("list contacts succeeds");
|
||||
assert_eq!(listed.len(), 1);
|
||||
assert_eq!(listed[0].id, "ct-db-100");
|
||||
|
||||
// Different account -> no match.
|
||||
let other_account = engine
|
||||
.list_contacts("acct-other")
|
||||
.await
|
||||
.expect("list contacts succeeds");
|
||||
assert!(other_account.is_empty());
|
||||
|
||||
assert_eq!(contacts_fts_hit_count(&engine, "Architecture").await, 1);
|
||||
}
|
||||
|
||||
/// Re-saving a contact with the same ID (an update, not a fresh insert) replaces both the
|
||||
/// row and its FTS entry rather than duplicating either.
|
||||
#[tokio::test]
|
||||
async fn save_contact_upserts_existing_row_and_fts_entry() {
|
||||
let (engine, _dir) = DatabaseEngine::connect_ephemeral().await.unwrap();
|
||||
|
||||
let mut contact = sample_contact("ct-db-200", "acct-1");
|
||||
engine.save_contact(&contact).await.unwrap();
|
||||
|
||||
contact.display_name = "Renamed Contact".to_string();
|
||||
engine.save_contact(&contact).await.unwrap();
|
||||
|
||||
let fetched = engine.get_contact("ct-db-200").await.unwrap();
|
||||
assert_eq!(fetched.display_name, "Renamed Contact");
|
||||
|
||||
assert_eq!(
|
||||
contacts_fts_hit_count(&engine, "Architecture Contact").await,
|
||||
0
|
||||
);
|
||||
assert_eq!(contacts_fts_hit_count(&engine, "Renamed").await, 1);
|
||||
}
|
||||
|
||||
/// `get_contact` for an ID that was never saved reports `sqlx::Error::RowNotFound` rather
|
||||
/// than silently fabricating an empty/default contact.
|
||||
#[tokio::test]
|
||||
async fn get_contact_reports_not_found_for_missing_contact() {
|
||||
let (engine, _dir) = DatabaseEngine::connect_ephemeral().await.unwrap();
|
||||
|
||||
let err = engine
|
||||
.get_contact("ct-does-not-exist")
|
||||
.await
|
||||
.expect_err("missing contact must be a real error");
|
||||
assert!(matches!(
|
||||
err,
|
||||
DatabaseError::Query(sqlx::Error::RowNotFound)
|
||||
));
|
||||
}
|
||||
|
||||
/// `set_message_read` flips the persisted
|
||||
/// `read_flag` in place (both directions), and reports
|
||||
/// `sqlx::Error::RowNotFound` for a message ID that was never saved,
|
||||
|
||||
Reference in new issue
Block a user