diff --git a/.github/workflows/ci-test.yml b/.github/workflows/ci-test.yml new file mode 100644 index 0000000..3147882 --- /dev/null +++ b/.github/workflows/ci-test.yml @@ -0,0 +1,197 @@ +name: ci-test + +on: + pull_request: + push: + branches: [main] + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }}-${{ github.head_ref }} + cancel-in-progress: ${{ github.ref != 'refs/heads/main' }} + +permissions: + contents: read + +env: + DEFAULT_PYTHON_VERSION: "3.11" + +jobs: + smoke-tests: + runs-on: ubuntu-latest + timeout-minutes: 10 + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: ${{ env.DEFAULT_PYTHON_VERSION }} + cache: "pip" + + - name: Install dependencies + run: | + python -m pip install --upgrade pip + python -m pip install -e ".[dev]" + + - name: Run smoke tests + run: pytest -q --maxfail=1 tests/smoke + + pre-commit: + runs-on: ubuntu-latest + timeout-minutes: 15 + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: ${{ env.DEFAULT_PYTHON_VERSION }} + cache: "pip" + + - name: Install dependencies + run: | + python -m pip install --upgrade pip + python -m pip install -e ".[dev]" + + - name: Run pre-commit + run: pre-commit run --all-files --show-diff-on-failure + + lint: + runs-on: ubuntu-latest + timeout-minutes: 15 + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: ${{ env.DEFAULT_PYTHON_VERSION }} + cache: "pip" + + - name: Install dependencies + run: | + python -m pip install --upgrade pip + python -m pip install -e ".[dev]" + + - name: Run Ruff format check + run: ruff format --check . + + - name: Run Ruff lint + run: ruff check . + + - name: Run mypy + run: mypy src/ + + unit-tests: + needs: [smoke-tests] + runs-on: ubuntu-latest + timeout-minutes: 20 + + env: + COVERAGE_FILE: coverage-unit.dat + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: ${{ env.DEFAULT_PYTHON_VERSION }} + cache: "pip" + + - name: Install dependencies + run: | + python -m pip install --upgrade pip + python -m pip install -e ".[dev]" + + - name: Run unit tests + run: pytest -q tests/unit --cov=denbust --cov-report=term-missing --cov-report= + + - name: Upload unit coverage data + uses: actions/upload-artifact@v4 + with: + name: coverage-unit-data + path: coverage-unit.dat + + integration-tests: + needs: [smoke-tests] + runs-on: ubuntu-latest + timeout-minutes: 25 + + env: + COVERAGE_FILE: coverage-integration.dat + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: ${{ env.DEFAULT_PYTHON_VERSION }} + cache: "pip" + + - name: Install dependencies + run: | + python -m pip install --upgrade pip + python -m pip install -e ".[dev]" + + - name: Run integration tests + run: pytest -q tests/integration --cov=denbust --cov-report=term-missing --cov-report= + + - name: Upload integration coverage data + uses: actions/upload-artifact@v4 + with: + name: coverage-integration-data + path: coverage-integration.dat + + coverage: + needs: [pre-commit, lint, unit-tests, integration-tests] + runs-on: ubuntu-latest + timeout-minutes: 15 + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: ${{ env.DEFAULT_PYTHON_VERSION }} + cache: "pip" + + - name: Install dependencies + run: | + python -m pip install --upgrade pip + python -m pip install -e ".[dev]" + + - name: Download unit coverage data + uses: actions/download-artifact@v4 + with: + name: coverage-unit-data + path: coverage-data/unit + + - name: Download integration coverage data + uses: actions/download-artifact@v4 + with: + name: coverage-integration-data + path: coverage-data/integration + + - name: Combine and report coverage + run: | + python -m coverage combine coverage-data/unit/coverage-unit.dat coverage-data/integration/coverage-integration.dat + python -m coverage report --show-missing + python -m coverage xml -o coverage.xml + + - name: Upload coverage artifact + uses: actions/upload-artifact@v4 + with: + name: coverage-xml + path: coverage.xml diff --git a/.gitignore b/.gitignore index acaeb6e..459c882 100644 --- a/.gitignore +++ b/.gitignore @@ -182,9 +182,9 @@ cython_debug/ .abstra/ # Visual Studio Code -# Visual Studio Code specific template is maintained in a separate VisualStudioCode.gitignore +# Visual Studio Code specific template is maintained in a separate VisualStudioCode.gitignore # that can be found at https://github.com/github/gitignore/blob/main/Global/VisualStudioCode.gitignore -# and can be added to the global gitignore or merged into this file. However, if you prefer, +# and can be added to the global gitignore or merged into this file. However, if you prefer, # you could uncomment the following to ignore the entire vscode folder # .vscode/ diff --git a/.pre-commit-ci.yaml b/.pre-commit-ci.yaml new file mode 100644 index 0000000..cd6016b --- /dev/null +++ b/.pre-commit-ci.yaml @@ -0,0 +1,3 @@ +ci: + autofix_prs: false + autoupdate_schedule: monthly diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml new file mode 100644 index 0000000..9bbc111 --- /dev/null +++ b/.pre-commit-config.yaml @@ -0,0 +1,16 @@ +repos: + - repo: https://github.com/pre-commit/pre-commit-hooks + rev: v5.0.0 + hooks: + - id: check-merge-conflict + - id: end-of-file-fixer + exclude: ^tests/fixtures/html/ + - id: trailing-whitespace + exclude: ^tests/fixtures/html/ + - id: check-yaml + + - repo: https://github.com/astral-sh/ruff-pre-commit + rev: v0.5.7 + hooks: + - id: ruff + - id: ruff-format diff --git a/docs/product_def.md b/docs/product_def.md index 3e13f68..6c72677 100644 --- a/docs/product_def.md +++ b/docs/product_def.md @@ -9,24 +9,24 @@ date: ינואר 2026 במאבק בתעשיית המין בישראל, החוק בעדנו. אסור לסרסר בנשים, גברים וקטינות. אסור להביא א.נשים לעסוק בזנות. אסור להחזיק מקום המשמש לזנות. ובזכות מאבק ארוך-שנים של המטה, אסור גם לצרוך זנות. החוק מייצר תודעה – אך האפקטיביות שלו נבחנת ביחס לאכיפה. -אכיפה חזקה מקדמת הרתעה, מחזקת את הערכים הנורמטיביים באיסור סרסור וצריכת זנות ומצמצת את הביקוש לקיומה של תעשיית המין האפלה. לעומת זאת, אכיפה חלשה או בררנית מורידה מן האפקטיביות של החוק, מעלימה עין להתפשטות הפשיעה ומשאירה נשים ונערות חשופות לניצול קשה. גם השיח הציבורי סביב האכיפה, הסיקור התקשורתי והנרטיב בו, יכולים להשפיע מאוד על התודעה החברתית סביב תעשיית המין. +אכיפה חזקה מקדמת הרתעה, מחזקת את הערכים הנורמטיביים באיסור סרסור וצריכת זנות ומצמצת את הביקוש לקיומה של תעשיית המין האפלה. לעומת זאת, אכיפה חלשה או בררנית מורידה מן האפקטיביות של החוק, מעלימה עין להתפשטות הפשיעה ומשאירה נשים ונערות חשופות לניצול קשה. גם השיח הציבורי סביב האכיפה, הסיקור התקשורתי והנרטיב בו, יכולים להשפיע מאוד על התודעה החברתית סביב תעשיית המין. -פרויקט "מדד האכיפה" יבחן את מאמצי האכיפה של המשטרה בתחום זה ויציג אותם באופן שקוף ומופשט לציבור. כגוף בעל נוכחות תקשורתית ויכולות בתחום התקשורת והמדיה, שיקוף מידע זה יכול להשפיע באופן ממשי על התודעה והשיח הציבורי בתחום. במקביל, המטה יעקוב אחרי הליכים פתוחים ויגיש חוות דעת משפטיות במקרים המתאימים מתוך הידע והניסיון שלנו, במטרה לקדם כתבי אישום. +פרויקט "מדד האכיפה" יבחן את מאמצי האכיפה של המשטרה בתחום זה ויציג אותם באופן שקוף ומופשט לציבור. כגוף בעל נוכחות תקשורתית ויכולות בתחום התקשורת והמדיה, שיקוף מידע זה יכול להשפיע באופן ממשי על התודעה והשיח הציבורי בתחום. במקביל, המטה יעקוב אחרי הליכים פתוחים ויגיש חוות דעת משפטיות במקרים המתאימים מתוך הידע והניסיון שלנו, במטרה לקדם כתבי אישום. ## מטרות הפרויקט ומנגנוני יישום לפרויקט ארבעה מטרות מרכזיות: 1. פיקוח אזרחי על מצב האכיפה בתחום הסרסור, צריכת זנות וסגירת זירות זנות במטרה למפות את מצב האכיפה ולזהות פערים ולקדם מדיניות מטיבה בהתאם. -2. העלאת מודעות ציבורית למצב האמיתי של המאבק בתעשיית המין תוך שיקוף נתוני אכיפה ותיווך משמעותם לציבור בתקשורת וברשתות של המטה. +2. העלאת מודעות ציבורית למצב האמיתי של המאבק בתעשיית המין תוך שיקוף נתוני אכיפה ותיווך משמעותם לציבור בתקשורת וברשתות של המטה. 3. יצירת לחץ ציבורי במטרה לעודד אכיפה אפקטיבית יותר בתחום. 4. חיזוק שיתוף הפעולה בין המטה לבין גורמי אכיפת החוק, תוך שיתוף ביכולות המשפטיות של המטה וההיכרות העמוקה עם השטח ועם המתרחש ברמה היומיומית בזנות בישראל. על מנת להגשים מטרות אלו, הפרויקט יכלול מספר מנגנונים מקבילים: -- איסוף מידע -- שיקוף מידע -- תרומת ידע +- איסוף מידע +- שיקוף מידע +- תרומת ידע ### איסוף מידע @@ -78,7 +78,7 @@ date: ינואר 2026 אופן איסוף המידע ייעשה בשלבים. בתחילת הפרויקט, המידע ייאסף מחיפושים ידניים במאגרי מידע משפטיים על בסיס מנוי ומניהול התראות גוגל כדי לגלות על פרסומים תקשורתיים בתחום ועל אירועים שיש לעקוב אחריהם. לצד פעולות אלו, ייבנה פורמט להגשת בקשות חופש מידע אודות המידע שלא זמין לציבור הרחב וייקבע תדירות הגשת הבקשות על מנת לחשוף את הנתונים העדכניים והרלוונטיים ביותר. בעת הצורך, המטה ייעזר באסטרטגיות הלובי שלו כדי להבטיח כי המידע שגורמי אכיפת חוק מחויבים למסור יימסר. -במהלך התנעת הפרויקט, יחד עם יועצות ויועצים מתחום הסייבר ומתחום אכיפת החוק, יאופיין כלי אשר מסוגל לרכז את המידע מאת מקורות מידע משפטיים ותקשורתיים. הכלי יסרוק מאגרים פתוחים על מנת לרכז את ההליכים ופריטי המידע הרלוונטיים. כיום אין מאגר אחד אשר מרכז את המידע אודות מצב האכיפה, ועל מנת להבין את המצב יש צורך לקרוא כתבות והודעות דוברות ולחבר את המידע באופן ידני, ולהשלים את החוסרים בתהליך של בקשת חופש מידע. תהליך זה מורכב ודורש זמן רב, אך אוטומציה של החיפוש יכולה לייעל אותו באופן משמעותי. מתוך סקירה ראשונית, מסתמן כי לא ניתן לבנות כלי אשר יפעל בתוך מאגרי מידע סגורים (למשל, נבו או תקדין) אך כי, במגבלות מסוימות, יהיה ניתן להפעיל כלי כזה באתר של הרשות השופטת, שכן הפרסומים שם פומביים ופתוחים לציבור הרחב, ולצידו גם בפלטפורמות תקשורתיות. לאחר אפיון ובחינת הסוגיות המשפטיות והטכנולוגיות, הכלי ייבנה במסגרת הפרויקט. +במהלך התנעת הפרויקט, יחד עם יועצות ויועצים מתחום הסייבר ומתחום אכיפת החוק, יאופיין כלי אשר מסוגל לרכז את המידע מאת מקורות מידע משפטיים ותקשורתיים. הכלי יסרוק מאגרים פתוחים על מנת לרכז את ההליכים ופריטי המידע הרלוונטיים. כיום אין מאגר אחד אשר מרכז את המידע אודות מצב האכיפה, ועל מנת להבין את המצב יש צורך לקרוא כתבות והודעות דוברות ולחבר את המידע באופן ידני, ולהשלים את החוסרים בתהליך של בקשת חופש מידע. תהליך זה מורכב ודורש זמן רב, אך אוטומציה של החיפוש יכולה לייעל אותו באופן משמעותי. מתוך סקירה ראשונית, מסתמן כי לא ניתן לבנות כלי אשר יפעל בתוך מאגרי מידע סגורים (למשל, נבו או תקדין) אך כי, במגבלות מסוימות, יהיה ניתן להפעיל כלי כזה באתר של הרשות השופטת, שכן הפרסומים שם פומביים ופתוחים לציבור הרחב, ולצידו גם בפלטפורמות תקשורתיות. לאחר אפיון ובחינת הסוגיות המשפטיות והטכנולוגיות, הכלי ייבנה במסגרת הפרויקט. בשביל לשקף את המידע, במסגרת התנעת הפרויקט הצוות ייעצב עמוד אינטרנט בו יוצג כל המידע. המידע יוצג באופן אסטטי, מונגש לציבור ומנותח לפי תבחינים מהותיים וכמותיים. התצוגה תכלול, בין היתר: @@ -88,7 +88,7 @@ date: ינואר 2026 - כמות ההליכים הפליליים בכל תחום ועדכונים בהתאם לפומביות המידע - ניתוח מהותי של נתונים – היקף צווי הסגירה שניתנו ללא חלוקת קנסות; היקף צווי הסגירה שניתנו במסגרתם נעצרו א.נשים ממעגל הזנות; ועוד -כך, המידע הסטטיסטי יהיה זמין לציבור המתעניינים ולכלי תקשורת הסוקרים את התופעה, וכמו כן המטה יוכל לתאר את מצב המאבק, פערים בין גופי אכיפה או מחוזות שונים, מגמת ההתעמרות בא.נשים במעגל הזנות, וכו'. +כך, המידע הסטטיסטי יהיה זמין לציבור המתעניינים ולכלי תקשורת הסוקרים את התופעה, וכמו כן המטה יוכל לתאר את מצב המאבק, פערים בין גופי אכיפה או מחוזות שונים, מגמת ההתעמרות בא.נשים במעגל הזנות, וכו'. ## ניהול הפרויקט @@ -108,20 +108,20 @@ date: ינואר 2026 ### תכלול הפרויקט (שוטף) -- תכלול הנתונים והמידע - - ? אחוז משרה מנהלת אכיפה + מנהלת דוברות + מנהלת קשרי ממשל - - עלות ניהול הכלי בשוטף +- תכלול הנתונים והמידע + - ? אחוז משרה מנהלת אכיפה + מנהלת דוברות + מנהלת קשרי ממשל + - עלות ניהול הכלי בשוטף -- ניהול השיח השוטף עם הקואליציה - - ? אחוז משרה מנהלת אכיפה + מנהלת קואליציה +- ניהול השיח השוטף עם הקואליציה + - ? אחוז משרה מנהלת אכיפה + מנהלת קואליציה -- ניהול האתר, הרשתות החברתיות ותחום התקשורת - - ? אחוז משרה מנהלת דוברות + מנהלת אכיפה +- ניהול האתר, הרשתות החברתיות ותחום התקשורת + - ? אחוז משרה מנהלת דוברות + מנהלת אכיפה -- מעקב אחרי ההליכים והשתתפות במקרים המתאימים - - הגשת מידע/חוות דעת במקרים המתאימים; הגשת בקשה להצטרף כידיד בית משפט בעת הצורך וכאשר רלוונטי - - בנוסף, חיזוק הקשר עם המשטרה והפרקליטות באמצעות שיח שוטף - - ? אחוז משרה מנהלת אכיפה +- מעקב אחרי ההליכים והשתתפות במקרים המתאימים + - הגשת מידע/חוות דעת במקרים המתאימים; הגשת בקשה להצטרף כידיד בית משפט בעת הצורך וכאשר רלוונטי + - בנוסף, חיזוק הקשר עם המשטרה והפרקליטות באמצעות שיח שוטף + - ? אחוז משרה מנהלת אכיפה ## מדידת השפעת הפרויקט @@ -132,5 +132,5 @@ date: ינואר 2026 3. מעקב אחרי נתוני האכיפה ומגמת עלייה של פעולות האכיפה לאחר הפעלת המדד לתקופה ממושכת. 4. כמות ההליכים בהם המטה השתתף וההשפעה של המטה באותם הליכים. -- מועד מדידה: שנה לאחר עליית המדד לאוויר -- משאב נדרש: עלות מדידה +- מועד מדידה: שנה לאחר עליית המדד לאוויר +- משאב נדרש: עלות מדידה diff --git a/pyproject.toml b/pyproject.toml index 43a0d73..72680e7 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -27,8 +27,10 @@ dependencies = [ dev = [ "pytest>=8.0.0", "pytest-asyncio>=0.24.0", + "pytest-cov>=5.0.0", "respx>=0.21.0", "pytest-mock>=3.12.0", + "pre-commit>=3.7.0", "ruff>=0.5.0", "mypy>=1.10.0", "types-beautifulsoup4>=4.12.0", diff --git a/src/denbust/pipeline.py b/src/denbust/pipeline.py index 7c32201..4cb2f97 100644 --- a/src/denbust/pipeline.py +++ b/src/denbust/pipeline.py @@ -7,8 +7,8 @@ from denbust.classifier.relevance import Classifier, create_classifier from denbust.config import Config, SourceType, load_config -from denbust.dedup.similarity import Deduplicator, create_deduplicator from denbust.data_models import ClassifiedArticle, RawArticle, UnifiedItem +from denbust.dedup.similarity import Deduplicator, create_deduplicator from denbust.output.formatter import print_items from denbust.sources.base import Source from denbust.sources.maariv import create_maariv_source diff --git a/tests/integration/test_pipeline.py b/tests/integration/test_pipeline.py index d35bc70..179e458 100644 --- a/tests/integration/test_pipeline.py +++ b/tests/integration/test_pipeline.py @@ -21,6 +21,7 @@ # Load fixture files FIXTURES_DIR = Path(__file__).parent.parent / "fixtures" +TEST_LOOKBACK_DAYS = 5000 def load_fixture(path: str) -> str: @@ -104,7 +105,7 @@ async def test_fetch_rss_source(self) -> None: sources = [RSSSource("ynet", "https://ynet.co.il/feed.xml")] keywords = ["בית בושת", "זנות", "סרסור"] - articles = await fetch_all_sources(sources, days=14, keywords=keywords) + articles = await fetch_all_sources(sources, days=TEST_LOOKBACK_DAYS, keywords=keywords) # Should find articles matching keywords assert len(articles) >= 1 @@ -147,8 +148,8 @@ class TestDeduplicateArticles: def test_deduplicate_similar_articles(self) -> None: """Test deduplicating similar articles.""" - from denbust.dedup.similarity import Deduplicator from denbust.data_models import ClassificationResult, ClassifiedArticle + from denbust.dedup.similarity import Deduplicator articles = [ ClassifiedArticle( @@ -226,7 +227,9 @@ async def test_full_pipeline_mocked(self, tmp_path: Path) -> None: sources = create_sources(config) assert len(sources) == 1 - articles = await fetch_all_sources(sources, days=14, keywords=config.keywords) + articles = await fetch_all_sources( + sources, days=TEST_LOOKBACK_DAYS, keywords=config.keywords + ) # Should find some matching articles assert len(articles) >= 1 diff --git a/tests/integration/test_scrapers.py b/tests/integration/test_scrapers.py index 76a72cc..fc810c5 100644 --- a/tests/integration/test_scrapers.py +++ b/tests/integration/test_scrapers.py @@ -11,6 +11,7 @@ # Load fixture files FIXTURES_DIR = Path(__file__).parent.parent / "fixtures" +TEST_LOOKBACK_DAYS = 5000 def load_fixture(path: str) -> str: @@ -38,7 +39,7 @@ async def test_parse_search_results(self) -> None: ) scraper = MakoScraper() - articles = await scraper.fetch(days=14, keywords=["סרסור"]) + articles = await scraper.fetch(days=TEST_LOOKBACK_DAYS, keywords=["סרסור"]) # Should find articles from the fixture assert len(articles) >= 1 @@ -65,7 +66,9 @@ async def test_deduplicates_results(self) -> None: scraper = MakoScraper() # Search with multiple keywords - articles = await scraper.fetch(days=14, keywords=["סרסור", "זנות", "בית בושת"]) + articles = await scraper.fetch( + days=TEST_LOOKBACK_DAYS, keywords=["סרסור", "זנות", "בית בושת"] + ) # Should deduplicate by URL urls = [str(a.url) for a in articles] @@ -88,7 +91,7 @@ async def test_handles_empty_results(self) -> None: ) scraper = MaarivScraper() - articles = await scraper.fetch(days=14, keywords=["test"]) + articles = await scraper.fetch(days=TEST_LOOKBACK_DAYS, keywords=["test"]) # Should return empty list, not crash assert articles == [] @@ -101,7 +104,7 @@ async def test_handles_http_error(self) -> None: respx.get("https://www.maariv.co.il/search").mock(return_value=Response(404)) scraper = MaarivScraper() - articles = await scraper.fetch(days=14, keywords=["test"]) + articles = await scraper.fetch(days=TEST_LOOKBACK_DAYS, keywords=["test"]) # Should return empty list, not crash assert articles == [] @@ -121,7 +124,9 @@ async def test_parse_rss_feed(self) -> None: respx.get("https://ynet.co.il/feed.xml").mock(return_value=Response(200, text=rss_content)) source = RSSSource("ynet", "https://ynet.co.il/feed.xml") - articles = await source.fetch(days=14, keywords=["בית בושת", "זנות", "צו סגירה"]) + articles = await source.fetch( + days=TEST_LOOKBACK_DAYS, keywords=["בית בושת", "זנות", "צו סגירה"] + ) # Should find matching articles assert len(articles) >= 1 @@ -144,7 +149,7 @@ async def test_filters_by_keywords(self) -> None: source = RSSSource("ynet", "https://ynet.co.il/feed.xml") # Search for keyword that doesn't match - articles = await source.fetch(days=14, keywords=["מילה_שלא_קיימת"]) + articles = await source.fetch(days=TEST_LOOKBACK_DAYS, keywords=["מילה_שלא_קיימת"]) # Should not find any articles assert len(articles) == 0 @@ -158,7 +163,7 @@ async def test_handles_feed_error(self) -> None: respx.get("https://ynet.co.il/feed.xml").mock(return_value=Response(500)) source = RSSSource("ynet", "https://ynet.co.il/feed.xml") - articles = await source.fetch(days=14, keywords=["test"]) + articles = await source.fetch(days=TEST_LOOKBACK_DAYS, keywords=["test"]) # Should return empty list, not crash assert articles == [] diff --git a/tests/smoke/test_smoke_suite.py b/tests/smoke/test_smoke_suite.py new file mode 100644 index 0000000..51837f3 --- /dev/null +++ b/tests/smoke/test_smoke_suite.py @@ -0,0 +1,39 @@ +"""Fast smoke tests used as a CI gate before heavier suites.""" + +from pathlib import Path + +from typer.testing import CliRunner + +from denbust.cli import app +from denbust.config import Config +from denbust.store.seen import SeenStore + + +def test_cli_version_command_smoke() -> None: + """CLI entrypoint should load and return version output.""" + runner = CliRunner() + result = runner.invoke(app, ["version"]) + + assert result.exit_code == 0 + assert "denbust version" in result.output + + +def test_config_defaults_smoke() -> None: + """Core config model should instantiate with defaults.""" + config = Config() + + assert config.days > 0 + assert config.max_articles > 0 + assert len(config.keywords) > 0 + + +def test_seen_store_round_trip_smoke(tmp_path: Path) -> None: + """Seen store should persist and reload URLs.""" + path = tmp_path / "seen.json" + + first = SeenStore(path) + first.mark_seen(["https://example.com/article"]) + first.save() + + second = SeenStore(path) + assert second.is_seen("https://example.com/article") diff --git a/tests/unit/test_dedup.py b/tests/unit/test_dedup.py index a71bad5..c61ec78 100644 --- a/tests/unit/test_dedup.py +++ b/tests/unit/test_dedup.py @@ -4,7 +4,6 @@ from pydantic import HttpUrl -from denbust.dedup.similarity import ArticleGroup, Deduplicator, create_deduplicator from denbust.data_models import ( Category, ClassificationResult, @@ -12,6 +11,7 @@ RawArticle, SubCategory, ) +from denbust.dedup.similarity import ArticleGroup, Deduplicator, create_deduplicator def make_article(title: str, source: str, url: str = "https://example.com/1") -> ClassifiedArticle: diff --git a/tests/unit/test_models.py b/tests/unit/test_models.py index 716bc0e..fafaf00 100644 --- a/tests/unit/test_models.py +++ b/tests/unit/test_models.py @@ -2,10 +2,8 @@ from datetime import UTC, datetime -from pydantic import HttpUrl - import pytest -from pydantic import ValidationError +from pydantic import HttpUrl, ValidationError from denbust.data_models import ( Category,