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)
118 lines
4.3 KiB
Rust
118 lines
4.3 KiB
Rust
// SPDX-License-Identifier: Apache-2.0
|
|
// Copyright 2026 <author org>
|
|
//! Integration tests for `GooglePeopleClient` against a wiremock server.
|
|
|
|
use kei_contacts_google::{ContactsError, GooglePeopleClient};
|
|
use wiremock::matchers::{header_exists, method, path, query_param};
|
|
use wiremock::{Mock, MockServer, ResponseTemplate};
|
|
|
|
const SAMPLE_RESPONSE: &str = r#"{
|
|
"connections": [
|
|
{
|
|
"resourceName": "people/c111",
|
|
"names": [{"displayName": "Alice Smith", "givenName": "Alice", "familyName": "Smith"}],
|
|
"emailAddresses": [{"value": "alice@example.com"}],
|
|
"phoneNumbers": [{"value": "+1-555-0101"}],
|
|
"organizations": [{"name": "ACME"}],
|
|
"biographies": [{"value": "Engineer"}]
|
|
},
|
|
{
|
|
"resourceName": "people/c222",
|
|
"names": [{"displayName": "Bob Jones", "givenName": "Bob", "familyName": "Jones"}],
|
|
"emailAddresses": [{"value": "bob@example.com"}, {"value": "bob2@example.com"}],
|
|
"phoneNumbers": [],
|
|
"organizations": [],
|
|
"biographies": []
|
|
}
|
|
],
|
|
"nextPageToken": "tok123"
|
|
}"#;
|
|
|
|
#[tokio::test]
|
|
async fn list_connections_parses_real_response() {
|
|
let server = MockServer::start().await;
|
|
Mock::given(method("GET"))
|
|
.and(path("/v1/people/me/connections"))
|
|
.and(header_exists("Authorization"))
|
|
.respond_with(ResponseTemplate::new(200).set_body_string(SAMPLE_RESPONSE))
|
|
.mount(&server)
|
|
.await;
|
|
|
|
let client = GooglePeopleClient::new("fake-token".to_string())
|
|
.with_base_url(server.uri());
|
|
let contacts = client.list_connections().await.expect("should succeed");
|
|
|
|
assert_eq!(contacts.len(), 2);
|
|
let alice = &contacts[0];
|
|
assert_eq!(alice.resource_name, "people/c111");
|
|
assert_eq!(alice.display_name, "Alice Smith");
|
|
assert_eq!(alice.emails, vec!["alice@example.com"]);
|
|
assert_eq!(alice.phones, vec!["+1-555-0101"]);
|
|
assert_eq!(alice.organization, "ACME");
|
|
assert_eq!(alice.bio, "Engineer");
|
|
|
|
let bob = &contacts[1];
|
|
assert_eq!(bob.emails.len(), 2);
|
|
assert_eq!(bob.phones.len(), 0);
|
|
assert_eq!(bob.organization, "");
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn auth_error_on_401() {
|
|
let server = MockServer::start().await;
|
|
Mock::given(method("GET"))
|
|
.and(path("/v1/people/me/connections"))
|
|
.respond_with(ResponseTemplate::new(401))
|
|
.mount(&server)
|
|
.await;
|
|
|
|
let client = GooglePeopleClient::new("expired-token".to_string())
|
|
.with_base_url(server.uri());
|
|
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");
|
|
}
|