Files
wehub-resource-sync 09e9f3545f
Test / Code Quality (push) Has been cancelled
Test / Test (macos-latest, Python 3.10) (push) Has been cancelled
Test / Test (macos-latest, Python 3.11) (push) Has been cancelled
Test / Test (macos-latest, Python 3.12) (push) Has been cancelled
Test / Test (macos-latest, Python 3.13) (push) Has been cancelled
Test / Test (macos-latest, Python 3.14) (push) Has been cancelled
Test / Test (ubuntu-latest, Python 3.10) (push) Has been cancelled
Test / Test (ubuntu-latest, Python 3.11) (push) Has been cancelled
Test / Test (ubuntu-latest, Python 3.12) (push) Has been cancelled
Test / Test (ubuntu-latest, Python 3.13) (push) Has been cancelled
Test / Test (ubuntu-latest, Python 3.14) (push) Has been cancelled
Test / Test (windows-latest, Python 3.10) (push) Has been cancelled
Test / Test (windows-latest, Python 3.11) (push) Has been cancelled
Test / Test (windows-latest, Python 3.12) (push) Has been cancelled
Test / Test (windows-latest, Python 3.13) (push) Has been cancelled
Test / Test (windows-latest, Python 3.14) (push) Has been cancelled
CodeQL / Analyze (push) Has been cancelled
dependency-audit / pip-audit (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 13:30:13 +08:00

395 lines
15 KiB
Python

"""Unit tests for the transport-neutral ``notebooklm._app.source_clean`` core.
These pin the relocated ``source clean`` business logic at the ``_app`` boundary
(independent of the Click adapter):
* :func:`classify_junk_sources` — the junk classifier (error-status / gateway-title
/ fragment-stripped URL dedup with oldest-kept ordering). These assertions
were **moved** from ``tests/unit/cli/test_source.py::TestSourceCleanClassify``,
which already called the pure function directly through the
``cli.source_cmd._classify_junk_sources`` re-export — they now target the
neutral symbol with no behaviour change.
* :func:`normalize_url_for_dedup` — fragment-only stripping + scheme/host lower.
* :func:`candidates_payload` — the JSON payload shape projection.
* :func:`run_source_clean` — the classify → confirm → batched-delete orchestration
(already-clean short-circuit, dry-run, cancellation, chunked deletion with
partial-failure capture) driven by injected list/delete/confirm callables.
Pure-service tests (no Click / CliRunner): the command-layer wiring is exercised
in ``tests/unit/cli/test_source.py::TestSourceCleanCommand``.
"""
from __future__ import annotations
from datetime import datetime
from unittest.mock import AsyncMock, MagicMock
import pytest
from notebooklm._app.source_clean import (
candidates_payload,
classify_junk_sources,
normalize_url_for_dedup,
run_source_clean,
)
from notebooklm.types import Source, SourceStatus
def _src(
sid: str,
*,
title: str | None = None,
url: str | None = None,
status: int = SourceStatus.READY,
created_at: datetime | None = None,
) -> Source:
"""Build a Source fixture with sensible defaults for clean tests."""
return Source(id=sid, title=title, url=url, status=status, created_at=created_at)
# ===========================================================================
# classify_junk_sources (moved from cli/test_source.py::TestSourceCleanClassify)
# ===========================================================================
class TestClassifyJunkSources:
"""Pure classification logic — every branch without mocking the client."""
def test_empty_notebook_returns_nothing(self) -> None:
assert classify_junk_sources([]) == []
def test_error_status_is_flagged(self) -> None:
s = _src("src_e", status=SourceStatus.ERROR, url="https://ex.com/a")
out = classify_junk_sources([s])
assert [(c[0], c[3]) for c in out] == [("src_e", "error_status")]
def test_unknown_status_is_not_flagged(self) -> None:
# Unrecognized status codes must NOT be auto-deleted; they may
# represent future NotebookLM states or missing-status payloads.
s = _src("src_u", status=99, url="https://ex.com/a")
assert classify_junk_sources([s]) == []
def test_zero_status_is_not_flagged(self) -> None:
# status=0 maps to "unknown" via the truthy fallback. Must NOT delete.
s = _src("src_z", status=0, url="https://ex.com/a")
assert classify_junk_sources([s]) == []
def test_processing_status_is_not_flagged(self) -> None:
s = _src("src_p", status=SourceStatus.PROCESSING, url="https://ex.com/a")
assert classify_junk_sources([s]) == []
@pytest.mark.parametrize(
"title",
[
"Access Denied",
"403 Forbidden",
"404 Not Found",
"Just a Moment...",
"Attention Required! | Cloudflare",
"Security check",
"CAPTCHA verification",
" 403 ",
],
)
def test_gateway_titles_are_flagged(self, title: str) -> None:
s = _src("src_g", title=title, url="https://ex.com/a")
out = classify_junk_sources([s])
assert [(c[0], c[3]) for c in out] == [("src_g", "gateway_title")]
def test_legitimate_titles_starting_with_digits_are_not_flagged(self) -> None:
# "404 Not Found" is a gateway title, but "100 Ways to ..." is not.
s = _src("src_ok", title="100 Ways to Cook Pasta", url="https://ex.com/a")
assert classify_junk_sources([s]) == []
def test_url_title_on_ready_source_is_not_deleted(self) -> None:
# Regression: PR review caught that URL-as-title was being treated as
# junk, which deletes legitimate in-flight sources (Source.title is
# documented to "may be URL if not yet processed").
s = _src(
"src_url",
title="https://example.com/article",
url="https://example.com/article",
)
assert classify_junk_sources([s]) == []
def test_dedup_keeps_oldest_and_flags_later_copies(self) -> None:
# Oldest at t=0; two later duplicates.
sources = [
_src("src_3", url="https://ex.com/a", created_at=datetime(2024, 3, 1)),
_src("src_1", url="https://ex.com/a", created_at=datetime(2024, 1, 1)),
_src("src_2", url="https://ex.com/a", created_at=datetime(2024, 2, 1)),
]
out = classify_junk_sources(sources)
deleted_ids = sorted(c[0] for c in out)
assert deleted_ids == ["src_2", "src_3"]
assert all(c[3].startswith("duplicate_of:src_1"[:21]) for c in out)
def test_dedup_when_oldest_is_error(self) -> None:
# First copy (oldest) is error → flagged as error_status, NOT recorded
# in seen_urls. Second copy becomes the kept anchor; third copy
# deduped against it. Both deletions report their own reason.
sources = [
_src(
"src_e",
url="https://ex.com/a",
status=SourceStatus.ERROR,
created_at=datetime(2024, 1, 1),
),
_src("src_ok", url="https://ex.com/a", created_at=datetime(2024, 2, 1)),
_src("src_dup", url="https://ex.com/a", created_at=datetime(2024, 3, 1)),
]
out = classify_junk_sources(sources)
by_id = {c[0]: c[3] for c in out}
assert set(by_id) == {"src_e", "src_dup"}
assert by_id["src_e"] == "error_status"
assert by_id["src_dup"].startswith("duplicate_of:")
def test_dedup_preserves_query_string(self) -> None:
# Different YouTube video IDs (via ?v=) must NOT be collapsed.
sources = [
_src("yt_a", url="https://youtube.com/watch?v=AAA"),
_src("yt_b", url="https://youtube.com/watch?v=BBB"),
]
assert classify_junk_sources(sources) == []
def test_dedup_strips_fragment(self) -> None:
sources = [
_src("src_1", url="https://ex.com/a#top", created_at=datetime(2024, 1, 1)),
_src("src_2", url="https://ex.com/a#bottom", created_at=datetime(2024, 2, 1)),
]
out = classify_junk_sources(sources)
assert [c[0] for c in out] == ["src_2"]
def test_dedup_is_case_insensitive_on_scheme_and_host(self) -> None:
# Per RFC 3986, scheme and host are case-insensitive, so mixed-case
# copies of the same URL must be recognised as duplicates.
sources = [
_src("src_1", url="https://Example.COM/a", created_at=datetime(2024, 1, 1)),
_src("src_2", url="HTTPS://example.com/a", created_at=datetime(2024, 2, 1)),
]
out = classify_junk_sources(sources)
assert [c[0] for c in out] == ["src_2"]
def test_undated_sources_go_to_end_of_sort(self) -> None:
# If src_undated were placed at position 0 (epoch sentinel), it would
# be kept and src_dated deleted as a duplicate. With float('inf') the
# dated one wins.
sources = [
_src("src_undated", url="https://ex.com/a", created_at=None),
_src("src_dated", url="https://ex.com/a", created_at=datetime(2024, 1, 1)),
]
out = classify_junk_sources(sources)
assert [c[0] for c in out] == ["src_undated"]
def test_source_with_no_url_is_not_deduped(self) -> None:
# Text-only sources have url=None — they must never be deduped together.
sources = [
_src("src_1", title="Note A"),
_src("src_2", title="Note B"),
]
assert classify_junk_sources(sources) == []
# ===========================================================================
# normalize_url_for_dedup — net-new direct coverage
# ===========================================================================
class TestNormalizeUrlForDedup:
def test_strips_fragment_only(self) -> None:
assert normalize_url_for_dedup("https://ex.com/a#frag") == "https://ex.com/a"
def test_preserves_query_string(self) -> None:
assert normalize_url_for_dedup("https://ex.com/a?v=1#frag") == "https://ex.com/a?v=1"
def test_lowercases_scheme_and_host_only(self) -> None:
# Path case is preserved; scheme + host are lowercased.
assert normalize_url_for_dedup("HTTPS://Example.COM/A/Path") == "https://example.com/A/Path"
# ===========================================================================
# candidates_payload — JSON projection shape
# ===========================================================================
def test_candidates_payload_shape() -> None:
candidates = [
("src_1", "Page", "ready", "duplicate_of:src_0aaa"),
("src_2", "oops", "error", "error_status"),
]
assert candidates_payload(candidates) == [
{
"id": "src_1",
"title": "Page",
"status": "ready",
"reason": "duplicate_of:src_0aaa",
},
{"id": "src_2", "title": "oops", "status": "error", "reason": "error_status"},
]
def test_candidates_payload_empty() -> None:
assert candidates_payload([]) == []
# ===========================================================================
# run_source_clean — orchestration
# ===========================================================================
def _junk_sources() -> list[Source]:
"""Two junk sources: one error-status, one gateway-title."""
return [
_src("src_err", title="oops", status=SourceStatus.ERROR),
_src("src_block", title="Just a Moment...", url="https://ex.com/x"),
]
@pytest.mark.asyncio
async def test_run_clean_already_clean_short_circuits() -> None:
list_sources = AsyncMock(return_value=[_src("src_ok", title="Page", url="https://ex.com/a")])
delete_source = AsyncMock()
result = await run_source_clean(
notebook_id="nb_1",
dry_run=False,
yes=True,
list_sources=list_sources,
delete_source=delete_source,
confirm_delete=lambda n: True,
)
assert result.status == "already_clean"
assert result.candidates == ()
assert result.candidate_count == 0
delete_source.assert_not_called()
@pytest.mark.asyncio
async def test_run_clean_dry_run_skips_delete() -> None:
list_sources = AsyncMock(return_value=_junk_sources())
delete_source = AsyncMock()
seen: list[object] = []
result = await run_source_clean(
notebook_id="nb_1",
dry_run=True,
yes=False,
list_sources=list_sources,
delete_source=delete_source,
confirm_delete=lambda n: True,
on_candidates=seen.append,
)
assert result.status == "dry_run"
assert result.candidate_count == 2
assert result.deleted_count == 0
delete_source.assert_not_called()
# on_candidates fires once with the classified list.
assert len(seen) == 1
@pytest.mark.asyncio
async def test_run_clean_declined_confirmation_cancels() -> None:
list_sources = AsyncMock(return_value=_junk_sources())
delete_source = AsyncMock()
result = await run_source_clean(
notebook_id="nb_1",
dry_run=False,
yes=False,
list_sources=list_sources,
delete_source=delete_source,
confirm_delete=lambda n: False,
)
assert result.status == "cancelled"
assert result.candidate_count == 2
delete_source.assert_not_called()
@pytest.mark.asyncio
async def test_run_clean_yes_deletes_all_candidates() -> None:
list_sources = AsyncMock(return_value=_junk_sources())
delete_source = AsyncMock()
confirm = MagicMock() # never consulted under yes=True
started: list[int] = []
result = await run_source_clean(
notebook_id="nb_1",
dry_run=False,
yes=True,
list_sources=list_sources,
delete_source=delete_source,
confirm_delete=confirm,
on_delete_start=started.append,
)
assert result.status == "completed"
assert result.deleted_count == 2
assert result.failure_count == 0
assert delete_source.await_count == 2
confirm.assert_not_called()
assert started == [2]
@pytest.mark.asyncio
async def test_run_clean_captures_partial_failures() -> None:
list_sources = AsyncMock(return_value=_junk_sources())
async def fake_delete(notebook_id: str, sid: str) -> None:
if sid == "src_block":
raise RuntimeError("boom")
delete_source = AsyncMock(side_effect=fake_delete)
result = await run_source_clean(
notebook_id="nb_1",
dry_run=False,
yes=True,
list_sources=list_sources,
delete_source=delete_source,
confirm_delete=lambda n: True,
)
assert result.status == "completed"
assert result.deleted_count == 1
assert result.failure_count == 1
failing_ids = {sid for sid, _ in result.failures}
assert failing_ids == {"src_block"}
@pytest.mark.asyncio
async def test_run_clean_batches_with_sleep_between_chunks() -> None:
# 12 dup sources → two chunks of 10 + 2; sleep fires once between chunks.
dups = [
_src(f"src_{i}", url="https://ex.com/a", created_at=datetime(2024, 1, i + 1))
for i in range(12)
]
list_sources = AsyncMock(return_value=dups)
delete_source = AsyncMock()
sleep = AsyncMock()
result = await run_source_clean(
notebook_id="nb_1",
dry_run=False,
yes=True,
list_sources=list_sources,
delete_source=delete_source,
confirm_delete=lambda n: True,
sleep=sleep,
)
# Oldest of the 12 is kept; the other 11 are duplicates deleted.
assert result.status == "completed"
assert result.deleted_count == 11
# 11 deletions span two chunks → exactly one inter-chunk sleep.
sleep.assert_awaited_once()
@pytest.mark.asyncio
async def test_run_clean_uses_injected_classifier() -> None:
# An injected classifier overrides the default so callers can pre-classify.
sentinel_candidates = [("src_x", "T", "ready", "error_status")]
list_sources = AsyncMock(return_value=[_src("src_x", title="T")])
delete_source = AsyncMock()
result = await run_source_clean(
notebook_id="nb_1",
dry_run=True,
yes=False,
list_sources=list_sources,
delete_source=delete_source,
confirm_delete=lambda n: True,
classify_sources=lambda srcs: sentinel_candidates,
)
assert result.status == "dry_run"
assert result.candidates == tuple(sentinel_candidates)