"""Antispam C1 decision logic (SPEC §C.4) — pure, no database, no engine.""" from __future__ import annotations import pytest from webmail_plugin_antispam.policy import Settings, decide, matches SETTINGS = Settings( enabled_accounts=[], move_threshold=12.0, banner_threshold=6.0, network_checks=False, learn_from_provider_junk=True, ) NO_RULES: dict[str, set[str]] = {"allow": set(), "deny": set()} def _decide(sender: str = "s@example.com", rules=None, score=None, settings=SETTINGS) -> str: return decide(sender=sender, rules=rules or NO_RULES, score=score, settings=settings) @pytest.mark.parametrize( ("score", "expected"), [ (0.0, "none"), (5.9, "none"), (6.0, "banner"), (11.9, "banner"), (12.0, "moved"), (99.0, "moved"), ], ) def test_thresholds_are_inclusive_lower_bounds(score: float, expected: str) -> None: assert _decide(score=score) == expected def test_unreachable_engine_fails_open() -> None: """score=None is "could not classify" — mail must still be delivered plainly.""" assert _decide(score=None) == "none" def test_deny_list_moves_without_the_engine() -> None: """Acceptance 5: a deny-listed sender is filed with no engine involvement.""" rules = {"allow": set(), "deny": {"spammer@bad.test"}} assert _decide(sender="spammer@bad.test", rules=rules, score=None) == "deny_listed" def test_allow_list_beats_a_high_score() -> None: """Acceptance 5: an allow-listed sender survives even an overwhelming score.""" rules = {"allow": {"boss@work.test"}, "deny": set()} assert _decide(sender="boss@work.test", rules=rules, score=100.0) == "allow_listed" def test_deny_beats_allow() -> None: """Both lists naming the same sender: the refusal wins.""" rules = {"allow": {"x@y.test"}, "deny": {"x@y.test"}} assert _decide(sender="x@y.test", rules=rules, score=0.0) == "deny_listed" def test_domain_patterns_cover_every_sender_there() -> None: rules = {"allow": set(), "deny": {"@bad.test"}} assert _decide(sender="anyone@bad.test", rules=rules) == "deny_listed" assert _decide(sender="anyone@good.test", rules=rules) == "none" @pytest.mark.parametrize( ("patterns", "email", "hit"), [ ({"a@b.test"}, "A@B.TEST", True), # case-insensitive ({"a@b.test"}, " a@b.test ", True), # tolerant of stray whitespace ({"@b.test"}, "anyone@b.test", True), ({"@b.test"}, "anyone@other.test", False), ({"a@b.test"}, "", False), # no sender at all (set(), "a@b.test", False), ], ) def test_pattern_matching(patterns: set[str], email: str, hit: bool) -> None: assert matches(patterns, email) is hit def test_per_account_enablement() -> None: """Empty list means every account; a populated one is an allow-list.""" everywhere = SETTINGS assert everywhere.covers("any-account-id") scoped = Settings( enabled_accounts=["acc-1"], move_threshold=12.0, banner_threshold=6.0, network_checks=False, learn_from_provider_junk=True, ) assert scoped.covers("acc-1") assert not scoped.covers("acc-2") # --- contacts are never filed as spam ---------------------------------------- def test_a_contact_is_warned_about_but_never_moved() -> None: """Mail from someone in the address book must not vanish into Junk.""" assert _decide(score=99.0) == "moved" # same score, not a contact assert ( decide( sender="friend@example.com", rules=NO_RULES, score=99.0, settings=SETTINGS, contact=True, ) == "banner" ) def test_a_contact_below_the_move_threshold_is_unaffected() -> None: for score, expected in ((0.0, "none"), (6.0, "banner"), (11.9, "banner")): assert ( decide( sender="friend@example.com", rules=NO_RULES, score=score, settings=SETTINGS, contact=True, ) == expected ) def test_an_explicit_deny_still_beats_being_a_contact() -> None: """Deny-listing someone you also have saved is a deliberate act — honour it.""" rules = {"allow": set(), "deny": {"ex@example.com"}} assert ( decide(sender="ex@example.com", rules=rules, score=0.0, settings=SETTINGS, contact=True) == "deny_listed" )