Files
govoplan-addresses/tests/test_addresses_service.py
2026-07-14 13:22:09 +02:00

902 lines
37 KiB
Python

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()