intermittent commit
This commit is contained in:
901
tests/test_addresses_service.py
Normal file
901
tests/test_addresses_service.py
Normal file
@@ -0,0 +1,901 @@
|
||||
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()
|
||||
Reference in New Issue
Block a user