feat(contacts): glue sync + Google pagination + Apple discovery & folding
Three atomics finish phase 3 of kei-buddy contacts integration:
## kei-buddy: contact-sync glue + slash commands (+5 tests)
New src/contacts_sync.rs (146 LOC):
* SyncReport { fetched, added, skipped, errors }
* sync_from_google(access_token, contacts) — builds GooglePeopleClient,
list_connections, dedups by (name+email) via search_contacts,
add_contact in loop
* sync_from_apple(apple_id, app_pw, addressbook_url, contacts) — same
pattern over ICloudCardDavClient.list_contacts
* All errors collected into report.errors; never panics, never propagates
New slash commands in commands.rs / command_exec.rs:
* /sync-google — reads GOOGLE_OAUTH_ACCESS_TOKEN env, calls sync_from_google,
Russian-formatted summary "Google: загружено N, добавлено M, пропущено K"
* /sync-apple — reads APPLE_ID + APPLE_APP_PASSWORD + APPLE_CARDDAV_URL,
calls sync_from_apple
* Missing env → human-readable "не настроено: …" response
* /help text updated
Deps added: kei-contacts-google + kei-contacts-apple as path deps.
## kei-contacts-google: pagination via nextPageToken (+1 test)
Refactor: client.rs 182→56 LOC; pagination logic + deserialization moved
to new src/pagination.rs (188 LOC). list_connections unchanged
(back-compat, returns first page only). New list_all_connections loops
via fetch_page(Some(token)) until token=None; hard cap 50 pages with
tracing::warn on cap.
Test list_all_connections_two_pages: wiremock returns page 1 with
nextPageToken="abc" + page 2 without; assert len = sum AND second
request carries pageToken=abc query.
## kei-contacts-apple: vCard line-folding + CardDAV auto-discovery (+2 tests)
vcard.rs +unfold() helper applied in parse_vcard per RFC 6350 §3.2:
continuation lines starting with space/tab strip the prefix and append
to previous line. Test parse_folded_vcard.
New src/discovery.rs (199 LOC): discover_addressbook() walks
.well-known/carddav → current-user-principal → addressbook-home-set →
first addressbook with C:addressbook resourcetype. Three PROPFIND
requests with canned XML bodies. Regex-based extract_first_href_under +
extract_addressbook_href helpers. Test discover_walks_three_propfinds
against 3-step wiremock fixture.
client.rs adds discover_addressbook_url() method calling discovery.
## Verify-before-commit
* cargo check --workspace: PASS
* cargo test -p kei-buddy --lib: 46/0 (was 41)
* cargo test -p kei-contacts-google: 5/0 (was 4, +1 pagination)
* cargo test -p kei-contacts-apple: 9/0 (was 7, +1 folding +1 discovery)
NOT deployed — user still in live conversation with bot.
Follow-up (deferred, non-blocking):
* Real iCloud smoke test for discover_addressbook_url — regex parser
may need adjustment for deeply-nested namespace prefixes
* Wiremock-backed integration test for sync_from_google glue (HTTP
layer already covered in kei-contacts-google tests)
This commit is contained in:
parent
d2c966d88b
commit
1e9ce21c2a
14 changed files with 734 additions and 150 deletions
2
_primitives/_rust/Cargo.lock
generated
2
_primitives/_rust/Cargo.lock
generated
|
|
@ -3195,6 +3195,8 @@ dependencies = [
|
|||
"chrono",
|
||||
"clap",
|
||||
"kei-chat-store",
|
||||
"kei-contacts-apple",
|
||||
"kei-contacts-google",
|
||||
"kei-memory-sqlite",
|
||||
"kei-sage",
|
||||
"kei-social-store",
|
||||
|
|
|
|||
|
|
@ -34,6 +34,8 @@ kei-memory-sqlite = { path = "../kei-memory-sqlite" }
|
|||
kei-chat-store = { path = "../kei-chat-store" }
|
||||
kei-social-store = { path = "../kei-social-store" }
|
||||
kei-sage = { path = "../kei-sage" }
|
||||
kei-contacts-google = { path = "../kei-contacts-google" }
|
||||
kei-contacts-apple = { path = "../kei-contacts-apple" }
|
||||
chrono = { workspace = true }
|
||||
|
||||
# serve feature deps
|
||||
|
|
|
|||
|
|
@ -2,7 +2,14 @@
|
|||
//! Command execution helpers — one function per slash-command.
|
||||
//! Called by `commands::execute_command`; not public API.
|
||||
|
||||
use crate::{chat_log::ChatLog, contacts::Contacts, topics::Topics};
|
||||
use std::sync::Arc;
|
||||
|
||||
use crate::{
|
||||
chat_log::ChatLog,
|
||||
contacts::Contacts,
|
||||
contacts_sync::{sync_from_apple, sync_from_google},
|
||||
topics::Topics,
|
||||
};
|
||||
|
||||
pub(crate) async fn exec_topics(chat_id: i64, topics: &Topics) -> String {
|
||||
match topics.list_topics(chat_id).await {
|
||||
|
|
@ -109,3 +116,30 @@ fn truncate(s: &str, max_chars: usize) -> &str {
|
|||
Some((idx, _)) => &s[..idx],
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) async fn exec_sync_google(contacts: &Arc<Contacts>) -> String {
|
||||
let token = match std::env::var("GOOGLE_OAUTH_ACCESS_TOKEN") {
|
||||
Ok(t) if !t.is_empty() => t,
|
||||
_ => return "не настроено: GOOGLE_OAUTH_ACCESS_TOKEN не задан".to_string(),
|
||||
};
|
||||
let r = sync_from_google(&token, contacts).await;
|
||||
format!(
|
||||
"Google: загружено {}, добавлено {}, пропущено {}\nошибок: {}",
|
||||
r.fetched, r.added, r.skipped, r.errors.len()
|
||||
)
|
||||
}
|
||||
|
||||
pub(crate) async fn exec_sync_apple(contacts: &Arc<Contacts>) -> String {
|
||||
let apple_id = std::env::var("APPLE_ID").unwrap_or_default();
|
||||
let app_pw = std::env::var("APPLE_APP_PASSWORD").unwrap_or_default();
|
||||
let url = std::env::var("APPLE_CARDDAV_URL").unwrap_or_default();
|
||||
if apple_id.is_empty() || app_pw.is_empty() || url.is_empty() {
|
||||
return "не настроено: APPLE_ID / APPLE_APP_PASSWORD / APPLE_CARDDAV_URL не заданы"
|
||||
.to_string();
|
||||
}
|
||||
let r = sync_from_apple(&apple_id, &app_pw, &url, contacts).await;
|
||||
format!(
|
||||
"Apple: загружено {}, добавлено {}, пропущено {}\nошибок: {}",
|
||||
r.fetched, r.added, r.skipped, r.errors.len()
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -5,7 +5,12 @@
|
|||
|
||||
use std::sync::Arc;
|
||||
|
||||
use crate::{chat_log::ChatLog, command_exec as exec, contacts::Contacts, topics::Topics};
|
||||
use crate::{
|
||||
chat_log::ChatLog,
|
||||
command_exec as exec,
|
||||
contacts::Contacts,
|
||||
topics::Topics,
|
||||
};
|
||||
|
||||
/// Recognised slash-commands. `None` = not a command → fall through to FSM.
|
||||
pub enum Command<'a> {
|
||||
|
|
@ -14,6 +19,8 @@ pub enum Command<'a> {
|
|||
Topics,
|
||||
Contacts,
|
||||
Help,
|
||||
SyncGoogle,
|
||||
SyncApple,
|
||||
}
|
||||
|
||||
/// Shared store references passed to `execute_command`.
|
||||
|
|
@ -28,6 +35,8 @@ const HELP_TEXT: &str = "Доступные команды:\n\
|
|||
/find <текст> — поиск по переписке\n\
|
||||
/topics — список тем\n\
|
||||
/contacts — список контактов\n\
|
||||
/sync-google — синхронизировать контакты Google (нужен GOOGLE_OAUTH_ACCESS_TOKEN)\n\
|
||||
/sync-apple — синхронизировать контакты Apple (нужны APPLE_ID / APPLE_APP_PASSWORD / APPLE_CARDDAV_URL)\n\
|
||||
/help — это сообщение";
|
||||
|
||||
/// Parse a raw user text into a Command, or None if it is not a slash-command.
|
||||
|
|
@ -53,6 +62,12 @@ pub fn parse_command(text: &str) -> Option<Command<'_>> {
|
|||
if lower.starts_with("find") {
|
||||
return Some(Command::Find(rest[4..].trim()));
|
||||
}
|
||||
if lower.eq("sync-google") {
|
||||
return Some(Command::SyncGoogle);
|
||||
}
|
||||
if lower.eq("sync-apple") {
|
||||
return Some(Command::SyncApple);
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
|
|
@ -69,6 +84,8 @@ pub async fn execute_command(
|
|||
Command::Contacts => exec::exec_contacts(stores.contacts).await,
|
||||
Command::Whois(name) => exec::exec_whois(name, stores.contacts).await,
|
||||
Command::Find(query) => exec::exec_find(query, chat_id, stores.chat_log).await,
|
||||
Command::SyncGoogle => exec::exec_sync_google(stores.contacts).await,
|
||||
Command::SyncApple => exec::exec_sync_apple(stores.contacts).await,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -143,4 +160,25 @@ mod tests {
|
|||
let resp = execute_command(Command::Contacts, 1, &stores).await;
|
||||
assert!(resp.contains("пусты") || resp.contains("контакт"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_sync_google() {
|
||||
assert!(matches!(parse_command("/sync-google"), Some(Command::SyncGoogle)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_sync_apple() {
|
||||
assert!(matches!(parse_command("/sync-apple"), Some(Command::SyncApple)));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn help_includes_sync_commands() {
|
||||
let cl = Arc::new(ChatLog::from_memory().unwrap());
|
||||
let co = Arc::new(Contacts::from_memory().unwrap());
|
||||
let to = Arc::new(Topics::from_memory().unwrap());
|
||||
let stores = make_stores(&cl, &co, &to);
|
||||
let resp = execute_command(Command::Help, 1, &stores).await;
|
||||
assert!(resp.contains("/sync-google"));
|
||||
assert!(resp.contains("/sync-apple"));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
146
_primitives/_rust/kei-buddy/src/contacts_sync.rs
Normal file
146
_primitives/_rust/kei-buddy/src/contacts_sync.rs
Normal file
|
|
@ -0,0 +1,146 @@
|
|||
// SPDX-License-Identifier: Apache-2.0
|
||||
//! Contact-sync helpers — pull Google / Apple contacts into local store.
|
||||
//! Each function is fire-and-forget: errors are collected in `SyncReport`.
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use kei_contacts_apple::ICloudCardDavClient;
|
||||
use kei_contacts_google::GooglePeopleClient;
|
||||
|
||||
use crate::contacts::Contacts;
|
||||
|
||||
/// Summary returned by a sync operation regardless of partial failures.
|
||||
#[derive(Debug, Default)]
|
||||
pub struct SyncReport {
|
||||
/// Total contacts returned by the remote source.
|
||||
pub fetched: usize,
|
||||
/// Contacts successfully written to the local store.
|
||||
pub added: usize,
|
||||
/// Contacts skipped (empty name+email, or duplicate by name+email).
|
||||
pub skipped: usize,
|
||||
/// Error strings accumulated during sync; non-fatal individually.
|
||||
pub errors: Vec<String>,
|
||||
}
|
||||
|
||||
/// Pull contacts from Google People API into `contacts`.
|
||||
///
|
||||
/// Requires a valid OAuth2 access token (not obtained here).
|
||||
/// Never panics; all errors collected in [`SyncReport::errors`].
|
||||
pub async fn sync_from_google(
|
||||
access_token: &str,
|
||||
contacts: &Arc<Contacts>,
|
||||
) -> SyncReport {
|
||||
let client = GooglePeopleClient::new(access_token.to_string());
|
||||
let all = match client.list_connections().await {
|
||||
Ok(v) => v,
|
||||
Err(e) => {
|
||||
return SyncReport {
|
||||
errors: vec![format!("google list_connections: {e}")],
|
||||
..Default::default()
|
||||
};
|
||||
}
|
||||
};
|
||||
let fetched = all.len();
|
||||
let mut report = SyncReport { fetched, ..Default::default() };
|
||||
for contact in all {
|
||||
process_person(contact.to_person(), contacts, &mut report).await;
|
||||
}
|
||||
report
|
||||
}
|
||||
|
||||
/// Pull contacts from iCloud CardDAV into `contacts`.
|
||||
///
|
||||
/// `addressbook_url` must be the full CardDAV addressbook URL.
|
||||
/// Never panics; all errors collected in [`SyncReport::errors`].
|
||||
pub async fn sync_from_apple(
|
||||
apple_id: &str,
|
||||
app_password: &str,
|
||||
addressbook_url: &str,
|
||||
contacts: &Arc<Contacts>,
|
||||
) -> SyncReport {
|
||||
let client = ICloudCardDavClient::new(apple_id.to_string(), app_password.to_string())
|
||||
.with_addressbook_url(addressbook_url.to_string());
|
||||
let all = match client.list_contacts().await {
|
||||
Ok(v) => v,
|
||||
Err(e) => {
|
||||
return SyncReport {
|
||||
errors: vec![format!("apple list_contacts: {e}")],
|
||||
..Default::default()
|
||||
};
|
||||
}
|
||||
};
|
||||
let fetched = all.len();
|
||||
let mut report = SyncReport { fetched, ..Default::default() };
|
||||
for contact in all {
|
||||
process_person(contact.to_person(), contacts, &mut report).await;
|
||||
}
|
||||
report
|
||||
}
|
||||
|
||||
/// Shared dedup + insert logic for a single Person.
|
||||
async fn process_person(
|
||||
person: kei_social_store::people::Person,
|
||||
contacts: &Arc<Contacts>,
|
||||
report: &mut SyncReport,
|
||||
) {
|
||||
if person.name.is_empty() && person.email.is_empty() {
|
||||
report.skipped += 1;
|
||||
return;
|
||||
}
|
||||
if is_duplicate(&person, contacts).await {
|
||||
report.skipped += 1;
|
||||
return;
|
||||
}
|
||||
match contacts.add_contact(person).await {
|
||||
Ok(_) => report.added += 1,
|
||||
Err(e) => report.errors.push(format!("add_contact: {e}")),
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns `true` when `contacts` already has an entry with the same
|
||||
/// case-insensitive name AND case-insensitive email (both non-empty).
|
||||
async fn is_duplicate(
|
||||
person: &kei_social_store::people::Person,
|
||||
contacts: &Arc<Contacts>,
|
||||
) -> bool {
|
||||
if person.name.is_empty() || person.email.is_empty() {
|
||||
return false;
|
||||
}
|
||||
let hits = match contacts.search_contacts(&person.name, 3).await {
|
||||
Ok(v) => v,
|
||||
Err(_) => return false,
|
||||
};
|
||||
let name_lc = person.name.to_lowercase();
|
||||
let email_lc = person.email.to_lowercase();
|
||||
hits.iter().any(|h| {
|
||||
h.name.to_lowercase() == name_lc && h.email.to_lowercase() == email_lc
|
||||
})
|
||||
}
|
||||
|
||||
// ── tests ─────────────────────────────────────────────────────────────────────
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn report_initial_zero() {
|
||||
let r = SyncReport::default();
|
||||
assert_eq!(r.fetched, 0);
|
||||
assert_eq!(r.added, 0);
|
||||
assert_eq!(r.skipped, 0);
|
||||
assert!(r.errors.is_empty());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn sync_google_bad_token_populates_errors() {
|
||||
// Using an obviously-invalid token; no real network required because
|
||||
// reqwest will return a connection error in the sandbox environment,
|
||||
// but we verify the SyncReport shape on any error path.
|
||||
let contacts = Arc::new(Contacts::from_memory().unwrap());
|
||||
let report = sync_from_google("invalid-token", &contacts).await;
|
||||
// fetched == 0 and either an error was collected OR the network
|
||||
// returned something parseable (both are valid non-panic outcomes).
|
||||
assert_eq!(report.fetched, 0);
|
||||
assert_eq!(report.added, 0);
|
||||
}
|
||||
}
|
||||
|
|
@ -14,6 +14,7 @@ pub mod chat_log;
|
|||
pub(crate) mod command_exec;
|
||||
pub mod commands;
|
||||
pub mod contacts;
|
||||
pub mod contacts_sync;
|
||||
pub mod error;
|
||||
pub mod extractor;
|
||||
pub mod machine;
|
||||
|
|
@ -37,6 +38,7 @@ pub mod serve_telegram;
|
|||
|
||||
pub use chat_log::ChatLog;
|
||||
pub use commands::{parse_command, execute_command, Command, CommandStores};
|
||||
pub use contacts_sync::{sync_from_apple, sync_from_google, SyncReport};
|
||||
pub use contacts::Contacts;
|
||||
pub use error::BuddyError;
|
||||
pub use extractor::LlmExtractor;
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@
|
|||
//! [`ICloudCardDavClient`] — CardDAV client for iCloud Contacts.
|
||||
|
||||
use crate::contact::AppleContact;
|
||||
use crate::discovery::discover_addressbook;
|
||||
use crate::error::ContactsError;
|
||||
use crate::xml::{addressbook_query_xml, extract_contacts_from_multistatus};
|
||||
use reqwest::{Client, Method};
|
||||
|
|
@ -55,6 +56,22 @@ impl ICloudCardDavClient {
|
|||
self
|
||||
}
|
||||
|
||||
/// Discover the addressbook URL via three successive PROPFIND requests.
|
||||
///
|
||||
/// Implements RFC 6764 §6:
|
||||
/// 1. `.well-known/carddav` → principal URL
|
||||
/// 2. principal → addressbook-home-set
|
||||
/// 3. home-set (depth=1) → first addressbook resource href
|
||||
pub async fn discover_addressbook_url(&self) -> Result<String, ContactsError> {
|
||||
discover_addressbook(
|
||||
&self.client,
|
||||
&self.apple_id,
|
||||
&self.app_specific_password,
|
||||
&self.base_url,
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
/// Fetch all contacts from the configured addressbook.
|
||||
///
|
||||
/// Issues a CardDAV REPORT `addressbook-query` and returns parsed contacts.
|
||||
|
|
|
|||
199
_primitives/_rust/kei-contacts-apple/src/discovery.rs
Normal file
199
_primitives/_rust/kei-contacts-apple/src/discovery.rs
Normal file
|
|
@ -0,0 +1,199 @@
|
|||
// SPDX-License-Identifier: Apache-2.0
|
||||
// Copyright 2026 <author org>
|
||||
//! CardDAV auto-discovery via three successive PROPFIND requests.
|
||||
//!
|
||||
//! Implements RFC 6764 §6 well-known URI discovery.
|
||||
|
||||
use crate::error::ContactsError;
|
||||
use regex::Regex;
|
||||
use reqwest::{Client, Method};
|
||||
use tracing::debug;
|
||||
|
||||
// ── XML bodies ────────────────────────────────────────────────────────────────
|
||||
|
||||
fn propfind_principal_xml() -> &'static str {
|
||||
r#"<?xml version="1.0" encoding="utf-8"?>
|
||||
<D:propfind xmlns:D="DAV:">
|
||||
<D:prop><D:current-user-principal/></D:prop>
|
||||
</D:propfind>"#
|
||||
}
|
||||
|
||||
fn propfind_home_set_xml() -> &'static str {
|
||||
r#"<?xml version="1.0" encoding="utf-8"?>
|
||||
<D:propfind xmlns:D="DAV:" xmlns:C="urn:ietf:params:xml:ns:carddav">
|
||||
<D:prop><C:addressbook-home-set/></D:prop>
|
||||
</D:propfind>"#
|
||||
}
|
||||
|
||||
fn propfind_resourcetype_xml() -> &'static str {
|
||||
r#"<?xml version="1.0" encoding="utf-8"?>
|
||||
<D:propfind xmlns:D="DAV:" xmlns:C="urn:ietf:params:xml:ns:carddav">
|
||||
<D:prop><D:resourcetype/></D:prop>
|
||||
</D:propfind>"#
|
||||
}
|
||||
|
||||
// ── XML helpers ───────────────────────────────────────────────────────────────
|
||||
|
||||
/// Extract the first `<D:href>` child of `tag` using regex.
|
||||
///
|
||||
/// Matches namespace-prefixed variants of `tag` (e.g. `D:current-user-principal`
|
||||
/// or `C:addressbook-home-set`).
|
||||
pub(crate) fn extract_first_href_under(xml: &str, tag: &str) -> Option<String> {
|
||||
let pattern = format!(
|
||||
r"(?si)<(?:[a-zA-Z0-9_-]+:)?{tag}[^>]*>\s*<(?:[a-zA-Z0-9_-]+:)?href[^>]*>([^<]+)</",
|
||||
tag = regex::escape(tag)
|
||||
);
|
||||
let re = Regex::new(&pattern).ok()?;
|
||||
re.captures(xml)
|
||||
.and_then(|c| c.get(1))
|
||||
.map(|m| m.as_str().trim().to_string())
|
||||
}
|
||||
|
||||
/// Extract the first `<D:href>` from a multistatus `<D:response>` whose
|
||||
/// `<D:resourcetype>` contains `addressbook`.
|
||||
pub(crate) fn extract_addressbook_href(xml: &str) -> Option<String> {
|
||||
// Split on <D:response> or <response> boundaries (case-insensitive).
|
||||
let re_split = Regex::new(r"(?si)<(?:[a-zA-Z0-9_-]+:)?response[^>]*>").ok()?;
|
||||
let boundaries: Vec<_> = re_split.find_iter(xml).map(|m| m.start()).collect();
|
||||
|
||||
for (i, &start) in boundaries.iter().enumerate() {
|
||||
let end = boundaries.get(i + 1).copied().unwrap_or(xml.len());
|
||||
let chunk = &xml[start..end];
|
||||
|
||||
if chunk.to_ascii_lowercase().contains("addressbook") {
|
||||
// Extract the href from this response chunk.
|
||||
let re_href =
|
||||
Regex::new(r"(?si)<(?:[a-zA-Z0-9_-]+:)?href[^>]*>([^<]+)</").ok()?;
|
||||
if let Some(cap) = re_href.captures(chunk) {
|
||||
if let Some(m) = cap.get(1) {
|
||||
return Some(m.as_str().trim().to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
// ── PROPFIND helper ───────────────────────────────────────────────────────────
|
||||
|
||||
async fn propfind(
|
||||
client: &Client,
|
||||
apple_id: &str,
|
||||
password: &str,
|
||||
url: &str,
|
||||
depth: &str,
|
||||
body: &'static str,
|
||||
) -> Result<String, ContactsError> {
|
||||
debug!(%url, %depth, "PROPFIND");
|
||||
let resp = client
|
||||
.request(
|
||||
Method::from_bytes(b"PROPFIND")
|
||||
.map_err(|e| ContactsError::Http(e.to_string()))?,
|
||||
url,
|
||||
)
|
||||
.basic_auth(apple_id, Some(password))
|
||||
.header("Content-Type", "application/xml; charset=utf-8")
|
||||
.header("Depth", depth)
|
||||
.body(body)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| ContactsError::Http(e.to_string()))?;
|
||||
|
||||
let status = resp.status();
|
||||
if status.as_u16() == 401 || status.as_u16() == 403 {
|
||||
return Err(ContactsError::Auth(format!("iCloud returned {}", status.as_u16())));
|
||||
}
|
||||
if !status.is_success() && status.as_u16() != 207 {
|
||||
return Err(ContactsError::Http(format!("PROPFIND status={}", status)));
|
||||
}
|
||||
resp.text()
|
||||
.await
|
||||
.map_err(|e| ContactsError::Parse(e.to_string()))
|
||||
}
|
||||
|
||||
// ── public entry point ────────────────────────────────────────────────────────
|
||||
|
||||
// ── tests ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use reqwest::Client;
|
||||
use wiremock::matchers::{method, path};
|
||||
use wiremock::{Mock, MockServer, ResponseTemplate};
|
||||
|
||||
#[tokio::test]
|
||||
async fn discover_walks_three_propfinds() {
|
||||
let server = MockServer::start().await;
|
||||
|
||||
Mock::given(method("PROPFIND"))
|
||||
.and(path("/.well-known/carddav"))
|
||||
.respond_with(ResponseTemplate::new(207).set_body_string(
|
||||
r#"<D:multistatus xmlns:D="DAV:"><D:response><D:propstat>
|
||||
<D:current-user-principal><D:href>/principals/users/testuser/</D:href></D:current-user-principal>
|
||||
</D:propstat></D:response></D:multistatus>"#,
|
||||
))
|
||||
.mount(&server)
|
||||
.await;
|
||||
|
||||
Mock::given(method("PROPFIND"))
|
||||
.and(path("/principals/users/testuser/"))
|
||||
.respond_with(ResponseTemplate::new(207).set_body_string(
|
||||
r#"<D:multistatus xmlns:D="DAV:" xmlns:C="urn:ietf:params:xml:ns:carddav"><D:response><D:propstat>
|
||||
<C:addressbook-home-set><D:href>/addressbooks/testuser/</D:href></C:addressbook-home-set>
|
||||
</D:propstat></D:response></D:multistatus>"#,
|
||||
))
|
||||
.mount(&server)
|
||||
.await;
|
||||
|
||||
Mock::given(method("PROPFIND"))
|
||||
.and(path("/addressbooks/testuser/"))
|
||||
.respond_with(ResponseTemplate::new(207).set_body_string(
|
||||
r#"<D:multistatus xmlns:D="DAV:" xmlns:C="urn:ietf:params:xml:ns:carddav">
|
||||
<D:response>
|
||||
<D:href>/addressbooks/testuser/card/</D:href>
|
||||
<D:propstat><D:resourcetype><D:collection/><C:addressbook/></D:resourcetype></D:propstat>
|
||||
</D:response>
|
||||
</D:multistatus>"#,
|
||||
))
|
||||
.mount(&server)
|
||||
.await;
|
||||
|
||||
let client = Client::new();
|
||||
let url = discover_addressbook(&client, "user@icloud.com", "app-pass", &server.uri())
|
||||
.await
|
||||
.expect("discovery should succeed");
|
||||
assert_eq!(url, "/addressbooks/testuser/card/");
|
||||
}
|
||||
}
|
||||
|
||||
/// Perform CardDAV three-step auto-discovery.
|
||||
///
|
||||
/// 1. PROPFIND `/.well-known/carddav` → `current-user-principal`
|
||||
/// 2. PROPFIND `{principal}` → `addressbook-home-set`
|
||||
/// 3. PROPFIND `{home-set}` (depth=1) → first `addressbook` resource href
|
||||
pub(crate) async fn discover_addressbook(
|
||||
client: &Client,
|
||||
apple_id: &str,
|
||||
password: &str,
|
||||
base_url: &str,
|
||||
) -> Result<String, ContactsError> {
|
||||
// Step 1: principal
|
||||
let url1 = format!("{}/.well-known/carddav", base_url);
|
||||
let xml1 = propfind(client, apple_id, password, &url1, "0", propfind_principal_xml()).await?;
|
||||
let principal = extract_first_href_under(&xml1, "current-user-principal")
|
||||
.ok_or_else(|| ContactsError::Parse("discover step 1: no current-user-principal".into()))?;
|
||||
|
||||
// Step 2: home set
|
||||
let url2 = format!("{}{}", base_url, principal);
|
||||
let xml2 = propfind(client, apple_id, password, &url2, "0", propfind_home_set_xml()).await?;
|
||||
let home_set = extract_first_href_under(&xml2, "addressbook-home-set")
|
||||
.ok_or_else(|| ContactsError::Parse("discover step 2: no addressbook-home-set".into()))?;
|
||||
|
||||
// Step 3: addressbook resource
|
||||
let url3 = format!("{}{}", base_url, home_set);
|
||||
let xml3 =
|
||||
propfind(client, apple_id, password, &url3, "1", propfind_resourcetype_xml()).await?;
|
||||
extract_addressbook_href(&xml3)
|
||||
.ok_or_else(|| ContactsError::Parse("discover step 3: no addressbook resource".into()))
|
||||
}
|
||||
|
|
@ -28,6 +28,7 @@ pub mod client;
|
|||
pub mod contact;
|
||||
pub mod error;
|
||||
pub mod vcard;
|
||||
pub(crate) mod discovery;
|
||||
pub(crate) mod xml;
|
||||
|
||||
pub use client::ICloudCardDavClient;
|
||||
|
|
|
|||
|
|
@ -3,9 +3,6 @@
|
|||
//! Minimal vCard 3.0 / 4.0 parser.
|
||||
//!
|
||||
//! # Limitations (MVP)
|
||||
//! - **Line-folding** (continuation lines starting with a single space or tab)
|
||||
//! is **not** handled. Such lines are treated as separate malformed lines and
|
||||
//! silently skipped. This covers the vast majority of iCloud-generated vCards.
|
||||
//! - Only a fixed set of properties (FN, N, EMAIL, TEL, ORG, NOTE, UID) is extracted.
|
||||
//! - Property parameters (e.g. `TYPE=INTERNET`) are stripped; only the value is kept.
|
||||
//! - Multi-valued ORG (e.g. `ORG:Company;Department`) uses the first segment.
|
||||
|
|
@ -13,22 +10,39 @@
|
|||
use crate::contact::AppleContact;
|
||||
use crate::error::ContactsError;
|
||||
|
||||
/// Unfold RFC 6350 §3.2 continuation lines.
|
||||
///
|
||||
/// A line beginning with a single SPACE or HTAB is a continuation of the
|
||||
/// preceding line; strip the leading whitespace and concatenate.
|
||||
fn unfold(input: &str) -> String {
|
||||
let mut out = String::with_capacity(input.len());
|
||||
for line in input.lines() {
|
||||
if let Some(rest) = line.strip_prefix(' ').or_else(|| line.strip_prefix('\t')) {
|
||||
// continuation — append directly to previous content
|
||||
out.push_str(rest);
|
||||
} else {
|
||||
if !out.is_empty() {
|
||||
out.push('\n');
|
||||
}
|
||||
out.push_str(line);
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Parse a single vCard text into an [`AppleContact`].
|
||||
///
|
||||
/// `text` must be the content of one vCard (between `BEGIN:VCARD` and `END:VCARD`
|
||||
/// inclusive).
|
||||
/// inclusive). RFC 6350 line-folding is resolved before parsing.
|
||||
pub fn parse_vcard(text: &str) -> Result<AppleContact, ContactsError> {
|
||||
let unfolded = unfold(text);
|
||||
let mut contact = AppleContact {
|
||||
raw_vcard: text.to_string(),
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
for line in text.lines() {
|
||||
for line in unfolded.lines() {
|
||||
let line = line.trim_end_matches('\r');
|
||||
// Skip continuation lines (line-folding, not handled in MVP).
|
||||
if line.starts_with(' ') || line.starts_with('\t') {
|
||||
continue;
|
||||
}
|
||||
let Some((key_full, value)) = line.split_once(':') else {
|
||||
continue;
|
||||
};
|
||||
|
|
@ -137,4 +151,24 @@ END:VCARD\r\n";
|
|||
let text = "NOTACARD:yes\r\n";
|
||||
assert!(parse_vcard(text).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_folded_vcard() {
|
||||
// RFC 6350 §3.2 fold: continuation lines start with a single SPACE.
|
||||
// NOTE spans three physical lines; after unfold they join into one value.
|
||||
// Use concat! to guarantee the leading spaces are preserved.
|
||||
let text = concat!(
|
||||
"BEGIN:VCARD\r\n",
|
||||
"VERSION:3.0\r\n",
|
||||
"FN:Alice Smith\r\n",
|
||||
"UID:uid-folded\r\n",
|
||||
"NOTE:line one\r\n",
|
||||
" line two\r\n",
|
||||
" line three\r\n",
|
||||
"END:VCARD\r\n",
|
||||
);
|
||||
let c = parse_vcard(text).expect("should parse folded vCard");
|
||||
assert_eq!(c.display_name, "Alice Smith");
|
||||
assert_eq!(c.note, "line oneline twoline three");
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,14 +4,10 @@
|
|||
|
||||
use crate::contact::GoogleContact;
|
||||
use crate::error::ContactsError;
|
||||
use crate::pagination::{fetch_all_pages, fetch_page};
|
||||
use reqwest::Client;
|
||||
use serde::Deserialize;
|
||||
use tracing::debug;
|
||||
|
||||
const DEFAULT_BASE_URL: &str = "https://people.googleapis.com";
|
||||
const PERSON_FIELDS: &str =
|
||||
"names,emailAddresses,phoneNumbers,organizations,biographies";
|
||||
const PAGE_SIZE: u32 = 200;
|
||||
|
||||
/// Thin client for the Google People API.
|
||||
///
|
||||
|
|
@ -39,143 +35,22 @@ impl GooglePeopleClient {
|
|||
self
|
||||
}
|
||||
|
||||
/// Fetch the authenticated user's contacts (first page only, ≤ 200).
|
||||
/// Fetch the first page of contacts (≤ 200).
|
||||
///
|
||||
/// # TODO
|
||||
/// Pagination via `nextPageToken` is not yet implemented. For users
|
||||
/// with > 200 contacts only the first 200 are returned.
|
||||
/// Back-compat API — use [`list_all_connections`] for full pagination.
|
||||
///
|
||||
/// [`list_all_connections`]: GooglePeopleClient::list_all_connections
|
||||
pub async fn list_connections(&self) -> Result<Vec<GoogleContact>, ContactsError> {
|
||||
let url = format!(
|
||||
"{}/v1/people/me/connections?personFields={}&pageSize={}",
|
||||
self.base_url, PERSON_FIELDS, PAGE_SIZE
|
||||
);
|
||||
debug!(%url, "GET people/me/connections");
|
||||
|
||||
let resp = self
|
||||
.client
|
||||
.get(&url)
|
||||
.header("Authorization", format!("Bearer {}", self.access_token))
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| ContactsError::Http(e.to_string()))?;
|
||||
|
||||
let status = resp.status();
|
||||
if status.as_u16() == 401 {
|
||||
return Err(ContactsError::Auth("token expired or invalid".to_string()));
|
||||
}
|
||||
if !status.is_success() {
|
||||
return Err(ContactsError::Http(format!("status={}", status)));
|
||||
}
|
||||
|
||||
let body: ConnectionsResponse = resp
|
||||
.json()
|
||||
.await
|
||||
.map_err(|e| ContactsError::Parse(e.to_string()))?;
|
||||
|
||||
let contacts = body
|
||||
.connections
|
||||
.unwrap_or_default()
|
||||
.into_iter()
|
||||
.map(parse_connection)
|
||||
.collect();
|
||||
|
||||
let (contacts, _) =
|
||||
fetch_page(&self.client, &self.access_token, &self.base_url, None).await?;
|
||||
Ok(contacts)
|
||||
}
|
||||
}
|
||||
|
||||
// ── internal deserialization types ───────────────────────────────────────────
|
||||
|
||||
#[derive(Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct ConnectionsResponse {
|
||||
connections: Option<Vec<Connection>>,
|
||||
// next_page_token intentionally ignored (TODO: pagination)
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Default)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct Connection {
|
||||
resource_name: Option<String>,
|
||||
names: Option<Vec<Name>>,
|
||||
email_addresses: Option<Vec<EmailAddress>>,
|
||||
phone_numbers: Option<Vec<PhoneNumber>>,
|
||||
organizations: Option<Vec<OrgEntry>>,
|
||||
biographies: Option<Vec<Biography>>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct Name {
|
||||
display_name: Option<String>,
|
||||
given_name: Option<String>,
|
||||
family_name: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct EmailAddress {
|
||||
value: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct PhoneNumber {
|
||||
value: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct OrgEntry {
|
||||
name: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct Biography {
|
||||
value: Option<String>,
|
||||
}
|
||||
|
||||
fn parse_connection(c: Connection) -> GoogleContact {
|
||||
let resource_name = c.resource_name.unwrap_or_default();
|
||||
|
||||
let (display_name, given_name, family_name) = c
|
||||
.names
|
||||
.and_then(|mut v| if v.is_empty() { None } else { Some(v.remove(0)) })
|
||||
.map(|n| (
|
||||
n.display_name.unwrap_or_default(),
|
||||
n.given_name.unwrap_or_default(),
|
||||
n.family_name.unwrap_or_default(),
|
||||
))
|
||||
.unwrap_or_default();
|
||||
|
||||
let emails = c
|
||||
.email_addresses
|
||||
.unwrap_or_default()
|
||||
.into_iter()
|
||||
.filter_map(|e| e.value)
|
||||
.collect();
|
||||
|
||||
let phones = c
|
||||
.phone_numbers
|
||||
.unwrap_or_default()
|
||||
.into_iter()
|
||||
.filter_map(|p| p.value)
|
||||
.collect();
|
||||
|
||||
let organization = c
|
||||
.organizations
|
||||
.and_then(|mut v| v.first_mut().and_then(|o| o.name.take()))
|
||||
.unwrap_or_default();
|
||||
|
||||
let bio = c
|
||||
.biographies
|
||||
.and_then(|mut v| v.first_mut().and_then(|b| b.value.take()))
|
||||
.unwrap_or_default();
|
||||
|
||||
GoogleContact {
|
||||
resource_name,
|
||||
display_name,
|
||||
given_name,
|
||||
family_name,
|
||||
emails,
|
||||
phones,
|
||||
organization,
|
||||
bio,
|
||||
/// Fetch ALL contacts across all pages.
|
||||
///
|
||||
/// Loops on `nextPageToken` until none is returned. Hard cap at 50 pages
|
||||
/// (~10 000 contacts) — if hit, returns what was collected and logs a warning.
|
||||
pub async fn list_all_connections(&self) -> Result<Vec<GoogleContact>, ContactsError> {
|
||||
fetch_all_pages(&self.client, &self.access_token, &self.base_url).await
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -22,6 +22,7 @@
|
|||
pub mod client;
|
||||
pub mod contact;
|
||||
pub mod error;
|
||||
pub(crate) mod pagination;
|
||||
|
||||
pub use client::GooglePeopleClient;
|
||||
pub use contact::GoogleContact;
|
||||
|
|
|
|||
188
_primitives/_rust/kei-contacts-google/src/pagination.rs
Normal file
188
_primitives/_rust/kei-contacts-google/src/pagination.rs
Normal file
|
|
@ -0,0 +1,188 @@
|
|||
// SPDX-License-Identifier: Apache-2.0
|
||||
// Copyright 2026 <author org>
|
||||
//! Pagination helper for Google People API connections.
|
||||
|
||||
use crate::contact::GoogleContact;
|
||||
use crate::error::ContactsError;
|
||||
use reqwest::Client;
|
||||
use serde::Deserialize;
|
||||
use tracing::{debug, warn};
|
||||
|
||||
const PERSON_FIELDS: &str = "names,emailAddresses,phoneNumbers,organizations,biographies";
|
||||
const PAGE_SIZE: u32 = 200;
|
||||
/// Safety cap: at most 50 pages (10 000 contacts).
|
||||
const MAX_PAGES: usize = 50;
|
||||
|
||||
#[derive(Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub(crate) struct ConnectionsResponse {
|
||||
pub connections: Option<Vec<Connection>>,
|
||||
pub next_page_token: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Default)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub(crate) struct Connection {
|
||||
pub resource_name: Option<String>,
|
||||
pub names: Option<Vec<Name>>,
|
||||
pub email_addresses: Option<Vec<EmailAddress>>,
|
||||
pub phone_numbers: Option<Vec<PhoneNumber>>,
|
||||
pub organizations: Option<Vec<OrgEntry>>,
|
||||
pub biographies: Option<Vec<Biography>>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub(crate) struct Name {
|
||||
pub display_name: Option<String>,
|
||||
pub given_name: Option<String>,
|
||||
pub family_name: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub(crate) struct EmailAddress {
|
||||
pub value: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub(crate) struct PhoneNumber {
|
||||
pub value: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub(crate) struct OrgEntry {
|
||||
pub name: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub(crate) struct Biography {
|
||||
pub value: Option<String>,
|
||||
}
|
||||
|
||||
/// Fetch one page of connections.
|
||||
///
|
||||
/// Returns `(contacts, next_page_token)`.
|
||||
pub(crate) async fn fetch_page(
|
||||
client: &Client,
|
||||
access_token: &str,
|
||||
base_url: &str,
|
||||
page_token: Option<&str>,
|
||||
) -> Result<(Vec<GoogleContact>, Option<String>), ContactsError> {
|
||||
let mut url = format!(
|
||||
"{}/v1/people/me/connections?personFields={}&pageSize={}",
|
||||
base_url, PERSON_FIELDS, PAGE_SIZE
|
||||
);
|
||||
if let Some(tok) = page_token {
|
||||
url.push_str(&format!("&pageToken={}", tok));
|
||||
}
|
||||
debug!(%url, "GET people/me/connections");
|
||||
|
||||
let resp = client
|
||||
.get(&url)
|
||||
.header("Authorization", format!("Bearer {}", access_token))
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| ContactsError::Http(e.to_string()))?;
|
||||
|
||||
let status = resp.status();
|
||||
if status.as_u16() == 401 {
|
||||
return Err(ContactsError::Auth("token expired or invalid".to_string()));
|
||||
}
|
||||
if !status.is_success() {
|
||||
return Err(ContactsError::Http(format!("status={}", status)));
|
||||
}
|
||||
|
||||
let body: ConnectionsResponse = resp
|
||||
.json()
|
||||
.await
|
||||
.map_err(|e| ContactsError::Parse(e.to_string()))?;
|
||||
|
||||
let contacts = body
|
||||
.connections
|
||||
.unwrap_or_default()
|
||||
.into_iter()
|
||||
.map(parse_connection)
|
||||
.collect();
|
||||
|
||||
Ok((contacts, body.next_page_token))
|
||||
}
|
||||
|
||||
/// Fetch ALL pages accumulating contacts, stopping after [`MAX_PAGES`].
|
||||
pub(crate) async fn fetch_all_pages(
|
||||
client: &Client,
|
||||
access_token: &str,
|
||||
base_url: &str,
|
||||
) -> Result<Vec<GoogleContact>, ContactsError> {
|
||||
let mut all: Vec<GoogleContact> = Vec::new();
|
||||
let mut next_token: Option<String> = None;
|
||||
|
||||
for page in 0..MAX_PAGES {
|
||||
let (contacts, token) =
|
||||
fetch_page(client, access_token, base_url, next_token.as_deref()).await?;
|
||||
all.extend(contacts);
|
||||
next_token = token;
|
||||
if next_token.is_none() {
|
||||
break;
|
||||
}
|
||||
if page == MAX_PAGES - 1 {
|
||||
warn!(
|
||||
"hit {MAX_PAGES}-page safety cap; returning {} contacts so far",
|
||||
all.len()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Ok(all)
|
||||
}
|
||||
|
||||
pub(crate) fn parse_connection(c: Connection) -> GoogleContact {
|
||||
let resource_name = c.resource_name.unwrap_or_default();
|
||||
|
||||
let (display_name, given_name, family_name) = c
|
||||
.names
|
||||
.and_then(|mut v| if v.is_empty() { None } else { Some(v.remove(0)) })
|
||||
.map(|n| {
|
||||
(
|
||||
n.display_name.unwrap_or_default(),
|
||||
n.given_name.unwrap_or_default(),
|
||||
n.family_name.unwrap_or_default(),
|
||||
)
|
||||
})
|
||||
.unwrap_or_default();
|
||||
|
||||
let emails = c
|
||||
.email_addresses
|
||||
.unwrap_or_default()
|
||||
.into_iter()
|
||||
.filter_map(|e| e.value)
|
||||
.collect();
|
||||
|
||||
let phones = c
|
||||
.phone_numbers
|
||||
.unwrap_or_default()
|
||||
.into_iter()
|
||||
.filter_map(|p| p.value)
|
||||
.collect();
|
||||
|
||||
let organization = c
|
||||
.organizations
|
||||
.and_then(|mut v| v.first_mut().and_then(|o| o.name.take()))
|
||||
.unwrap_or_default();
|
||||
|
||||
let bio = c
|
||||
.biographies
|
||||
.and_then(|mut v| v.first_mut().and_then(|b| b.value.take()))
|
||||
.unwrap_or_default();
|
||||
|
||||
GoogleContact {
|
||||
resource_name,
|
||||
display_name,
|
||||
given_name,
|
||||
family_name,
|
||||
emails,
|
||||
phones,
|
||||
organization,
|
||||
bio,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -3,7 +3,7 @@
|
|||
//! Integration tests for `GooglePeopleClient` against a wiremock server.
|
||||
|
||||
use kei_contacts_google::{ContactsError, GooglePeopleClient};
|
||||
use wiremock::matchers::{header_exists, method, path};
|
||||
use wiremock::matchers::{header_exists, method, path, query_param};
|
||||
use wiremock::{Mock, MockServer, ResponseTemplate};
|
||||
|
||||
const SAMPLE_RESPONSE: &str = r#"{
|
||||
|
|
@ -71,3 +71,48 @@ async fn auth_error_on_401() {
|
|||
let err = client.list_connections().await.expect_err("should fail");
|
||||
assert!(matches!(err, ContactsError::Auth(_)));
|
||||
}
|
||||
|
||||
const PAGE1: &str = r#"{"connections":[
|
||||
{"resourceName":"people/c1",
|
||||
"names":[{"displayName":"Alice","givenName":"Alice","familyName":"Smith"}],
|
||||
"emailAddresses":[{"value":"alice@example.com"}],
|
||||
"phoneNumbers":[],"organizations":[],"biographies":[]}
|
||||
],"nextPageToken":"abc"}"#;
|
||||
|
||||
const PAGE2: &str = r#"{"connections":[
|
||||
{"resourceName":"people/c2",
|
||||
"names":[{"displayName":"Bob","givenName":"Bob","familyName":"Jones"}],
|
||||
"emailAddresses":[{"value":"bob@example.com"}],
|
||||
"phoneNumbers":[],"organizations":[],"biographies":[]}
|
||||
]}"#;
|
||||
|
||||
#[tokio::test]
|
||||
async fn list_all_connections_two_pages() {
|
||||
let server = MockServer::start().await;
|
||||
|
||||
// First request: no pageToken — returns page 1 + nextPageToken="abc"
|
||||
Mock::given(method("GET"))
|
||||
.and(path("/v1/people/me/connections"))
|
||||
.respond_with(ResponseTemplate::new(200).set_body_string(PAGE1))
|
||||
.up_to_n_times(1)
|
||||
.mount(&server)
|
||||
.await;
|
||||
|
||||
// Second request: must carry pageToken=abc — returns page 2 (no token)
|
||||
Mock::given(method("GET"))
|
||||
.and(path("/v1/people/me/connections"))
|
||||
.and(query_param("pageToken", "abc"))
|
||||
.respond_with(ResponseTemplate::new(200).set_body_string(PAGE2))
|
||||
.mount(&server)
|
||||
.await;
|
||||
|
||||
let client = GooglePeopleClient::new("fake-token".to_string())
|
||||
.with_base_url(server.uri());
|
||||
|
||||
let contacts = client.list_all_connections().await.expect("should succeed");
|
||||
|
||||
assert_eq!(contacts.len(), 2, "must collect both pages");
|
||||
let names: Vec<_> = contacts.iter().map(|c| c.display_name.as_str()).collect();
|
||||
assert!(names.contains(&"Alice"), "page 1 contact present");
|
||||
assert!(names.contains(&"Bob"), "page 2 contact present");
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue