from __future__ import annotations import unittest from sqlalchemy import create_engine from sqlalchemy.orm import sessionmaker from govoplan_core.core.change_sequence import ChangeSequenceEntry from govoplan_core.db.base import Base from govoplan_core.db.base import utcnow from govoplan_addresses.backend.carddav import AddressCardDAVObject, AddressCardDAVReportResult, AddressCardDAVWriteResult from govoplan_addresses.backend.capabilities import ( CAPABILITY_ADDRESSES_CONTACT_WRITER, CAPABILITY_ADDRESSES_LOOKUP, CAPABILITY_ADDRESSES_RECIPIENT_SOURCE, AddressesContactWriterCapability, AddressesLookupCapability, AddressesRecipientSourceCapability, AddressWriterError, ) from govoplan_addresses.backend.db.models import ( AddressBook, AddressList, AddressListEntry, AddressSyncConflict, AddressSyncDiagnostic, AddressSyncSource, AddressSyncTombstone, Contact, ContactEmail, ContactPhone, ContactPostalAddress, ) from govoplan_addresses.backend.schemas import ( AddressBookCreateRequest, AddressListCreateRequest, AddressListEntryCreateRequest, AddressCardDavSourceCreateRequest, AddressSyncAttemptFinishRequest, AddressSyncConflictCreateRequest, AddressSyncConflictResolveRequest, AddressSyncDiagnosticCreateRequest, AddressSyncSourceCreateRequest, AddressSyncSourceUpdateRequest, AddressSyncTombstoneCreateRequest, ContactCreateRequest, ContactEmailPayload, ContactPostalAddressPayload, ) from govoplan_addresses.backend.manifest import manifest from govoplan_addresses.backend.service import ( address_book_contact_counts, address_list_entry_counts, create_address_book, create_address_list, create_address_list_entry, create_carddav_sync_source, create_contact, create_sync_source, delete_address_list_entry, delete_contact, delete_sync_source, export_address_book_vcard, import_vcards, list_address_list_entries, list_address_lists, list_address_books, list_contacts, list_sync_conflicts, list_sync_diagnostics, list_sync_sources, list_sync_tombstones, record_sync_conflict, record_sync_diagnostic, record_sync_tombstone, run_sync_source, preview_sync_source, restore_contact, resolve_sync_conflict, start_sync_attempt, finish_sync_attempt, update_sync_source, ) class FakeCardDavClient: def __init__(self, objects: list[AddressCardDAVObject], *, sync_token: str = "sync-token-1", ctag: str = "ctag-1") -> None: self.objects = objects self.sync_token = sync_token self.ctag = ctag self.puts: list[dict[str, object]] = [] self.deletes: list[dict[str, object]] = [] def propfind_collection(self) -> AddressCardDAVReportResult: return AddressCardDAVReportResult(sync_token=self.sync_token, ctag=self.ctag) def list_objects(self) -> AddressCardDAVReportResult: return AddressCardDAVReportResult(objects=self.objects, sync_token=self.sync_token, ctag=self.ctag) def sync_collection(self, _sync_token: str) -> AddressCardDAVReportResult: return self.list_objects() def fetch_object(self, href: str) -> str: for item in self.objects: if item.href == href and item.address_data: return item.address_data raise RuntimeError(f"missing fake CardDAV object: {href}") def put_object(self, href: str, vcard: str, *, etag: str | None = None, create: bool = False, overwrite: bool = False) -> AddressCardDAVWriteResult: self.puts.append({"href": href, "vcard": vcard, "etag": etag, "create": create, "overwrite": overwrite}) return AddressCardDAVWriteResult(href=href, etag=f'"written-{len(self.puts)}"', status=201 if create else 204) def delete_object(self, href: str, *, etag: str | None = None, overwrite: bool = False) -> AddressCardDAVWriteResult: self.deletes.append({"href": href, "etag": etag, "overwrite": overwrite}) return AddressCardDAVWriteResult(href=href, etag=None, status=204) class Principal: account_id = "account-1" group_ids = frozenset({"group-1"}) @property def tenant_id(self) -> str: return "tenant-1" def has(self, scope: str) -> bool: return scope in { "addresses:address_book:read", "addresses:address_book:write", "addresses:address_list:read", "addresses:address_list:write", "addresses:address_list:delete", "addresses:contact:read", "addresses:contact:write", "addresses:contact:delete", "addresses:sync:read", "addresses:sync:write", } class ReadOnlyPrincipal(Principal): def has(self, scope: str) -> bool: return scope in { "addresses:address_book:read", "addresses:contact:read", } class AddressServiceTest(unittest.TestCase): def setUp(self) -> None: engine = create_engine("sqlite:///:memory:") Base.metadata.create_all( engine, tables=[ AddressBook.__table__, AddressList.__table__, Contact.__table__, ContactEmail.__table__, ContactPhone.__table__, ContactPostalAddress.__table__, AddressListEntry.__table__, AddressSyncSource.__table__, AddressSyncTombstone.__table__, AddressSyncConflict.__table__, AddressSyncDiagnostic.__table__, ChangeSequenceEntry.__table__, ], ) self.session = sessionmaker(bind=engine)() self.principal = Principal() def test_scoped_book_contact_lookup_and_soft_delete(self) -> None: book = create_address_book(self.session, self.principal, AddressBookCreateRequest(scope_type="user", name="Personal")) self.session.commit() self.session.refresh(book) contact = create_contact( self.session, self.principal, book.id, ContactCreateRequest(given_name="Ada", family_name="Lovelace", emails=[ContactEmailPayload(email="ada@example.local")]), ) self.session.commit() self.session.refresh(contact) self.assertEqual([item.id for item in list_address_books(self.session, self.principal)], [book.id]) self.assertEqual(address_book_contact_counts(self.session, [book.id])[book.id], 1) self.assertEqual([item.id for item in list_contacts(self.session, self.principal, query="ada")], [contact.id]) self.assertEqual(contact.emails[0].email, "ada@example.local") self.assertTrue(contact.emails[0].is_primary) delete_contact(self.session, self.principal, contact.id) self.session.commit() self.assertEqual(list_contacts(self.session, self.principal, address_book_id=book.id), []) self.assertEqual(address_book_contact_counts(self.session, [book.id]).get(book.id, 0), 0) archived = list_contacts(self.session, self.principal, address_book_id=book.id, include_deleted=True) self.assertEqual([item.id for item in archived], [contact.id]) restore_contact(self.session, self.principal, contact.id) self.session.commit() self.assertEqual([item.id for item in list_contacts(self.session, self.principal, address_book_id=book.id)], [contact.id]) def test_vcard_import_and_export_preserves_common_fields(self) -> None: book = create_address_book(self.session, self.principal, AddressBookCreateRequest(scope_type="user", name="Imported")) self.session.commit() self.session.refresh(book) result = import_vcards( self.session, self.principal, book.id, """BEGIN:VCARD VERSION:4.0 FN:Ada Lovelace N:Lovelace;Ada;;; ORG:Analytical Engine Office TITLE:Mathematician EMAIL;TYPE=work:ada@example.local TEL;TYPE=work:+49 30 123 ADR;TYPE=work:;;Main Street 1;Berlin;BE;10115;Germany CATEGORIES:science,engineering NOTE:First programmer URL:https://example.local/ada END:VCARD """, ) self.session.commit() self.session.refresh(result.contacts[0]) self.assertEqual(result.skipped, 0) self.assertEqual(result.issues, []) contact = result.contacts[0] self.assertEqual(contact.display_name, "Ada Lovelace") self.assertEqual(contact.organization, "Analytical Engine Office") self.assertEqual(contact.role_title, "Mathematician") self.assertEqual(contact.tags, ["science", "engineering"]) self.assertEqual(contact.emails[0].label, "work") self.assertEqual(contact.phones[0].phone, "+49 30 123") self.assertEqual(contact.postal_addresses[0].locality, "Berlin") self.assertEqual(contact.source_kind, "vcard") self.assertEqual(contact.source_payload_kind, "vcard") self.assertIn("BEGIN:VCARD", contact.source_payload_raw) self.assertEqual(contact.provenance["vcard"]["urls"], ["https://example.local/ada"]) _book, content = export_address_book_vcard(self.session, self.principal, book.id) self.assertIn("BEGIN:VCARD", content) self.assertIn("FN:Ada Lovelace", content) self.assertIn("EMAIL;TYPE=work:ada@example.local", content) self.assertIn("CATEGORIES:science,engineering", content) self.assertIn("URL:https://example.local/ada", content) def test_vcard_import_reports_field_issues_without_blocking_valid_cards(self) -> None: book = create_address_book(self.session, self.principal, AddressBookCreateRequest(scope_type="user", name="Imported")) self.session.commit() self.session.refresh(book) result = import_vcards( self.session, self.principal, book.id, """BEGIN:VCARD VERSION:4.0 FN:Valid Person EMAIL:not-an-email EMAIL;TYPE=work:valid@example.local END:VCARD BEGIN:VCARD VERSION:4.0 NOTE:No identity END:VCARD """, ) self.session.commit() self.assertEqual(len(result.contacts), 1) self.assertEqual(result.skipped, 1) self.assertEqual(result.contacts[0].emails[0].email, "valid@example.local") self.assertTrue(any(issue.field == "EMAIL" and issue.severity == "warning" for issue in result.issues)) self.assertTrue(any(issue.severity == "error" for issue in result.issues)) def test_manifest_and_address_capabilities(self) -> None: provided = {item.name for item in manifest.provides_interfaces} self.assertIn(CAPABILITY_ADDRESSES_LOOKUP, provided) self.assertIn(CAPABILITY_ADDRESSES_RECIPIENT_SOURCE, provided) self.assertIn(CAPABILITY_ADDRESSES_CONTACT_WRITER, provided) book = create_address_book(self.session, self.principal, AddressBookCreateRequest(scope_type="user", name="Recipients")) self.session.commit() self.session.refresh(book) contact = create_contact( self.session, self.principal, book.id, ContactCreateRequest(display_name="Ada Lovelace", emails=[ContactEmailPayload(email="ada@example.local")]), ) self.session.commit() self.session.refresh(contact) lookup = AddressesLookupCapability().lookup(self.session, self.principal, query="ada") self.assertEqual(len(lookup), 1) self.assertEqual(lookup[0].email, "ada@example.local") self.assertEqual(lookup[0].contact_id, contact.id) warm_lookup = AddressesLookupCapability().lookup(self.session, self.principal, query="", limit=10) self.assertEqual([item.email for item in warm_lookup], ["ada@example.local"]) recipient_sources = AddressesRecipientSourceCapability().list_sources(self.session, self.principal) self.assertEqual(len(recipient_sources), 1) self.assertEqual(recipient_sources[0].source_id, f"addresses:address_book:{book.id}") self.assertEqual(recipient_sources[0].recipient_count, 1) snapshot = AddressesRecipientSourceCapability().snapshot_address_book(self.session, self.principal, address_book_id=book.id) self.assertEqual(snapshot.source_id, f"addresses:address_book:{book.id}") self.assertEqual(snapshot.recipients[0].email, "ada@example.local") self.assertEqual(snapshot.recipients[0].provenance["contact_id"], contact.id) writer = AddressesContactWriterCapability() targets = writer.list_write_targets(self.session, self.principal) self.assertEqual(len(targets), 1) self.assertTrue(targets[0].allowed) self.assertEqual(targets[0].reason, "allowed") self.assertEqual(targets[0].required_scopes, ("addresses:address_book:read", "addresses:contact:write")) created = writer.create_contact( self.session, self.principal, address_book_id=book.id, payload=ContactCreateRequest(display_name="Grace Hopper", emails=[ContactEmailPayload(email="grace@example.local")]), provenance={"consumer_module": "test"}, ) self.assertEqual(created.email, "grace@example.local") self.assertEqual(created.provenance["source_kind"], "local") self.assertEqual(created.provenance["contact_provenance"]["consumer_module"], "test") self.session.commit() missing_scope = writer.can_write_to_address_book(self.session, ReadOnlyPrincipal(), address_book_id=book.id) self.assertFalse(missing_scope.allowed) self.assertEqual(missing_scope.reason, "missing_scope") self.assertEqual(missing_scope.required_scopes, ("addresses:address_book:read", "addresses:contact:write")) book.read_only = True self.session.commit() read_only = writer.can_write_to_address_book(self.session, self.principal, address_book_id=book.id) self.assertFalse(read_only.allowed) self.assertEqual(read_only.reason, "address_book_read_only") self.assertTrue(read_only.read_only) with self.assertRaises(AddressWriterError) as blocked: writer.create_contact( self.session, self.principal, address_book_id=book.id, payload=ContactCreateRequest(display_name="Blocked Contact", emails=[ContactEmailPayload(email="blocked@example.local")]), ) self.assertEqual(blocked.exception.decision.reason, "address_book_read_only") def test_address_lists_group_contacts_and_expose_recipient_sources(self) -> None: book = create_address_book(self.session, self.principal, AddressBookCreateRequest(scope_type="user", name="Personal")) other_book = create_address_book(self.session, self.principal, AddressBookCreateRequest(scope_type="user", name="Other")) self.session.commit() contact = create_contact( self.session, self.principal, book.id, ContactCreateRequest( display_name="Ada Lovelace", emails=[ ContactEmailPayload(label="work", email="ada.work@example.local", is_primary=True), ContactEmailPayload(label="private", email="ada.private@example.local"), ], postal_addresses=[ ContactPostalAddressPayload(label="office", street="Main Street 1", postal_code="10115", locality="Berlin", country="Germany", is_primary=True), ], ), ) other_contact = create_contact( self.session, self.principal, other_book.id, ContactCreateRequest(display_name="Grace Hopper", emails=[ContactEmailPayload(email="grace@example.local")]), ) self.session.commit() self.session.refresh(contact) self.session.refresh(other_contact) address_list = create_address_list(self.session, self.principal, book.id, AddressListCreateRequest(name="Mathematicians")) self.session.commit() self.session.refresh(address_list) entry = create_address_list_entry( self.session, self.principal, address_list.id, AddressListEntryCreateRequest(contact_id=contact.id, contact_email_id=contact.emails[1].id, label="Private address"), ) postal_entry = create_address_list_entry( self.session, self.principal, address_list.id, AddressListEntryCreateRequest(contact_id=contact.id, contact_postal_address_id=contact.postal_addresses[0].id, label="Office address"), ) self.session.commit() self.session.refresh(entry) self.session.refresh(postal_entry) self.assertEqual([item.id for item in list_address_lists(self.session, self.principal, address_book_id=book.id)], [address_list.id]) self.assertEqual(address_list_entry_counts(self.session, [address_list.id])[address_list.id], 2) entries = list_address_list_entries(self.session, self.principal, address_list.id) self.assertEqual(entries[0].contact_email.email, "ada.private@example.local") self.assertEqual(entries[1].target_kind, "postal_address") self.assertEqual(entries[1].contact_postal_address.locality, "Berlin") with self.assertRaisesRegex(ValueError, "same address book"): create_address_list_entry( self.session, self.principal, address_list.id, AddressListEntryCreateRequest(contact_id=other_contact.id), ) recipient_source = AddressesRecipientSourceCapability() sources = recipient_source.list_sources(self.session, self.principal) self.assertIn(f"addresses:address_list:{address_list.id}", {source.source_id for source in sources}) snapshot = recipient_source.snapshot(self.session, self.principal, source_id=f"addresses:address_list:{address_list.id}") self.assertEqual(snapshot.source_kind, "address_list") self.assertEqual(len(snapshot.recipients), 1) self.assertEqual(snapshot.recipients[0].email, "ada.private@example.local") self.assertEqual(snapshot.recipients[0].provenance["address_list_id"], address_list.id) self.assertEqual(snapshot.recipients[0].provenance["address_list_entry_id"], entry.id) delete_address_list_entry(self.session, self.principal, entry.id) delete_address_list_entry(self.session, self.principal, postal_entry.id) self.session.commit() self.assertEqual(list_address_list_entries(self.session, self.principal, address_list.id), []) def test_sync_source_marks_read_only_books_and_can_be_made_writable(self) -> None: book = create_address_book(self.session, self.principal, AddressBookCreateRequest(scope_type="user", name="CardDAV")) self.session.commit() self.session.refresh(book) sync_source = create_sync_source( self.session, self.principal, book.id, AddressSyncSourceCreateRequest( connector_type="carddav", display_name="CardDAV Personal", external_account_ref="account://carddav/personal", external_address_book_ref="/remote/addressbooks/personal/", sync_direction="read_only", metadata={"base_url": "https://carddav.example.local"}, ), ) self.session.commit() self.session.refresh(book) self.session.refresh(sync_source) self.assertEqual(book.source_kind, "carddav") self.assertEqual(book.source_ref, sync_source.id) self.assertTrue(book.read_only) self.assertEqual(book.sync_status, "idle") self.assertEqual([item.id for item in list_sync_sources(self.session, self.principal)], [sync_source.id]) decision = AddressesContactWriterCapability().can_write_to_address_book(self.session, self.principal, address_book_id=book.id) self.assertFalse(decision.allowed) self.assertEqual(decision.reason, "address_book_read_only") with self.assertRaisesRegex(ValueError, "read-only"): create_contact( self.session, self.principal, book.id, ContactCreateRequest(display_name="Read Only", emails=[ContactEmailPayload(email="readonly@example.local")]), ) sync_source = update_sync_source( self.session, self.principal, sync_source.id, AddressSyncSourceUpdateRequest(sync_direction="two_way", read_only=False, metadata={"base_url": "https://carddav.example.local"}), ) self.session.commit() self.session.refresh(book) self.session.refresh(sync_source) self.assertFalse(sync_source.read_only) self.assertFalse(book.read_only) self.assertTrue(AddressesContactWriterCapability().can_write_to_address_book(self.session, self.principal, address_book_id=book.id).allowed) def test_sync_attempts_diagnostics_tombstones_and_conflicts_are_persisted(self) -> None: book = create_address_book(self.session, self.principal, AddressBookCreateRequest(scope_type="user", name="Synced")) self.session.commit() self.session.refresh(book) contact = create_contact( self.session, self.principal, book.id, ContactCreateRequest(display_name="Ada Lovelace", emails=[ContactEmailPayload(email="ada@example.local")]), ) sync_source = create_sync_source( self.session, self.principal, book.id, AddressSyncSourceCreateRequest(connector_type="carddav", display_name="Writable CardDAV", sync_direction="two_way", read_only=False), ) self.session.commit() self.session.refresh(contact) self.session.refresh(sync_source) start_sync_attempt(self.session, self.principal, sync_source.id) self.session.commit() self.session.refresh(sync_source) self.assertEqual(sync_source.status, "running") finish_sync_attempt( self.session, self.principal, sync_source.id, AddressSyncAttemptFinishRequest(status="succeeded", sync_token="sync-token-1", etag="etag-1", remote_revision="rev-1"), ) diagnostic = record_sync_diagnostic( self.session, self.principal, sync_source.id, AddressSyncDiagnosticCreateRequest(severity="warning", code="carddav.rate_limit", message="Remote server asked us to slow down."), ) tombstone = record_sync_tombstone( self.session, self.principal, sync_source.id, AddressSyncTombstoneCreateRequest(contact_id=contact.id, remote_uid="ada-uid", resource_href="/contacts/ada.vcf"), ) conflict = record_sync_conflict( self.session, self.principal, sync_source.id, AddressSyncConflictCreateRequest( contact_id=contact.id, remote_uid="ada-uid", resource_href="/contacts/ada.vcf", field_path="emails[0].email", local_value={"email": "ada@example.local"}, remote_value={"email": "ada.remote@example.local"}, ), ) self.session.commit() self.session.refresh(sync_source) self.session.refresh(diagnostic) self.session.refresh(tombstone) self.session.refresh(conflict) self.assertEqual(sync_source.sync_token, "sync-token-1") self.assertEqual(sync_source.status, "conflict") self.assertEqual(list_sync_diagnostics(self.session, self.principal, sync_source.id)[0].id, diagnostic.id) self.assertEqual(list_sync_tombstones(self.session, self.principal, sync_source.id)[0].id, tombstone.id) self.assertEqual(list_sync_conflicts(self.session, self.principal, sync_source.id)[0].id, conflict.id) resolved = resolve_sync_conflict( self.session, self.principal, conflict.id, AddressSyncConflictResolveRequest(resolution="manual", status="resolved"), ) self.session.commit() self.session.refresh(sync_source) self.session.refresh(resolved) self.assertEqual(resolved.status, "resolved") self.assertEqual(resolved.resolution, "manual") self.assertEqual(list_sync_conflicts(self.session, self.principal, sync_source.id), []) self.assertEqual(sync_source.status, "idle") def test_sync_conflict_merge_resolution_applies_merged_payload(self) -> None: book = create_address_book(self.session, self.principal, AddressBookCreateRequest(scope_type="user", name="Merged")) self.session.commit() self.session.refresh(book) contact = create_contact( self.session, self.principal, book.id, ContactCreateRequest(display_name="Local Name", emails=[ContactEmailPayload(email="local@example.local")]), ) sync_source = create_sync_source( self.session, self.principal, book.id, AddressSyncSourceCreateRequest(connector_type="carddav", display_name="Writable CardDAV", sync_direction="two_way", read_only=False), ) self.session.commit() self.session.refresh(contact) self.session.refresh(sync_source) local_payload = { "display_name": "Local Name", "emails": [{"label": None, "email": "local@example.local", "is_primary": True}], "phones": [], "postal_addresses": [], "tags": [], } remote_payload = { "display_name": "Remote Name", "emails": [{"label": None, "email": "remote@example.local", "is_primary": True}], "phones": [], "postal_addresses": [], "tags": [], } conflict = record_sync_conflict( self.session, self.principal, sync_source.id, AddressSyncConflictCreateRequest( contact_id=contact.id, resource_href="/contacts/local.vcf", field_path="vcard", local_value={"payload": local_payload}, remote_value={"payload": remote_payload, "etag": '"etag-remote"'}, metadata={"remote_payload": remote_payload, "source_revision": '"etag-remote"'}, ), ) self.session.commit() self.session.refresh(conflict) resolve_sync_conflict( self.session, self.principal, conflict.id, AddressSyncConflictResolveRequest( resolution="merge", status="resolved", merged_payload=ContactCreateRequest(display_name="Local Name", emails=[ContactEmailPayload(email="remote@example.local")]), ), ) self.session.commit() self.session.refresh(contact) self.assertEqual(contact.display_name, "Local Name") self.assertEqual(contact.emails[0].email, "remote@example.local") self.assertEqual(contact.source_revision, '"etag-remote"') def test_carddav_run_imports_contacts_and_preview_detects_conflicts(self) -> None: book = create_address_book(self.session, self.principal, AddressBookCreateRequest(scope_type="user", name="Remote")) self.session.commit() self.session.refresh(book) sync_source = create_carddav_sync_source( self.session, self.principal, book.id, AddressCardDavSourceCreateRequest( collection_url="https://carddav.example.local/addressbooks/personal/", display_name="Remote CardDAV", auth_type="none", sync_direction="two_way", read_only=False, ), ) self.session.commit() self.session.refresh(sync_source) first_client = FakeCardDavClient([ AddressCardDAVObject( href="/addressbooks/personal/ada.vcf", etag='"etag-1"', address_data="""BEGIN:VCARD VERSION:4.0 UID:ada-uid FN:Ada Lovelace EMAIL:ada@example.local END:VCARD """, ) ]) plan = run_sync_source(self.session, self.principal, sync_source.id, client=first_client) self.session.commit() self.session.refresh(sync_source) self.assertEqual(plan.stats.created, 1) self.assertEqual(sync_source.status, "succeeded") contacts = list_contacts(self.session, self.principal, address_book_id=book.id) self.assertEqual(len(contacts), 1) self.assertEqual(contacts[0].display_name, "Ada Lovelace") self.assertEqual(contacts[0].source_kind, "carddav") self.assertEqual(contacts[0].source_ref, "/addressbooks/personal/ada.vcf") self.assertEqual(contacts[0].source_revision, '"etag-1"') contacts[0].display_name = "Ada Local" contacts[0].updated_at = utcnow() self.session.commit() conflict_client = FakeCardDavClient( [ AddressCardDAVObject( href="/addressbooks/personal/ada.vcf", etag='"etag-2"', address_data="""BEGIN:VCARD VERSION:4.0 UID:ada-uid FN:Ada Remote EMAIL:ada@example.local END:VCARD """, ) ], sync_token="sync-token-2", ctag="ctag-2", ) preview = preview_sync_source(self.session, self.principal, sync_source.id, client=conflict_client) self.assertEqual(preview.stats.conflicts, 1) self.assertEqual(preview.items[0].action, "conflict") self.assertEqual(preview.items[0].contact_id, contacts[0].id) run_conflict_client = FakeCardDavClient( [ AddressCardDAVObject( href="/addressbooks/personal/ada.vcf", etag='"etag-2"', address_data="""BEGIN:VCARD VERSION:4.0 UID:ada-uid FN:Ada Remote EMAIL:ada.remote@example.local END:VCARD """, ) ], sync_token="sync-token-2", ctag="ctag-2", ) run_sync_source(self.session, self.principal, sync_source.id, client=run_conflict_client) self.session.commit() conflicts = list_sync_conflicts(self.session, self.principal, sync_source.id) self.assertEqual(len(conflicts), 1) self.assertIn("payload", conflicts[0].remote_value) resolved = resolve_sync_conflict( self.session, self.principal, conflicts[0].id, AddressSyncConflictResolveRequest(resolution="use_remote", status="resolved"), ) self.session.commit() self.session.refresh(resolved) self.session.refresh(contacts[0]) self.assertEqual(resolved.status, "resolved") self.assertEqual(contacts[0].display_name, "Ada Remote") self.assertEqual(contacts[0].emails[0].email, "ada.remote@example.local") def test_carddav_two_way_pushes_local_creates_updates_and_deletes(self) -> None: book = create_address_book(self.session, self.principal, AddressBookCreateRequest(scope_type="user", name="Writable Remote")) self.session.commit() self.session.refresh(book) sync_source = create_carddav_sync_source( self.session, self.principal, book.id, AddressCardDavSourceCreateRequest( collection_url="https://carddav.example.local/addressbooks/personal/", display_name="Writable CardDAV", auth_type="none", sync_direction="two_way", read_only=False, ), ) self.session.commit() self.session.refresh(book) self.session.refresh(sync_source) contact = create_contact( self.session, self.principal, book.id, ContactCreateRequest(display_name="Grace Hopper", emails=[ContactEmailPayload(email="grace@example.local")]), ) self.session.commit() self.session.refresh(contact) create_client = FakeCardDavClient([], sync_token="sync-token-create", ctag="ctag-create") create_plan = run_sync_source(self.session, self.principal, sync_source.id, client=create_client) self.session.commit() self.session.refresh(contact) self.session.refresh(sync_source) self.assertEqual(create_plan.stats.created, 1) self.assertEqual(create_plan.items[-1].action, "remote_create") self.assertEqual(len(create_client.puts), 1) self.assertTrue(create_client.puts[0]["create"]) self.assertEqual(contact.source_ref, f"/addressbooks/personal/{contact.id}.vcf") self.assertEqual(contact.source_revision, '"written-1"') contact.display_name = "Grace Updated" contact.updated_at = utcnow() self.session.commit() self.session.refresh(contact) update_client = FakeCardDavClient( [ AddressCardDAVObject( href=contact.source_ref or "", etag=contact.source_revision, address_data="""BEGIN:VCARD VERSION:4.0 FN:Grace Hopper EMAIL:grace@example.local END:VCARD """, ) ], sync_token="sync-token-update", ctag="ctag-update", ) update_plan = run_sync_source(self.session, self.principal, sync_source.id, client=update_client) self.session.commit() self.session.refresh(contact) self.session.refresh(sync_source) self.assertEqual(update_plan.stats.updated, 1) self.assertEqual(update_plan.items[-1].action, "remote_update") self.assertEqual(len(update_client.puts), 1) self.assertFalse(update_client.puts[0]["create"]) self.assertEqual(update_client.puts[0]["etag"], '"written-1"') self.assertIn("FN:Grace Updated", str(update_client.puts[0]["vcard"])) delete_contact(self.session, self.principal, contact.id) self.session.commit() self.session.refresh(contact) delete_client = FakeCardDavClient( [ AddressCardDAVObject( href=contact.source_ref or "", etag=contact.source_revision, address_data="""BEGIN:VCARD VERSION:4.0 FN:Grace Updated EMAIL:grace@example.local END:VCARD """, ) ], sync_token="sync-token-delete", ctag="ctag-delete", ) delete_plan = run_sync_source(self.session, self.principal, sync_source.id, client=delete_client) self.session.commit() self.assertEqual(delete_plan.stats.deleted, 1) self.assertEqual(delete_plan.items[-1].action, "remote_delete") self.assertEqual(delete_client.deletes, [{"href": f"/addressbooks/personal/{contact.id}.vcf", "etag": contact.source_revision, "overwrite": False}]) self.assertEqual(len(list_sync_tombstones(self.session, self.principal, sync_source.id)), 1) def test_delete_sync_source_disconnects_without_removing_contacts(self) -> None: book = create_address_book(self.session, self.principal, AddressBookCreateRequest(scope_type="user", name="Disconnectable")) self.session.commit() self.session.refresh(book) sync_source = create_carddav_sync_source( self.session, self.principal, book.id, AddressCardDavSourceCreateRequest( collection_url="https://carddav.example.local/addressbooks/personal/", display_name="Disconnectable CardDAV", auth_type="none", sync_direction="two_way", read_only=False, ), ) contact = create_contact( self.session, self.principal, book.id, ContactCreateRequest(display_name="Local Contact", emails=[ContactEmailPayload(email="local@example.local")]), ) self.session.commit() self.session.refresh(book) self.session.refresh(sync_source) self.session.refresh(contact) self.assertEqual(book.source_kind, "carddav") self.assertEqual(book.source_ref, sync_source.id) self.assertEqual(len(list_sync_sources(self.session, self.principal, include_disabled=True)), 1) delete_sync_source(self.session, self.principal, sync_source.id) self.session.commit() self.session.refresh(book) self.session.refresh(contact) self.assertEqual(list_sync_sources(self.session, self.principal, include_disabled=True), []) self.assertEqual(book.source_kind, "local") self.assertIsNone(book.source_ref) self.assertFalse(book.read_only) self.assertEqual([item.id for item in list_contacts(self.session, self.principal, address_book_id=book.id)], [contact.id]) if __name__ == "__main__": unittest.main()