/* * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC * SPDX-FileCopyrightText: 2026 John Coffey * * SPDX-License-Identifier: Apache-2.0 OR MIT */ mod integration; use std::collections::HashSet; use integration::radicale::{AccountSeed, Radicale}; use integration::validate::{ all_contact_uids, all_event_uids, assert_contact_round_trip, assert_event_round_trip, calendar_event_count, cleanup, collection_id_by_name, collection_names, common, contact_card_count, count, open_archive, tmp_archive, }; use integration::{Account, Endpoint}; use inbuxa_migrate::error::Error; use inbuxa_migrate::sync::import_dav; use inbuxa_migrate::sync::import_dav::{DavAuth, DavImportConfig, DavKindArg}; use rusqlite::Connection; use serde_json::Value; fn dav_config(kind: DavKindArg, account: &Account, http: &Endpoint) -> DavImportConfig { DavImportConfig { kind, url: format!("http://{}:{}/{}/", http.host, http.port, account.username), auth: DavAuth::Basic { user: account.username.clone(), password: account.password.clone(), }, allow_cleartext: true, dav_connections: 2, multiget_batch: 25, allow_source_change: false, } } #[test] #[ignore = "requires Docker"] fn radicale_starts_seeds_and_imports() { let r = Radicale::start().expect("radicale start"); let per_account = r.seed_all().expect("radicale seed"); assert_eq!( per_account.len(), r.accounts.len(), "seed should return stats for every account" ); r.verify_seed(&per_account).expect("radicale verify"); for seed in &per_account { let account = r .accounts .iter() .find(|a| a.username == seed.username) .expect("account lookup"); assert!( !seed.calendars.is_empty(), "{}: expected calendars seeded", seed.username ); assert!( !seed.address_books.is_empty(), "{}: expected address books seeded", seed.username ); assert!( seed.total_events() > 0, "{}: expected events seeded", seed.username ); assert!( seed.total_contacts() > 0, "{}: expected contacts seeded", seed.username ); let cal_archive = tmp_archive(&format!("radicale-cal-{}", seed.username)); let summary = import_dav::run( common(&cal_archive), dav_config(DavKindArg::Caldav, account, &r.endpoint), ) .expect("caldav import"); assert!( !summary.any_failed(), "{}: first caldav import had failures: {summary:?}", seed.username ); let conn = open_archive(&cal_archive); assert_event_collection_exact(&conn, seed); assert_event_uids_round_trip(&conn, seed); assert_event_data_well_formed(&conn); for plan in &seed.calendars { for item in &plan.items { assert_event_round_trip( &conn, &item.source, &item.uid, &format!("{}/{}", seed.username, plan.display_name), ); } } let cal_count = count(&conn, "calendars") as usize; let ev_count = count(&conn, "calendar_events") as usize; let blob_count = count(&conn, "blobs") as usize; drop(conn); let summary2 = import_dav::run( common(&cal_archive), dav_config(DavKindArg::Caldav, account, &r.endpoint), ) .expect("idempotent caldav re-import"); assert!( !summary2.any_failed(), "{}: idempotent caldav re-import had failures", seed.username ); let conn = open_archive(&cal_archive); assert_eq!( count(&conn, "calendars") as usize, cal_count, "{}: idempotent re-import changed calendar count", seed.username ); assert_eq!( count(&conn, "calendar_events") as usize, ev_count, "{}: idempotent re-import changed event count", seed.username ); assert_eq!( count(&conn, "blobs") as usize, blob_count, "{}: idempotent re-import changed blob count", seed.username ); drop(conn); let card_archive = tmp_archive(&format!("radicale-card-{}", seed.username)); let summary = import_dav::run( common(&card_archive), dav_config(DavKindArg::Carddav, account, &r.endpoint), ) .expect("carddav import"); assert!( !summary.any_failed(), "{}: first carddav import had failures: {summary:?}", seed.username ); let conn = open_archive(&card_archive); assert_contact_collection_exact(&conn, seed); assert_contact_uids_round_trip(&conn, seed); assert_contact_data_well_formed(&conn); for plan in &seed.address_books { for item in &plan.items { assert_contact_round_trip( &conn, &item.source, &item.uid, &format!("{}/{}", seed.username, plan.display_name), ); } } let book_count = count(&conn, "address_books") as usize; let card_count = count(&conn, "contact_cards") as usize; drop(conn); let summary2 = import_dav::run( common(&card_archive), dav_config(DavKindArg::Carddav, account, &r.endpoint), ) .expect("idempotent carddav re-import"); assert!( !summary2.any_failed(), "{}: idempotent carddav re-import had failures", seed.username ); let conn = open_archive(&card_archive); assert_eq!( count(&conn, "address_books") as usize, book_count, "{}: idempotent re-import changed address book count", seed.username ); assert_eq!( count(&conn, "contact_cards") as usize, card_count, "{}: idempotent re-import changed contact count", seed.username ); drop(conn); let vanish_target_href = seed.calendars[0] .items .first() .map(|i| i.href.clone()) .expect("at least one event seeded"); let vanish_uid = seed.calendars[0] .items .first() .map(|i| i.uid.clone()) .expect("uid"); r.delete_item(account, &vanish_target_href) .expect("radicale delete vanished item"); let summary_after_vanish = import_dav::run( common(&cal_archive), dav_config(DavKindArg::Caldav, account, &r.endpoint), ) .expect("caldav re-import after vanish"); assert!( !summary_after_vanish.any_failed(), "{}: re-import after vanish had failures", seed.username ); let conn = open_archive(&cal_archive); let after_vanish_events = count(&conn, "calendar_events") as usize; assert_eq!( after_vanish_events, ev_count - 1, "{}: vanished event was not pruned from archive", seed.username ); let uids_now = all_event_uids(&conn); assert!( !uids_now.contains(&vanish_uid), "{}: vanished event uid {vanish_uid} still in archive", seed.username ); drop(conn); let icals = integration::data::load_icals().expect("load icals"); let (_added_event_href, added_event_uid) = r .add_event(account, 0, &format!("rad-{}", seed.username), &icals) .expect("radicale add event"); let summary_after_add_event = import_dav::run( common(&cal_archive), dav_config(DavKindArg::Caldav, account, &r.endpoint), ) .expect("caldav re-import after add"); assert!( !summary_after_add_event.any_failed(), "{}: re-import after add-event had failures", seed.username ); let conn = open_archive(&cal_archive); let after_add_events = count(&conn, "calendar_events") as usize; assert_eq!( after_add_events, ev_count, "{}: add-then-import should restore count to baseline (was {} after vanish, expected {ev_count})", seed.username, ev_count - 1 ); let uids_now = all_event_uids(&conn); assert!( uids_now.contains(&added_event_uid), "{}: added event uid {added_event_uid} not found in archive after re-import", seed.username ); let added_cal_id = collection_id_by_name(&conn, "calendars", &seed.calendars[0].display_name) .expect("calendar id"); let new_calendar_count = calendar_event_count(&conn, added_cal_id); assert_eq!( new_calendar_count as usize, seed.calendars[0].items.len(), "{}: target calendar event count after add+vanish should match original", seed.username ); drop(conn); let vcards = integration::data::load_vcards().expect("load vcards"); let (_added_card_href, added_card_uid) = r .add_contact(account, 0, &format!("rad-{}", seed.username), &vcards) .expect("radicale add contact"); let summary_after_add_contact = import_dav::run( common(&card_archive), dav_config(DavKindArg::Carddav, account, &r.endpoint), ) .expect("carddav re-import after add"); assert!( !summary_after_add_contact.any_failed(), "{}: re-import after add-contact had failures", seed.username ); let conn = open_archive(&card_archive); let after_add_contacts = count(&conn, "contact_cards") as usize; assert_eq!( after_add_contacts, card_count + 1, "{}: add-then-import should grow contact count by 1", seed.username ); let imported_contact_uids = all_contact_uids(&conn); assert!( imported_contact_uids.contains(&added_card_uid), "{}: added contact uid {added_card_uid} not found", seed.username ); drop(conn); cleanup(&cal_archive); cleanup(&card_archive); } let primary = &r.accounts[0]; let other = &r.accounts[1]; let shared_archive = tmp_archive("radicale-source-change"); import_dav::run( common(&shared_archive), dav_config(DavKindArg::Caldav, primary, &r.endpoint), ) .expect("seed archive with primary user"); let err = import_dav::run( common(&shared_archive), dav_config(DavKindArg::Caldav, other, &r.endpoint), ) .expect_err("expected source-change abort when pointing at second user"); assert!( matches!(err, Error::SourceChange(_)), "expected SourceChange, got {err:?}" ); cleanup(&shared_archive); r.stop().expect("radicale stop"); } fn assert_event_collection_exact(conn: &Connection, seed: &AccountSeed) { let names = collection_names(conn, "calendars"); let expected: HashSet = seed .calendars .iter() .map(|c| c.display_name.clone()) .collect(); assert_eq!( names, expected, "{}: calendar displayname set mismatch", seed.username ); for plan in &seed.calendars { let cal_id = collection_id_by_name(conn, "calendars", &plan.display_name) .unwrap_or_else(|| panic!("calendar {} missing", plan.display_name)); let got = calendar_event_count(conn, cal_id); assert_eq!( got as usize, plan.items.len(), "{}: calendar {} event count {got} != seeded {}", seed.username, plan.display_name, plan.items.len() ); } } fn assert_contact_collection_exact(conn: &Connection, seed: &AccountSeed) { let names = collection_names(conn, "address_books"); let expected: HashSet = seed .address_books .iter() .map(|c| c.display_name.clone()) .collect(); assert_eq!( names, expected, "{}: address book displayname set mismatch", seed.username ); for plan in &seed.address_books { let book_id = collection_id_by_name(conn, "address_books", &plan.display_name) .unwrap_or_else(|| panic!("address book {} missing", plan.display_name)); let got = contact_card_count(conn, book_id); assert_eq!( got as usize, plan.items.len(), "{}: book {} contact count {got} != seeded {}", seed.username, plan.display_name, plan.items.len() ); } } fn assert_event_uids_round_trip(conn: &Connection, seed: &AccountSeed) { let imported = all_event_uids(conn); let expected = seed.event_uids(); let missing: Vec<_> = expected.difference(&imported).collect(); assert!( missing.is_empty(), "{}: event uids missing from archive: {missing:?}", seed.username ); } fn assert_contact_uids_round_trip(conn: &Connection, seed: &AccountSeed) { let imported = all_contact_uids(conn); let expected = seed.contact_uids(); let missing: Vec<_> = expected.difference(&imported).collect(); assert!( missing.is_empty(), "{}: contact uids missing from archive: {missing:?}", seed.username ); } fn assert_event_data_well_formed(conn: &Connection) { let mut stmt = conn .prepare("SELECT data, data_type FROM calendar_events") .expect("prepare"); let rows: Vec<(String, String)> = stmt .query_map([], |r| Ok((r.get::<_, String>(0)?, r.get::<_, String>(1)?))) .expect("query") .filter_map(|r| r.ok()) .collect(); assert!(!rows.is_empty(), "no calendar_events rows"); for (raw, data_type) in rows { assert_eq!( data_type, "Event", "all VEVENT seeds must land as Event, got data_type={data_type}, raw={raw}" ); let v: Value = serde_json::from_str(&raw) .unwrap_or_else(|e| panic!("calendar_events.data invalid JSON: {e}; raw={raw}")); let obj = v.as_object().expect("event data is object"); let kind = obj .get("@type") .or_else(|| obj.get("type")) .and_then(Value::as_str) .unwrap_or(""); assert_eq!(kind, "Event", "JSCalendar @type must be Event, got {kind}"); assert!(obj.contains_key("uid"), "event JSON missing uid: {obj:?}"); assert!( obj.contains_key("start"), "event JSON missing start: {obj:?}" ); } } fn assert_contact_data_well_formed(conn: &Connection) { let mut stmt = conn .prepare("SELECT uid, data FROM contact_cards") .expect("prepare"); let rows: Vec<(String, String)> = stmt .query_map([], |r| Ok((r.get::<_, String>(0)?, r.get::<_, String>(1)?))) .expect("query") .filter_map(|r| r.ok()) .collect(); assert!(!rows.is_empty(), "no contact_cards rows"); for (uid, raw) in rows { assert!(!uid.is_empty(), "contact uid empty"); let v: Value = serde_json::from_str(&raw) .unwrap_or_else(|e| panic!("contact_cards.data invalid JSON: {e}; raw={raw}")); let obj = v.as_object().expect("contact data is object"); let kind = obj .get("@type") .or_else(|| obj.get("type")) .and_then(Value::as_str) .unwrap_or(""); assert_eq!(kind, "Card", "JSContact @type must be Card, got {kind}"); } }