Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 0 additions & 1 deletion src/door_sync/civicrm/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,6 @@
_CONTACT_BATCH_SIZE = (
500 # Caps contact_ids per Membership.get IN clause to keep request body bounded
)
_DEFAULT_ACTIVE_STATUSES = ("Current", "Grace", "New")
_MAX_PAGES = 1_000 # 250,000 records — far above any plausible deployment
_MAX_ATTEMPTS = 3

Expand Down
24 changes: 16 additions & 8 deletions tests/test_alert.py
Original file line number Diff line number Diff line change
Expand Up @@ -116,10 +116,12 @@ def test_raise_mailgun_sends_post(tmp_path: Path) -> None:
cfg = AlertConfig(transport="mailgun", smtp=None, mailgun=_mailgun_config())

with patch("door_sync.alert.httpx.post") as mock_post:
mock_post.return_value.raise_for_status = lambda: None
alert.raise_("safety halt", path=path, alert_config=cfg)

mock_post.assert_called_once()
# The Mailgun response status must be checked (a 4xx/5xx should surface as a
# send failure, not a silent success).
mock_post.return_value.raise_for_status.assert_called_once()
call_kwargs = mock_post.call_args
# Assert the exact Mailgun endpoint, not a loose substring: the domain must
# sit in the API path (https://api.mailgun.net/v3/<domain>/messages), which
Expand All @@ -137,10 +139,10 @@ def test_clear_mailgun_sends_resolved(tmp_path: Path) -> None:
cfg = AlertConfig(transport="mailgun", smtp=None, mailgun=_mailgun_config())

with patch("door_sync.alert.httpx.post") as mock_post:
mock_post.return_value.raise_for_status = lambda: None
alert.clear(path=path, alert_config=cfg)

mock_post.assert_called_once()
mock_post.return_value.raise_for_status.assert_called_once()
assert mock_post.call_args.kwargs["data"]["subject"] == "[door-sync] RESOLVED"
assert not path.exists()

Expand Down Expand Up @@ -183,9 +185,11 @@ def test_raise_smtp_sends_email(tmp_path: Path) -> None:
cfg = AlertConfig(transport="smtp", smtp=_smtp_config(), mailgun=None)

with patch("door_sync.alert.smtplib.SMTP") as mock_cls:
# mock_cls.return_value is a MagicMock, which already supports the
# context-manager protocol (and __exit__ returns False, so it won't
# swallow exceptions). _send_smtp uses `with server:` (no `as`), so no
# __enter__/__exit__ setup is needed.
mock_server = mock_cls.return_value
mock_server.__enter__ = lambda s: s
mock_server.__exit__ = lambda s, *a: None
alert.raise_("safety halt", path=path, alert_config=cfg)

mock_server.starttls.assert_called_once()
Expand All @@ -202,9 +206,11 @@ def test_clear_smtp_sends_resolved(tmp_path: Path) -> None:
cfg = AlertConfig(transport="smtp", smtp=_smtp_config(), mailgun=None)

with patch("door_sync.alert.smtplib.SMTP") as mock_cls:
# mock_cls.return_value is a MagicMock, which already supports the
# context-manager protocol (and __exit__ returns False, so it won't
# swallow exceptions). _send_smtp uses `with server:` (no `as`), so no
# __enter__/__exit__ setup is needed.
mock_server = mock_cls.return_value
mock_server.__enter__ = lambda s: s
mock_server.__exit__ = lambda s, *a: None
alert.clear(path=path, alert_config=cfg)

msg = mock_server.send_message.call_args.args[0]
Expand Down Expand Up @@ -247,9 +253,11 @@ def test_smtp_ssl_used_when_starttls_false(tmp_path: Path) -> None:
cfg = AlertConfig(transport="smtp", smtp=smtp_cfg, mailgun=None)

with patch("door_sync.alert.smtplib.SMTP_SSL") as mock_cls:
# mock_cls.return_value is a MagicMock, which already supports the
# context-manager protocol (and __exit__ returns False, so it won't
# swallow exceptions). _send_smtp uses `with server:` (no `as`), so no
# __enter__/__exit__ setup is needed.
mock_server = mock_cls.return_value
mock_server.__enter__ = lambda s: s
mock_server.__exit__ = lambda s, *a: None
alert.raise_("reason", path=path, alert_config=cfg)

mock_cls.assert_called_once()
Expand Down
23 changes: 15 additions & 8 deletions tests/test_unifi_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,9 @@
import hashlib
import json as _json
import logging
import ssl
from collections.abc import Callable, Iterator
from dataclasses import replace
from pathlib import Path
from typing import Any
from unittest.mock import MagicMock, patch
Expand All @@ -12,7 +14,7 @@
import pytest
from pytest_httpx import HTTPXMock

from door_sync.config import UnifiConfig
from door_sync.config import UnifiConfig, load
from door_sync.models import Diff, ResolvedMember, UnifiUser
from door_sync.unifi.client import (
UnifiClient,
Expand Down Expand Up @@ -84,7 +86,10 @@ def test_parse_sync_alias_rejects_non_digit_suffix() -> None:
assert _parse_sync_alias("sync-+5") is None
assert _parse_sync_alias("sync- 12") is None # whitespace-padded
assert _parse_sync_alias("sync-1_234") is None # underscore digit grouping
assert _parse_sync_alias("sync-²") is None # superscript two (unicode digit)
# Non-ASCII digits (str.isdigit() is True for these) must be rejected by the
# isascii() guard: a superscript and a regular non-ASCII decimal digit.
assert _parse_sync_alias("sync-²") is None # U+00B2 superscript two
assert _parse_sync_alias("sync-১") is None # U+09E7 Bengali digit one


# --- Name splitting ---
Expand Down Expand Up @@ -161,7 +166,12 @@ def _patched_tls(cert_der: bytes) -> Any:
"door_sync.unifi.client",
socket=MagicMock(create_connection=MagicMock(return_value=mock_sock)),
ssl=MagicMock(
SSLContext=MagicMock(return_value=mock_ctx), CERT_NONE=0, PROTOCOL_TLS_CLIENT=0
SSLContext=MagicMock(return_value=mock_ctx),
# Use the real ssl constants so the stub matches production values
# (e.g. PROTOCOL_TLS_CLIENT is 2, not 0) rather than magic numbers.
CERT_NONE=ssl.CERT_NONE,
PROTOCOL_TLS_CLIENT=ssl.PROTOCOL_TLS_CLIENT,
TLSVersion=ssl.TLSVersion,
),
)

Expand Down Expand Up @@ -1006,8 +1016,9 @@ def test_import_cards_fc_mismatch_in_response_does_not_leak_card_number(
client._import_cards([1234])
message = str(exc_info.value)
# FC bytes are operational, not credential material — present.
# facility_code 42 (from the test config) must be named as the expected FC.
assert "got FC 89" in message
assert f"expected {42}" in message
assert "expected 42" in message
# The raw nfc_id and the card-number portion must NOT appear.
assert "5904D2" not in message
assert "1234" not in message
Expand Down Expand Up @@ -1791,10 +1802,6 @@ def test_unifi_client_constructs_from_loaded_config(
This catches the regression where config.host was validated as a full URL
but the client was treating it as a bare hostname.
"""
from dataclasses import replace

from door_sync.config import load

repo_root = Path(__file__).parent.parent

env_path = tmp_path / "env"
Expand Down