Add pluggable server-side TextFilter service (social.md §4 US-4)
Deterministic default (ListBasedTextFilter) backed by a configurable JSON term list, with normalization (case-fold, leetspeak substitutions, stripped punctuation/spacing between letters) so trivial evasion doesn't slip through. Supports both reject (blocking) and redact (asterisk replacement) modes. TextFilter is a Protocol, leaving an explicit extension point for a future third-party moderation provider. Standalone module, not wired into any router/endpoint -- sibling chat tasks depend on this one. No DB/migration work. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Y2FESCaMXeJpC9gSctiEeD
This commit is contained in:
@@ -0,0 +1,194 @@
|
||||
"""Unit tests for app.services.text_filter (docs/planning/social.md §4/§5 US-4).
|
||||
|
||||
Pure, side-effect-free service -- no DB/Redis fixtures needed.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from app.services.text_filter import (
|
||||
DEFAULT_TERMS_PATH,
|
||||
FilterAction,
|
||||
FilterMode,
|
||||
FilterResult,
|
||||
ListBasedTextFilter,
|
||||
TextFilter,
|
||||
default_text_filter,
|
||||
load_terms,
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def redact_filter() -> ListBasedTextFilter:
|
||||
return ListBasedTextFilter(terms=["badword", "shoot"], mode=FilterMode.REDACT)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def reject_filter() -> ListBasedTextFilter:
|
||||
return ListBasedTextFilter(terms=["badword", "shoot"], mode=FilterMode.REJECT)
|
||||
|
||||
|
||||
class TestAllowPath:
|
||||
def test_clean_text_is_allowed_unchanged(self, redact_filter: ListBasedTextFilter) -> None:
|
||||
result = redact_filter.check("hello there, how are you today?")
|
||||
assert result.action is FilterAction.ALLOW
|
||||
assert result.filtered_text == "hello there, how are you today?"
|
||||
assert result.matched_terms == ()
|
||||
|
||||
def test_message_with_no_matches(self, reject_filter: ListBasedTextFilter) -> None:
|
||||
# Distinct from the clean-text case above: exercises the reject-mode
|
||||
# filter's no-match path specifically (mode should not matter when
|
||||
# nothing matches).
|
||||
text = "let's meet at the plaza around noon"
|
||||
result = reject_filter.check(text)
|
||||
assert result.action is FilterAction.ALLOW
|
||||
assert result.filtered_text == text
|
||||
assert result.matched_terms == ()
|
||||
|
||||
|
||||
class TestRedactPath:
|
||||
def test_exact_match_is_redacted(self, redact_filter: ListBasedTextFilter) -> None:
|
||||
result = redact_filter.check("don't be a badword about it")
|
||||
assert result.action is FilterAction.REDACT
|
||||
assert result.matched_terms == ("badword",)
|
||||
assert "badword" not in result.filtered_text
|
||||
assert result.filtered_text == "don't be a ******* about it"
|
||||
|
||||
def test_multiple_distinct_matches(self, redact_filter: ListBasedTextFilter) -> None:
|
||||
result = redact_filter.check("badword, don't shoot!")
|
||||
assert result.action is FilterAction.REDACT
|
||||
assert set(result.matched_terms) == {"badword", "shoot"}
|
||||
assert "badword" not in result.filtered_text
|
||||
assert "shoot" not in result.filtered_text
|
||||
# Length is preserved (asterisks replace matched spans 1:1).
|
||||
assert len(result.filtered_text) == len("badword, don't shoot!")
|
||||
|
||||
def test_redaction_char_is_configurable(self) -> None:
|
||||
f = ListBasedTextFilter(terms=["badword"], mode=FilterMode.REDACT, redaction_char="#")
|
||||
result = f.check("that's a badword thing to say")
|
||||
assert result.filtered_text == "that's a ####### thing to say"
|
||||
|
||||
|
||||
class TestRejectPath:
|
||||
def test_match_is_rejected_and_text_left_intact(self, reject_filter: ListBasedTextFilter) -> None:
|
||||
text = "stop, badword!"
|
||||
result = reject_filter.check(text)
|
||||
assert result.action is FilterAction.REJECT
|
||||
assert result.matched_terms == ("badword",)
|
||||
# Reject mode does not mutate the text -- callers are expected to
|
||||
# not store/deliver it at all, based on `action`.
|
||||
assert result.filtered_text == text
|
||||
|
||||
|
||||
class TestNormalization:
|
||||
def test_leetspeak_variant_is_caught(self, redact_filter: ListBasedTextFilter) -> None:
|
||||
# "b4dw0rd" -> normalizes to "badword" via the leet map (4->a, 0->o).
|
||||
result = redact_filter.check("you're being a b4dw0rd right now")
|
||||
assert result.action is FilterAction.REDACT
|
||||
assert result.matched_terms == ("badword",)
|
||||
|
||||
def test_separators_between_letters_are_stripped(self, redact_filter: ListBasedTextFilter) -> None:
|
||||
result = redact_filter.check("that is b.a.d.w.o.r.d behavior")
|
||||
assert result.action is FilterAction.REDACT
|
||||
assert result.matched_terms == ("badword",)
|
||||
|
||||
def test_spacing_between_letters_is_stripped(self, redact_filter: ListBasedTextFilter) -> None:
|
||||
result = redact_filter.check("b a d w o r d, seriously")
|
||||
assert result.action is FilterAction.REDACT
|
||||
assert result.matched_terms == ("badword",)
|
||||
|
||||
def test_case_folding(self, redact_filter: ListBasedTextFilter) -> None:
|
||||
result = redact_filter.check("BADWORD in all caps")
|
||||
assert result.action is FilterAction.REDACT
|
||||
assert result.matched_terms == ("badword",)
|
||||
|
||||
def test_symbol_lookalikes(self) -> None:
|
||||
# @ -> a, $ -> s: "@ss" should normalize to "ass".
|
||||
f = ListBasedTextFilter(terms=["ass"], mode=FilterMode.REDACT)
|
||||
result = f.check("what an @$$ move")
|
||||
assert result.action is FilterAction.REDACT
|
||||
assert result.matched_terms == ("ass",)
|
||||
|
||||
|
||||
class TestEmptyAndWhitespace:
|
||||
def test_empty_string(self, redact_filter: ListBasedTextFilter) -> None:
|
||||
result = redact_filter.check("")
|
||||
assert result.action is FilterAction.ALLOW
|
||||
assert result.filtered_text == ""
|
||||
assert result.matched_terms == ()
|
||||
|
||||
def test_whitespace_only_string(self, redact_filter: ListBasedTextFilter) -> None:
|
||||
text = " \t\n "
|
||||
result = redact_filter.check(text)
|
||||
assert result.action is FilterAction.ALLOW
|
||||
assert result.filtered_text == text
|
||||
assert result.matched_terms == ()
|
||||
|
||||
def test_punctuation_only_string_has_nothing_to_normalize(self, redact_filter: ListBasedTextFilter) -> None:
|
||||
# No alnum characters survive normalization at all -- must not
|
||||
# crash on an empty normalized string / empty index_map.
|
||||
result = redact_filter.check("...!!!???")
|
||||
assert result.action is FilterAction.ALLOW
|
||||
assert result.matched_terms == ()
|
||||
|
||||
|
||||
class TestConfigurability:
|
||||
def test_custom_term_list_is_used_instead_of_default(self) -> None:
|
||||
f = ListBasedTextFilter(terms=["zzzcustomzzz"], mode=FilterMode.REDACT)
|
||||
result = f.check("this contains zzzcustomzzz right here")
|
||||
assert result.action is FilterAction.REDACT
|
||||
assert result.matched_terms == ("zzzcustomzzz",)
|
||||
# Default list terms should NOT match since we overrode the list.
|
||||
result2 = f.check("this is fucking unrelated")
|
||||
assert result2.action is FilterAction.ALLOW
|
||||
|
||||
def test_duplicate_and_blank_terms_are_deduped(self) -> None:
|
||||
f = ListBasedTextFilter(terms=["badword", "badword", " ", ""], mode=FilterMode.REDACT)
|
||||
assert f._terms == ("badword",)
|
||||
|
||||
def test_default_terms_file_loads_and_is_nonempty(self) -> None:
|
||||
terms = load_terms()
|
||||
assert isinstance(terms, list)
|
||||
assert len(terms) > 0
|
||||
assert all(isinstance(t, str) and t for t in terms)
|
||||
|
||||
def test_default_terms_path_exists(self) -> None:
|
||||
assert DEFAULT_TERMS_PATH.exists()
|
||||
assert DEFAULT_TERMS_PATH.suffix == ".json"
|
||||
|
||||
def test_load_terms_from_explicit_path(self, tmp_path: Path) -> None:
|
||||
custom = tmp_path / "custom_terms.json"
|
||||
custom.write_text('{"terms": ["foo", "bar"]}', encoding="utf-8")
|
||||
assert load_terms(custom) == ["foo", "bar"]
|
||||
|
||||
def test_default_text_filter_factory_matches_default_list(self) -> None:
|
||||
f = default_text_filter(mode=FilterMode.REJECT)
|
||||
result = f.check("that's fucking ridiculous")
|
||||
assert result.action is FilterAction.REJECT
|
||||
assert "fuck" in result.matched_terms
|
||||
|
||||
|
||||
class TestProtocol:
|
||||
def test_list_based_filter_satisfies_text_filter_protocol(self, redact_filter: ListBasedTextFilter) -> None:
|
||||
assert isinstance(redact_filter, TextFilter)
|
||||
|
||||
def test_check_returns_filter_result(self, redact_filter: ListBasedTextFilter) -> None:
|
||||
result = redact_filter.check("hi")
|
||||
assert isinstance(result, FilterResult)
|
||||
|
||||
def test_a_third_party_style_implementation_satisfies_the_protocol(self) -> None:
|
||||
# Demonstrates the extension point: any object with a matching
|
||||
# `check` method satisfies TextFilter without inheriting from
|
||||
# anything -- this is how a future third-party provider would plug
|
||||
# in without touching this module.
|
||||
class AlwaysAllow:
|
||||
def check(self, text: str) -> FilterResult:
|
||||
return FilterResult(action=FilterAction.ALLOW, filtered_text=text, matched_terms=())
|
||||
|
||||
provider: TextFilter = AlwaysAllow()
|
||||
assert isinstance(provider, TextFilter)
|
||||
result = provider.check("anything at all, even fuck")
|
||||
assert result.action is FilterAction.ALLOW
|
||||
Reference in New Issue
Block a user