diff --git a/manga_watch/repair_mojibake.py b/manga_watch/repair_mojibake.py
index 9869f12..4f09e09 100644
--- a/manga_watch/repair_mojibake.py
+++ b/manga_watch/repair_mojibake.py
@@ -13,6 +13,7 @@
import argparse
import json
import os
+import shutil
import sys
from typing import Dict, List, Optional, TextIO, Tuple
@@ -33,7 +34,7 @@ def _contains_cjk(text: str) -> bool:
)
-def repair_mojibake(text: str) -> str:
+def repair_mojibake(text: object) -> str:
"""Repair a single string if it is a Latin-1 rendering of UTF-8 CJK text.
Rules, in order:
@@ -124,6 +125,11 @@ def parse_args(argv=None):
parser.add_argument("--state", default=get_state_path())
parser.add_argument("--backend", choices=("json", "firestore"), default="json")
parser.add_argument("--dry-run", action="store_true", help="do not save; print report only")
+ parser.add_argument(
+ "--backup-path",
+ default=None,
+ help="copy the state file here before applying repairs (apply mode only)",
+ )
parser.add_argument("--json", action="store_true", dest="json_output")
return parser.parse_args(argv)
@@ -143,6 +149,8 @@ def main(
state = load_state(args.state, backend=args.backend)
repaired_state, report = repair_state(state)
if not args.dry_run:
+ if args.backup_path:
+ shutil.copy2(args.state, args.backup_path)
save_state(repaired_state, args.state, backend=args.backend)
except Exception as exc:
print(f"[repair_mojibake] error: {exc}", file=err)
diff --git a/manga_watch/sources/base.py b/manga_watch/sources/base.py
index 4dedd12..04ea98c 100644
--- a/manga_watch/sources/base.py
+++ b/manga_watch/sources/base.py
@@ -57,8 +57,10 @@ def decode_response_bytes(
) -> str:
"""Decode raw HTTP response bytes to str with a strict fallback order.
- Order: declared charset parameter (if any) -> strict UTF-8 (with BOM
- stripping) -> apparent encoding -> latin-1 with replacement.
+ Order: declared charset parameter (if any) -> strict UTF-8 -> apparent
+ encoding -> latin-1 with replacement.
+
+ A single leading U+FEFF is stripped on every successful decode path.
The declared charset and apparent-encoding steps decode with
errors="replace", so a single malformed byte never discards the
@@ -70,9 +72,13 @@ def decode_response_bytes(
match = _CHARSET_RE.search(content_type)
if match:
try:
- return content.decode(match.group(1), errors="replace")
+ text = content.decode(match.group(1), errors="replace")
except LookupError:
pass
+ else:
+ if text.startswith("\ufeff"):
+ text = text[1:]
+ return text
try:
text = content.decode("utf-8")
except UnicodeDecodeError:
@@ -83,10 +89,17 @@ def decode_response_bytes(
return text
if apparent_encoding is not None:
try:
- return content.decode(apparent_encoding, errors="replace")
+ text = content.decode(apparent_encoding, errors="replace")
except LookupError:
pass
- return content.decode("latin-1", errors="replace")
+ else:
+ if text.startswith("\ufeff"):
+ text = text[1:]
+ return text
+ text = content.decode("latin-1", errors="replace")
+ if text.startswith("\ufeff"):
+ text = text[1:]
+ return text
class HttpClient(Protocol):
diff --git a/tests/_firestore_fakes.py b/tests/_firestore_fakes.py
new file mode 100644
index 0000000..5869d5c
--- /dev/null
+++ b/tests/_firestore_fakes.py
@@ -0,0 +1,156 @@
+"""Shared Firestore fakes for tests (test-only helper).
+
+Moved verbatim from tests/test_firestore_storage.py (Todo 10 decoupling).
+Import from here instead of tests.test_firestore_storage to avoid
+test-to-test imports. stdlib-only; no imports from tests/* (no cycles).
+"""
+
+import json
+
+
+def make_watchlist():
+ return {
+ "version": 2,
+ "works": [
+ {
+ "id": "work-1",
+ "source": "comic-walker",
+ "seed_url": "https://comic-walker.com/detail/work-1",
+ "enabled": True,
+ "notification_policy": {
+ "mode": "all",
+ "allowed_update_types": None,
+ },
+ }
+ ],
+ }
+
+
+def make_state():
+ return {
+ "version": 2,
+ "works": {
+ "work-1": {
+ "latest": {
+ "series_title": "作品A",
+ "episode_title": "第2話",
+ "latest_key": "episode-2",
+ "url": "https://example.com/episodes/2",
+ "update_type": "main_story",
+ "default_notify": True,
+ },
+ "history": [
+ {
+ "event_id": "event-1",
+ "detected_at": "2023-11-14T22:13:20Z",
+ "latest": {
+ "series_title": "作品A",
+ "episode_title": "第2話",
+ "latest_key": "episode-2",
+ },
+ }
+ ],
+ "unread": {"event_ids": ["event-1"]},
+ }
+ },
+ "last_run_at": 1_700_000_000,
+ "notification_outbox": [
+ {
+ "event": {
+ "schema_version": 1,
+ "event_id": "event-1",
+ "work_id": "work-1",
+ "latest_key": "episode-2",
+ "series_title": "作品A",
+ "update_type": "main_story",
+ "detected_at": "2023-11-14T22:13:20Z",
+ "from": {"latest_key": "episode-1"},
+ "to": {"latest_key": "episode-2"},
+ },
+ "pending_backends": ["stdout", "webhook"],
+ "attempt_count": 1,
+ "last_attempted_at": "2023-11-14T22:15:00Z",
+ "last_error": "webhook timeout",
+ }
+ ],
+ "discord_delivery": {
+ "daily_notification": {
+ "delivered_latest_keys": {
+ "work-1": {
+ "latest_key": "episode-2",
+ "delivered_at": None,
+ },
+ },
+ "pending_messages": [
+ {
+ "channel_id": "main-channel",
+ "content": "pending daily message",
+ "message_keys": [
+ {
+ "work_id": "work-1",
+ "latest_key": "episode-2",
+ }
+ ],
+ "created_at": "2023-11-14T22:13:20Z",
+ "attempt_count": 0,
+ "last_attempted_at": None,
+ "last_error": None,
+ }
+ ],
+ }
+ },
+ }
+
+
+class FakeSnapshot:
+ def __init__(self, doc_id, payload):
+ self.id = doc_id
+ self._payload = None if payload is None else json.loads(json.dumps(payload, ensure_ascii=False))
+
+ @property
+ def exists(self):
+ return self._payload is not None
+
+ def to_dict(self):
+ if self._payload is None:
+ return None
+ return json.loads(json.dumps(self._payload, ensure_ascii=False))
+
+
+class FakeDocument:
+ def __init__(self, store, collection_name, doc_id):
+ self.store = store
+ self.collection_name = collection_name
+ self.doc_id = doc_id
+
+ def get(self):
+ return FakeSnapshot(self.doc_id, self.store.get(self.collection_name, {}).get(self.doc_id))
+
+ def set(self, payload):
+ self.store.setdefault(self.collection_name, {})[self.doc_id] = json.loads(
+ json.dumps(payload, ensure_ascii=False)
+ )
+
+ def delete(self):
+ self.store.setdefault(self.collection_name, {}).pop(self.doc_id, None)
+
+
+class FakeCollection:
+ def __init__(self, store, collection_name):
+ self.store = store
+ self.collection_name = collection_name
+
+ def document(self, doc_id):
+ return FakeDocument(self.store, self.collection_name, doc_id)
+
+ def stream(self):
+ docs = self.store.setdefault(self.collection_name, {})
+ return [FakeSnapshot(doc_id, payload) for doc_id, payload in sorted(docs.items())]
+
+
+class FakeFirestoreClient:
+ def __init__(self):
+ self.store = {}
+
+ def collection(self, collection_name):
+ return FakeCollection(self.store, collection_name)
diff --git a/tests/acceptance_traceability.py b/tests/acceptance_traceability.py
index 6fb54dd..69657a2 100644
--- a/tests/acceptance_traceability.py
+++ b/tests/acceptance_traceability.py
@@ -10,68 +10,67 @@
"TC-LATEST-02": {
"layer": "formatter",
"tests": [
- "tests.test_discord_latest.DiscordLatestTests.test_build_latest_query_response_uses_watchlist_order_and_ignores_disabled_and_orphans",
+ "tests.test_discord_latest.DiscordLatestTests.test_build_latest_query_response_matrix",
],
},
"TC-LATEST-03": {
"layer": "formatter",
"tests": [
- "tests.test_discord_latest.DiscordLatestTests.test_build_latest_query_response_uses_watchlist_order_and_ignores_disabled_and_orphans",
+ "tests.test_discord_latest.DiscordLatestTests.test_build_latest_query_response_matrix",
],
},
"TC-LATEST-04": {
"layer": "formatter",
"tests": [
- "tests.test_discord_latest.DiscordLatestTests.test_build_latest_query_response_uses_watchlist_order_and_ignores_disabled_and_orphans",
+ "tests.test_discord_latest.DiscordLatestTests.test_build_latest_query_response_matrix",
],
},
"TC-LATEST-05": {
"layer": "formatter",
"tests": [
- "tests.test_discord_latest.DiscordLatestTests.test_build_latest_query_response_returns_empty_message_when_all_works_are_unfetched",
- "tests.test_discord_latest.DiscordLatestTests.test_build_latest_query_response_includes_unfetched_rows_when_mixed_with_saved_results",
+ "tests.test_discord_latest.DiscordLatestTests.test_build_latest_query_response_matrix",
],
},
"TC-LATEST-06": {
"layer": "formatter",
"tests": [
- "tests.test_discord_latest.DiscordLatestTests.test_build_latest_query_response_returns_empty_message_when_all_works_are_unfetched",
+ "tests.test_discord_latest.DiscordLatestTests.test_build_latest_query_response_matrix",
],
},
"TC-LATEST-07": {
"layer": "formatter",
"tests": [
- "tests.test_discord_latest.DiscordLatestTests.test_build_latest_query_response_uses_watchlist_order_and_ignores_disabled_and_orphans",
+ "tests.test_discord_latest.DiscordLatestTests.test_build_latest_query_response_matrix",
],
},
"TC-LATEST-08": {
"layer": "formatter",
"tests": [
- "tests.test_discord_latest.DiscordLatestTests.test_build_latest_query_response_uses_plain_text_and_fallback_labels_without_url",
+ "tests.test_discord_latest.DiscordLatestTests.test_build_latest_query_response_matrix",
],
},
"TC-LATEST-09": {
"layer": "formatter",
"tests": [
- "tests.test_discord_latest.DiscordLatestTests.test_build_latest_query_response_uses_plain_text_and_fallback_labels_without_url",
+ "tests.test_discord_latest.DiscordLatestTests.test_build_latest_query_response_matrix",
],
},
"TC-LATEST-10": {
"layer": "formatter",
"tests": [
- "tests.test_discord_latest.DiscordLatestTests.test_episode_label_truncation_matches_spec_examples",
+ "tests.test_discord_latest.DiscordLatestTests.test_build_latest_query_response_matrix",
],
},
"TC-LATEST-11": {
"layer": "formatter",
"tests": [
- "tests.test_discord_latest.DiscordLatestTests.test_episode_label_truncation_matches_spec_examples",
+ "tests.test_discord_latest.DiscordLatestTests.test_build_latest_query_response_matrix",
],
},
"TC-LATEST-12": {
"layer": "formatter",
"tests": [
- "tests.test_discord_latest.DiscordLatestTests.test_build_latest_query_response_warns_for_stale_saved_data",
+ "tests.test_discord_latest.DiscordLatestTests.test_build_latest_query_response_matrix",
],
},
"TC-DAILY-01": {
@@ -91,7 +90,7 @@
"TC-DAILY-03": {
"layer": "orchestration",
"tests": [
- "tests.test_discord_outbound.DiscordOutboundTests.test_enqueue_daily_notification_dedupes_by_work_id_and_latest_key_even_if_metadata_changes",
+ "tests.test_discord_outbound.DiscordOutboundTests.test_enqueue_daily_notification_dedupe_matrix",
],
},
"TC-DAILY-04": {
@@ -152,8 +151,7 @@
"TC-FETCH-03": {
"layer": "orchestration",
"tests": [
- "tests.test_runner.RunnerTests.test_run_coordinator_queues_scheduled_while_fetch_is_in_progress",
- "tests.test_runner.RunnerTests.test_run_coordinator_queues_startup_while_fetch_is_in_progress",
+ "tests.test_runner.RunnerTests.test_run_coordinator_queues_second_trigger_while_fetch_is_in_progress",
],
},
"TC-FETCH-04": {
@@ -239,8 +237,7 @@
"TC-SEC-01": {
"layer": "security",
"tests": [
- "tests.test_discord_outbound.DiscordOutboundTests.test_discord_channel_client_masks_bot_token_in_transport_error",
- "tests.test_discord_outbound.DiscordOutboundTests.test_discord_channel_client_masks_bot_token_in_error_response",
+ "tests.test_discord_outbound.DiscordOutboundTests.test_discord_channel_client_masks_bot_token_matrix",
"tests.test_runner.RunnerTests.test_run_once_redacts_secrets_from_failure_outputs",
],
},
diff --git a/tests/test_acceptance_manifest.py b/tests/test_acceptance_manifest.py
new file mode 100644
index 0000000..8aca2cf
--- /dev/null
+++ b/tests/test_acceptance_manifest.py
@@ -0,0 +1,37 @@
+import importlib
+import unittest
+
+from tests.acceptance_traceability import ACCEPTANCE_TRACEABILITY
+
+
+def _iter_manifest_paths():
+ seen = set()
+ for entry in ACCEPTANCE_TRACEABILITY.values():
+ for dotted in entry.get("tests", []):
+ if dotted not in seen:
+ seen.add(dotted)
+ yield dotted
+
+
+def resolve_dotted_test_path(dotted: str):
+ module_name, class_name, method_name = dotted.rsplit(".", 2)
+ module = importlib.import_module(module_name)
+ test_class = getattr(module, class_name)
+ return getattr(test_class, method_name)
+
+
+class AcceptanceManifestTests(unittest.TestCase):
+ def test_all_manifest_paths_resolve(self):
+ paths = list(_iter_manifest_paths())
+ self.assertTrue(paths, "ACCEPTANCE_TRACEABILITY has no test paths")
+ unresolved = []
+ for dotted in paths:
+ try:
+ resolve_dotted_test_path(dotted)
+ except (ImportError, AttributeError) as exc:
+ unresolved.append(f"{dotted}: {exc}")
+ self.assertEqual([], unresolved, f"unresolvable manifest paths: {unresolved}")
+
+
+if __name__ == "__main__":
+ unittest.main()
diff --git a/tests/test_acceptance_suite.py b/tests/test_acceptance_suite.py
deleted file mode 100644
index 9ce57b7..0000000
--- a/tests/test_acceptance_suite.py
+++ /dev/null
@@ -1,21 +0,0 @@
-import importlib
-import unittest
-
-from tests.acceptance_suite import (
- MOCKED_ACCEPTANCE_MODULES,
- TRACEABILITY_CASES,
- TRACEABILITY_MODULES,
-)
-
-
-class AcceptanceSuiteRegistryTests(unittest.TestCase):
- def test_traceability_modules_match_mocked_suite(self):
- self.assertEqual(set(MOCKED_ACCEPTANCE_MODULES), set(TRACEABILITY_MODULES))
-
- def test_traceability_manifest_is_non_empty(self):
- self.assertTrue(TRACEABILITY_CASES)
-
- def test_mocked_acceptance_modules_are_importable(self):
- for module_name in MOCKED_ACCEPTANCE_MODULES:
- imported = importlib.import_module(module_name)
- self.assertIsNotNone(imported)
diff --git a/tests/test_acceptance_traceability.py b/tests/test_acceptance_traceability.py
deleted file mode 100644
index b914258..0000000
--- a/tests/test_acceptance_traceability.py
+++ /dev/null
@@ -1,77 +0,0 @@
-from __future__ import annotations
-
-import importlib
-import unittest
-
-from tests.acceptance_traceability import ACCEPTANCE_TRACEABILITY
-
-
-EXPECTED_MOCKED_CASE_IDS = {
- "TC-LATEST-01",
- "TC-LATEST-02",
- "TC-LATEST-03",
- "TC-LATEST-04",
- "TC-LATEST-05",
- "TC-LATEST-06",
- "TC-LATEST-07",
- "TC-LATEST-08",
- "TC-LATEST-09",
- "TC-LATEST-10",
- "TC-LATEST-11",
- "TC-LATEST-12",
- "TC-DAILY-01",
- "TC-DAILY-02",
- "TC-DAILY-03",
- "TC-DAILY-04",
- "TC-DAILY-05",
- "TC-DAILY-06",
- "TC-DAILY-07",
- "TC-DAILY-08",
- "TC-DAILY-09",
- "TC-DAILY-10",
- "TC-FETCH-01",
- "TC-FETCH-02",
- "TC-FETCH-03",
- "TC-FETCH-04",
- "TC-REPORT-01",
- "TC-REPORT-02",
- "TC-REPORT-03",
- "TC-REPORT-04",
- "TC-DELIVERY-01",
- "TC-DELIVERY-02",
- "TC-DELIVERY-03",
- "TC-DELIVERY-04",
- "TC-STATE-01",
- "TC-STATE-02",
- "TC-STATE-03",
- "TC-SEC-01",
- "TC-SEC-02",
- "TC-SEC-03",
-}
-
-VALID_LAYERS = {
- "formatter",
- "mocked_discord_integration",
- "orchestration",
- "persistence",
- "security",
-}
-
-
-class AcceptanceTraceabilityTests(unittest.TestCase):
- def test_manifest_covers_mocked_acceptance_cases(self):
- self.assertEqual(EXPECTED_MOCKED_CASE_IDS, set(ACCEPTANCE_TRACEABILITY))
-
- def test_manifest_uses_known_layers(self):
- for case_id, entry in ACCEPTANCE_TRACEABILITY.items():
- self.assertIn(entry["layer"], VALID_LAYERS, case_id)
-
- def test_manifest_points_to_existing_tests(self):
- for case_id, entry in ACCEPTANCE_TRACEABILITY.items():
- self.assertTrue(entry["tests"], case_id)
- for dotted_path in entry["tests"]:
- module_name, class_name, method_name = dotted_path.rsplit(".", 2)
- module = importlib.import_module(module_name)
- klass = getattr(module, class_name)
- self.assertTrue(issubclass(klass, unittest.TestCase), dotted_path)
- self.assertTrue(hasattr(klass, method_name), dotted_path)
diff --git a/tests/test_availability.py b/tests/test_availability.py
index c46dda4..c540b4f 100644
--- a/tests/test_availability.py
+++ b/tests/test_availability.py
@@ -18,142 +18,139 @@ def test_supported_availability_sources_are_manga_sources_only(self):
self.assertEqual(("comic-walker", "nicovideo-manga"), supported_availability_sources())
self.assertNotIn("kakuyomu", supported_availability_sources())
- def test_comic_walker_resolves_exact_episode_url(self):
- seed_url = "https://comic-walker.com/detail/KC_004800_S"
- html = """
-
- 第1話
- 第2話①
-
- """
-
- result = resolve_episode_availability(
- "comic-walker",
- seed_url,
- "1話",
- http_client=StaticHttpClient({seed_url: html}),
- )
-
- self.assertEqual(
+ def test_resolves_exact_episode_url(self):
+ cases = (
{
"source": "comic-walker",
- "status": "free_now",
- "url": "https://comic-walker.com/detail/KC_004800_S/episodes/KC_0048000000100012_E",
+ "seed_url": "https://comic-walker.com/detail/KC_004800_S",
+ "query": "1話",
+ "html": """
+
+ 第1話
+ 第2話①
+
+ """,
+ "expected": {
+ "source": "comic-walker",
+ "status": "free_now",
+ "url": "https://comic-walker.com/detail/KC_004800_S/episodes/KC_0048000000100012_E",
+ },
+ },
+ {
+ "source": "nicovideo-manga",
+ "seed_url": "https://manga.nicovideo.jp/comic/62782",
+ "query": "第1話",
+ "html": """
+
+ ニセモノの錬金術師 第1話 / 杉浦次郎
+ ニセモノの錬金術師 第2話 / 杉浦次郎
+
+ """,
+ "expected": {
+ "source": "nicovideo-manga",
+ "status": "free_now",
+ "url": "https://manga.nicovideo.jp/watch/mg1000001",
+ },
},
- result,
- )
-
- def test_comic_walker_does_not_match_nearby_episode_number(self):
- seed_url = "https://comic-walker.com/detail/KC_004800_S"
- html = """
-
- 第10話
- 第11話
-
- """
-
- result = resolve_episode_availability(
- "comic-walker",
- seed_url,
- "第1話",
- http_client=StaticHttpClient({seed_url: html}),
)
- self.assertEqual({"source": "comic-walker", "status": "not_found", "url": None}, result)
+ for entry in cases:
+ with self.subTest(source=entry["source"]):
+ result = resolve_episode_availability(
+ entry["source"],
+ entry["seed_url"],
+ entry["query"],
+ http_client=StaticHttpClient({entry["seed_url"]: entry["html"]}),
+ )
- def test_comic_walker_binds_episode_label_to_same_anchor(self):
- seed_url = "https://comic-walker.com/detail/KC_004800_S"
- html = """
-
- 第2話
- 第1話
-
- """
-
- result = resolve_episode_availability(
- "comic-walker",
- seed_url,
- "第1話",
- http_client=StaticHttpClient({seed_url: html}),
- )
+ self.assertEqual(entry["expected"], result)
- self.assertEqual(
+ def test_does_not_match_nearby_episode_number(self):
+ cases = (
{
"source": "comic-walker",
- "status": "free_now",
- "url": "https://comic-walker.com/detail/KC_004800_S/episodes/KC_0048000000100012_E",
+ "seed_url": "https://comic-walker.com/detail/KC_004800_S",
+ "query": "第1話",
+ "html": """
+
+ 第10話
+ 第11話
+
+ """,
+ "expected": {"source": "comic-walker", "status": "not_found", "url": None},
},
- result,
- )
-
- def test_nicovideo_manga_resolves_exact_episode_url(self):
- seed_url = "https://manga.nicovideo.jp/comic/62782"
- html = """
-
- ニセモノの錬金術師 第1話 / 杉浦次郎
- ニセモノの錬金術師 第2話 / 杉浦次郎
-
- """
-
- result = resolve_episode_availability(
- "nicovideo-manga",
- seed_url,
- "第1話",
- http_client=StaticHttpClient({seed_url: html}),
- )
-
- self.assertEqual(
{
"source": "nicovideo-manga",
- "status": "free_now",
- "url": "https://manga.nicovideo.jp/watch/mg1000001",
+ "seed_url": "https://manga.nicovideo.jp/comic/62782",
+ "query": "1話",
+ "html": """
+
+ ニセモノの錬金術師 第10話 / 杉浦次郎
+ ニセモノの錬金術師 第11話 / 杉浦次郎
+
+ """,
+ "expected": {"source": "nicovideo-manga", "status": "not_found", "url": None},
},
- result,
)
- def test_nicovideo_manga_does_not_match_nearby_episode_number(self):
- seed_url = "https://manga.nicovideo.jp/comic/62782"
- html = """
-
- ニセモノの錬金術師 第10話 / 杉浦次郎
- ニセモノの錬金術師 第11話 / 杉浦次郎
-
- """
-
- result = resolve_episode_availability(
- "nicovideo-manga",
- seed_url,
- "1話",
- http_client=StaticHttpClient({seed_url: html}),
- )
+ for entry in cases:
+ with self.subTest(source=entry["source"]):
+ result = resolve_episode_availability(
+ entry["source"],
+ entry["seed_url"],
+ entry["query"],
+ http_client=StaticHttpClient({entry["seed_url"]: entry["html"]}),
+ )
- self.assertEqual({"source": "nicovideo-manga", "status": "not_found", "url": None}, result)
+ self.assertEqual(entry["expected"], result)
- def test_nicovideo_manga_binds_episode_label_to_same_anchor(self):
- seed_url = "https://manga.nicovideo.jp/comic/62782"
- html = """
-
- ニセモノの錬金術師 第2話 / 杉浦次郎
- ニセモノの錬金術師 第1話 / 杉浦次郎
-
- """
-
- result = resolve_episode_availability(
- "nicovideo-manga",
- seed_url,
- "第1話",
- http_client=StaticHttpClient({seed_url: html}),
- )
-
- self.assertEqual(
+ def test_binds_episode_label_to_same_anchor(self):
+ cases = (
+ {
+ "source": "comic-walker",
+ "seed_url": "https://comic-walker.com/detail/KC_004800_S",
+ "query": "第1話",
+ "html": """
+
+ 第2話
+ 第1話
+
+ """,
+ "expected": {
+ "source": "comic-walker",
+ "status": "free_now",
+ "url": "https://comic-walker.com/detail/KC_004800_S/episodes/KC_0048000000100012_E",
+ },
+ },
{
"source": "nicovideo-manga",
- "status": "free_now",
- "url": "https://manga.nicovideo.jp/watch/mg1000001",
+ "seed_url": "https://manga.nicovideo.jp/comic/62782",
+ "query": "第1話",
+ "html": """
+
+ ニセモノの錬金術師 第2話 / 杉浦次郎
+ ニセモノの錬金術師 第1話 / 杉浦次郎
+
+ """,
+ "expected": {
+ "source": "nicovideo-manga",
+ "status": "free_now",
+ "url": "https://manga.nicovideo.jp/watch/mg1000001",
+ },
},
- result,
)
+ for entry in cases:
+ with self.subTest(source=entry["source"]):
+ result = resolve_episode_availability(
+ entry["source"],
+ entry["seed_url"],
+ entry["query"],
+ http_client=StaticHttpClient({entry["seed_url"]: entry["html"]}),
+ )
+
+ self.assertEqual(entry["expected"], result)
+
def test_returns_not_found_without_exact_episode(self):
seed_url = "https://manga.nicovideo.jp/comic/62782"
html = 'ニセモノの錬金術師 第2話 / 杉浦次郎'
diff --git a/tests/test_check.py b/tests/test_check.py
index 29b4984..0d20762 100644
--- a/tests/test_check.py
+++ b/tests/test_check.py
@@ -215,92 +215,53 @@ def test_check_module_runs_via_python_m(self):
self.assertEqual({}, saved_state["works"])
self.assertIsInstance(saved_state["last_run_at"], int)
- def test_check_module_reports_load_watchlist_failures_as_json(self):
- missing_watchlist_path = Path(tempfile.gettempdir()) / "comic-crawler-missing-watchlist.json"
- if missing_watchlist_path.exists():
- missing_watchlist_path.unlink()
-
- result = self.run_check_module(missing_watchlist_path)
-
- self.assertEqual(1, result.returncode)
- self.assertEqual(
- {
- "updates": [],
- "errors": {
- "sources": [],
- "run": [
- {
+ def test_check_module_reports_stage_failures_as_json(self):
+ for failure_point in ("load_watchlist", "load_state", "save_state", "http_config"):
+ with self.subTest(failure_point=failure_point):
+ with tempfile.TemporaryDirectory() as tmpdir:
+ watchlist_path = Path(tmpdir) / "watchlist.json"
+ state_path = Path(tmpdir) / "state.json"
+ write_watchlist(watchlist_path, [])
+ extra_env = {"MANGA_WATCH_STATE": str(state_path)}
+ expected = {}
+ if failure_point == "load_watchlist":
+ watchlist_path = Path(tempfile.gettempdir()) / "comic-crawler-missing-watchlist.json"
+ if watchlist_path.exists():
+ watchlist_path.unlink()
+ extra_env = {}
+ expected = {
"stage": "load_watchlist",
"kind": "runtime",
"errorType": "FileNotFoundError",
- "message": f"[Errno 2] No such file or directory: '{missing_watchlist_path}'",
+ "message": f"[Errno 2] No such file or directory: '{watchlist_path}'",
}
- ],
- },
- },
- json.loads(result.stdout),
- )
- self.assertEqual("", result.stderr)
-
- def test_check_module_reports_load_state_failures_as_json(self):
- with tempfile.TemporaryDirectory() as tmpdir:
- watchlist_path = Path(tmpdir) / "watchlist.json"
- state_path = Path(tmpdir) / "state.json"
- write_watchlist(watchlist_path, [])
- state_path.write_text("{broken json", encoding="utf-8")
-
- result = self.run_check_module(
- watchlist_path,
- extra_env={"MANGA_WATCH_STATE": str(state_path)},
- )
-
- self.assertEqual(1, result.returncode)
- payload = json.loads(result.stdout)
- self.assertEqual([], payload["updates"])
- self.assertEqual([], payload["errors"]["sources"])
- self.assertEqual("load_state", payload["errors"]["run"][0]["stage"])
- self.assertEqual("JSONDecodeError", payload["errors"]["run"][0]["errorType"])
- self.assertEqual("", result.stderr)
-
- def test_check_module_reports_save_state_failures_as_json(self):
- with tempfile.TemporaryDirectory() as tmpdir:
- watchlist_path = Path(tmpdir) / "watchlist.json"
- write_watchlist(watchlist_path, [])
-
- result = self.run_check_module(
- watchlist_path,
- extra_env={"MANGA_WATCH_STATE": "/dev/null/state.json"},
- )
-
- self.assertEqual(1, result.returncode)
- payload = json.loads(result.stdout)
- self.assertEqual([], payload["updates"])
- self.assertEqual([], payload["errors"]["sources"])
- self.assertEqual("save_state", payload["errors"]["run"][0]["stage"])
- self.assertEqual("FileExistsError", payload["errors"]["run"][0]["errorType"])
- self.assertEqual("", result.stderr)
-
- def test_check_module_reports_invalid_http_config_as_json(self):
- with tempfile.TemporaryDirectory() as tmpdir:
- watchlist_path = Path(tmpdir) / "watchlist.json"
- state_path = Path(tmpdir) / "state.json"
- write_watchlist(watchlist_path, [])
-
- result = self.run_check_module(
- watchlist_path,
- extra_env={
- "MANGA_WATCH_STATE": str(state_path),
- "MANGA_WATCH_HTTP_WORKERS": "0",
- },
- )
-
- self.assertEqual(1, result.returncode)
- payload = json.loads(result.stdout)
- self.assertEqual([], payload["updates"])
- self.assertEqual([], payload["errors"]["sources"])
- self.assertEqual("http_config", payload["errors"]["run"][0]["stage"])
- self.assertEqual("ValueError", payload["errors"]["run"][0]["errorType"])
- self.assertEqual("", result.stderr)
+ elif failure_point == "load_state":
+ state_path.write_text("{broken json", encoding="utf-8")
+ expected = {"stage": "load_state", "errorType": "JSONDecodeError"}
+ elif failure_point == "save_state":
+ extra_env = {"MANGA_WATCH_STATE": "/dev/null/state.json"}
+ expected = {"stage": "save_state", "errorType": "FileExistsError"}
+ else:
+ extra_env = {
+ "MANGA_WATCH_STATE": str(state_path),
+ "MANGA_WATCH_HTTP_WORKERS": "0",
+ }
+ expected = {"stage": "http_config", "errorType": "ValueError"}
+
+ result = self.run_check_module(watchlist_path, extra_env=extra_env)
+
+ self.assertEqual(1, result.returncode)
+ payload = json.loads(result.stdout)
+ self.assertEqual([], payload["updates"])
+ self.assertEqual([], payload["errors"]["sources"])
+ self.assertEqual(1, len(payload["errors"]["run"]))
+ run_error = payload["errors"]["run"][0]
+ self.assertEqual(expected["stage"], run_error["stage"])
+ self.assertEqual(expected["errorType"], run_error["errorType"])
+ if "kind" in expected:
+ self.assertEqual(expected["kind"], run_error["kind"])
+ self.assertEqual(expected["message"], run_error["message"])
+ self.assertEqual("", result.stderr)
def test_normalize_item_returns_work_descriptor_fields(self):
item = check.normalize_item("https://kakuyomu.jp/works/123/episodes/456")
@@ -364,9 +325,14 @@ def test_build_watchlist_entry_uses_stable_comic_action_work_id(self):
self.assertEqual("comic-action:13933686331663374228", entry["id"])
self.assertEqual("comic-action", entry["source"])
- def test_build_watchlist_entry_canonicalizes_comicborder_episode_seed_to_rss(self):
- fake_client = mock.Mock()
- fake_client.get_text.return_value = """
+ def test_build_watchlist_entry_canonicalizes_feed_family_episode_seed_to_rss(self):
+ cases = [
+ (
+ "comicborder",
+ "comicborder.com",
+ "12207421983437812169",
+ "12207421983437805229",
+ """
@@ -375,27 +341,14 @@ def test_build_watchlist_entry_canonicalizes_comicborder_episode_seed_to_rss(sel
- """
- with mock.patch(
- "manga_watch.check.normalize_item",
- return_value={
- "source": "comicborder",
- "workId": "https://comicborder.com/episode/12207421983437812169",
- "seedUrl": "https://comicborder.com/episode/12207421983437812169",
- },
- ):
- entry = check.build_watchlist_entry(
- "https://comicborder.com/episode/12207421983437812169",
- http_client=fake_client,
- )
-
- self.assertEqual("comicborder:12207421983437805229", entry["id"])
- self.assertEqual("https://comicborder.com/rss/series/12207421983437805229", entry["seed_url"])
- self.assertEqual("comicborder", entry["source"])
-
- def test_build_watchlist_entry_canonicalizes_comic_earthstar_episode_seed_to_rss(self):
- fake_client = mock.Mock()
- fake_client.get_text.return_value = """
+ """,
+ ),
+ (
+ "comic-earthstar",
+ "comic-earthstar.com",
+ "12207421983526541742",
+ "12207421983526538413",
+ """
@@ -404,27 +357,14 @@ def test_build_watchlist_entry_canonicalizes_comic_earthstar_episode_seed_to_rss
- """
- with mock.patch(
- "manga_watch.check.normalize_item",
- return_value={
- "source": "comic-earthstar",
- "workId": "https://comic-earthstar.com/episode/12207421983526541742",
- "seedUrl": "https://comic-earthstar.com/episode/12207421983526541742",
- },
- ):
- entry = check.build_watchlist_entry(
- "https://comic-earthstar.com/episode/12207421983526541742",
- http_client=fake_client,
- )
-
- self.assertEqual("comic-earthstar:12207421983526538413", entry["id"])
- self.assertEqual("https://comic-earthstar.com/rss/series/12207421983526538413", entry["seed_url"])
- self.assertEqual("comic-earthstar", entry["source"])
-
- def test_build_watchlist_entry_canonicalizes_comic_trail_episode_seed_to_rss(self):
- fake_client = mock.Mock()
- fake_client.get_text.return_value = """
+ """,
+ ),
+ (
+ "comic-trail",
+ "comic-trail.com",
+ "2550689798402927313",
+ "14079602755560047206",
+ """
@@ -435,27 +375,14 @@ def test_build_watchlist_entry_canonicalizes_comic_trail_episode_seed_to_rss(sel
- """
- with mock.patch(
- "manga_watch.check.normalize_item",
- return_value={
- "source": "comic-trail",
- "workId": "https://comic-trail.com/episode/2550689798402927313",
- "seedUrl": "https://comic-trail.com/episode/2550689798402927313",
- },
- ):
- entry = check.build_watchlist_entry(
- "https://comic-trail.com/episode/2550689798402927313",
- http_client=fake_client,
- )
-
- self.assertEqual("comic-trail:14079602755560047206", entry["id"])
- self.assertEqual("https://comic-trail.com/rss/series/14079602755560047206", entry["seed_url"])
- self.assertEqual("comic-trail", entry["source"])
-
- def test_build_watchlist_entry_canonicalizes_comic_days_episode_seed_to_rss(self):
- fake_client = mock.Mock()
- fake_client.get_text.return_value = """
+ """,
+ ),
+ (
+ "comic-days",
+ "comic-days.com",
+ "12207421983746014850",
+ "13933686331650127004",
+ """
@@ -466,27 +393,14 @@ def test_build_watchlist_entry_canonicalizes_comic_days_episode_seed_to_rss(self
- """
- with mock.patch(
- "manga_watch.check.normalize_item",
- return_value={
- "source": "comic-days",
- "workId": "https://comic-days.com/episode/12207421983746014850",
- "seedUrl": "https://comic-days.com/episode/12207421983746014850",
- },
- ):
- entry = check.build_watchlist_entry(
- "https://comic-days.com/episode/12207421983746014850",
- http_client=fake_client,
- )
-
- self.assertEqual("comic-days:13933686331650127004", entry["id"])
- self.assertEqual("https://comic-days.com/rss/series/13933686331650127004", entry["seed_url"])
- self.assertEqual("comic-days", entry["source"])
-
- def test_build_watchlist_entry_canonicalizes_kuragebunch_episode_seed_to_rss(self):
- fake_client = mock.Mock()
- fake_client.get_text.return_value = """
+ """,
+ ),
+ (
+ "kuragebunch",
+ "kuragebunch.com",
+ "2550912964856491139",
+ "2550912964856487532",
+ """
@@ -495,23 +409,31 @@ def test_build_watchlist_entry_canonicalizes_kuragebunch_episode_seed_to_rss(sel
- """
- with mock.patch(
- "manga_watch.check.normalize_item",
- return_value={
- "source": "kuragebunch",
- "workId": "https://kuragebunch.com/episode/2550912964856491139",
- "seedUrl": "https://kuragebunch.com/episode/2550912964856491139",
- },
- ):
- entry = check.build_watchlist_entry(
- "https://kuragebunch.com/episode/2550912964856491139",
- http_client=fake_client,
- )
+ """,
+ ),
+ ]
- self.assertEqual("kuragebunch:2550912964856487532", entry["id"])
- self.assertEqual("https://kuragebunch.com/rss/series/2550912964856487532", entry["seed_url"])
- self.assertEqual("kuragebunch", entry["source"])
+ for source, host, episode_id, series_id, page_html in cases:
+ with self.subTest(source=source):
+ episode_url = f"https://{host}/episode/{episode_id}"
+ fake_client = mock.Mock()
+ fake_client.get_text.return_value = page_html
+ with mock.patch(
+ "manga_watch.check.normalize_item",
+ return_value={
+ "source": source,
+ "workId": episode_url,
+ "seedUrl": episode_url,
+ },
+ ):
+ entry = check.build_watchlist_entry(
+ episode_url,
+ http_client=fake_client,
+ )
+
+ self.assertEqual(f"{source}:{series_id}", entry["id"])
+ self.assertEqual(f"https://{host}/rss/series/{series_id}", entry["seed_url"])
+ self.assertEqual(source, entry["source"])
def test_build_watchlist_entry_uses_stable_champion_cross_work_id(self):
fake_client = mock.Mock()
fake_client.get_text.return_value = """
@@ -645,62 +567,174 @@ def test_evaluate_notification_policy_rejects_unknown_allowed_update_type(self):
update_type="main_story",
)
- def test_evaluate_notification_policy_truth_table(self):
+ def test_notification_policy_truth_table(self):
+ previous = {
+ "latest": {
+ "source": "fake",
+ "work_id": "work-1",
+ "latest_key": "ep-1",
+ "series_title": "作品A",
+ "episode_title": "第1話",
+ "url": "https://example.com/work/1",
+ },
+ "history": [],
+ "unread": {"event_ids": []},
+ "health": {
+ "last_checked_at": 10,
+ "last_success_at": 10,
+ "consecutive_failures": 0,
+ },
+ }
cases = [
(
+ "mode=all bypasses suppressed defaults",
{"mode": "all", "allowed_update_types": None},
"bonus",
+ False,
True,
"mode",
+ None,
"mode=all notifies every update_type",
),
(
+ "mode=important_only allows main_story",
{"mode": "important_only", "allowed_update_types": None},
"main_story",
True,
+ True,
"mode",
+ None,
"mode=important_only allows main_story",
),
(
+ "mode=important_only suppresses announcement",
{"mode": "important_only", "allowed_update_types": None},
"announcement",
False,
+ False,
"mode",
+ None,
"mode=important_only suppresses announcement",
),
(
+ "mode=mute suppresses unknown",
{"mode": "mute", "allowed_update_types": None},
"unknown",
+ True,
False,
"mode",
+ None,
"mode=mute suppresses every update_type",
),
(
+ "allowed_update_types override matched announcement",
{"mode": "mute", "allowed_update_types": ["announcement"]},
"announcement",
+ False,
True,
"allowed_update_types",
+ ["announcement"],
"allowed_update_types override matched announcement",
),
(
+ "allowed_update_types override did not include bonus",
{"mode": "all", "allowed_update_types": ["main_story"]},
"bonus",
False,
+ False,
"allowed_update_types",
+ ["main_story"],
"allowed_update_types override did not include bonus",
),
+ (
+ "mode=important_only allows unknown",
+ {"mode": "important_only", "allowed_update_types": None},
+ "unknown",
+ True,
+ True,
+ "mode",
+ None,
+ "mode=important_only allows unknown",
+ ),
+ (
+ "mode=important_only suppresses bonus",
+ {"mode": "important_only", "allowed_update_types": None},
+ "bonus",
+ False,
+ False,
+ "mode",
+ None,
+ "mode=important_only suppresses bonus",
+ ),
+ (
+ "mode=mute suppresses everything",
+ {"mode": "mute", "allowed_update_types": None},
+ "main_story",
+ True,
+ False,
+ "mode",
+ None,
+ "mode=mute suppresses every update_type",
+ ),
+ (
+ "allowed_update_types overrides mute",
+ {"mode": "mute", "allowed_update_types": ["bonus"]},
+ "bonus",
+ False,
+ True,
+ "allowed_update_types",
+ ["bonus"],
+ "allowed_update_types override matched bonus",
+ ),
+ (
+ "empty allowed_update_types overrides all",
+ {"mode": "all", "allowed_update_types": []},
+ "main_story",
+ True,
+ False,
+ "allowed_update_types",
+ [],
+ "allowed_update_types override did not include main_story",
+ ),
]
- for policy, update_type, should_notify, applied_via, reason in cases:
- with self.subTest(policy=policy, update_type=update_type):
+ for description, policy, update_type, default_notify, should_notify, applied_via, allowed, reason in cases:
+ with self.subTest(description=description):
decision = evaluate_notification_policy(policy, update_type=update_type)
self.assertEqual(policy["mode"], decision["mode"])
- self.assertEqual(policy["allowed_update_types"], decision["allowed_update_types"])
+ self.assertEqual(allowed, decision["allowed_update_types"])
self.assertEqual(should_notify, decision["should_notify"])
self.assertEqual(applied_via, decision["applied_via"])
self.assertEqual(reason, decision["reason"])
+ latest = {
+ "source": "fake",
+ "workId": "work-1",
+ "latestKey": f"ep-{update_type}",
+ "seriesTitle": "作品A",
+ "episodeTitle": f"{update_type} update",
+ "url": f"https://example.com/{update_type}",
+ "update_type": update_type,
+ "default_notify": default_notify,
+ }
+ next_entry, update = check.apply_item_transition(
+ "work-1",
+ previous,
+ latest,
+ seen_at=20,
+ history_retention=5,
+ notification_policy=policy,
+ )
+
+ self.assertIsNotNone(update)
+ self.assertEqual(1, len(next_entry["history"]))
+ self.assertEqual(["ep-" + update_type], next_entry["unread"]["event_ids"])
+ self.assertEqual(policy["mode"], update["notification"]["mode"])
+ self.assertEqual(allowed, update["notification"]["allowed_update_types"])
+ self.assertEqual(should_notify, update["notification"]["should_notify"])
+ self.assertEqual(applied_via, update["notification"]["applied_via"])
+
def test_run_check_initializes_state_without_updates(self):
with tempfile.TemporaryDirectory() as tmpdir:
watchlist_path = Path(tmpdir) / "watchlist.json"
@@ -1084,120 +1118,6 @@ def test_previous_series_metadata_searches_past_recent_history_without_title(sel
check.previous_series_metadata(previous),
)
- def test_apply_item_transition_evaluates_notification_policy_truth_table(self):
- previous = {
- "latest": {
- "source": "fake",
- "work_id": "work-1",
- "latest_key": "ep-1",
- "series_title": "作品A",
- "episode_title": "第1話",
- "url": "https://example.com/work/1",
- },
- "history": [],
- "unread": {"event_ids": []},
- "health": {
- "last_checked_at": 10,
- "last_success_at": 10,
- "consecutive_failures": 0,
- },
- }
- cases = [
- (
- "mode=all bypasses suppressed defaults",
- {"mode": "all", "allowed_update_types": None},
- "bonus",
- False,
- True,
- "mode",
- None,
- ),
- (
- "mode=important_only allows main_story",
- {"mode": "important_only", "allowed_update_types": None},
- "main_story",
- True,
- True,
- "mode",
- None,
- ),
- (
- "mode=important_only allows unknown",
- {"mode": "important_only", "allowed_update_types": None},
- "unknown",
- True,
- True,
- "mode",
- None,
- ),
- (
- "mode=important_only suppresses bonus",
- {"mode": "important_only", "allowed_update_types": None},
- "bonus",
- False,
- False,
- "mode",
- None,
- ),
- (
- "mode=mute suppresses everything",
- {"mode": "mute", "allowed_update_types": None},
- "main_story",
- True,
- False,
- "mode",
- None,
- ),
- (
- "allowed_update_types overrides mute",
- {"mode": "mute", "allowed_update_types": ["bonus"]},
- "bonus",
- False,
- True,
- "allowed_update_types",
- ["bonus"],
- ),
- (
- "empty allowed_update_types overrides all",
- {"mode": "all", "allowed_update_types": []},
- "main_story",
- True,
- False,
- "allowed_update_types",
- [],
- ),
- ]
-
- for description, policy, update_type, default_notify, should_notify, applied_via, allowed in cases:
- latest = {
- "source": "fake",
- "workId": "work-1",
- "latestKey": f"ep-{update_type}",
- "seriesTitle": "作品A",
- "episodeTitle": f"{update_type} update",
- "url": f"https://example.com/{update_type}",
- "update_type": update_type,
- "default_notify": default_notify,
- }
-
- with self.subTest(description=description):
- next_entry, update = check.apply_item_transition(
- "work-1",
- previous,
- latest,
- seen_at=20,
- history_retention=5,
- notification_policy=policy,
- )
-
- self.assertIsNotNone(update)
- self.assertEqual(1, len(next_entry["history"]))
- self.assertEqual(["ep-" + update_type], next_entry["unread"]["event_ids"])
- self.assertEqual(policy["mode"], update["notification"]["mode"])
- self.assertEqual(allowed, update["notification"]["allowed_update_types"])
- self.assertEqual(should_notify, update["notification"]["should_notify"])
- self.assertEqual(applied_via, update["notification"]["applied_via"])
-
def test_run_check_keeps_suppressed_updates_in_state_and_machine_readable_output(self):
with tempfile.TemporaryDirectory() as tmpdir:
watchlist_path = Path(tmpdir) / "watchlist.json"
@@ -1900,39 +1820,34 @@ def test_requests_http_client_limits_same_host_concurrency(self):
self.assertEqual(1, tracker["max"])
- def test_decode_response_bytes_utf8_without_charset_param(self):
- title = "科学的に存在しうるクリーチャー娘の観察日誌"
- decoded = decode_response_bytes(
- title.encode("utf-8"),
- content_type="text/xml",
- apparent_encoding=None,
- )
- self.assertEqual(title, decoded)
-
- def test_decode_response_bytes_utf8_with_explicit_charset(self):
+ def test_decode_response_bytes_decodes_utf8(self):
title = "科学的に存在しうるクリーチャー娘の観察日誌"
- decoded = decode_response_bytes(
- title.encode("utf-8"),
- content_type="text/html; charset=utf-8",
- apparent_encoding=None,
- )
- self.assertEqual(title, decoded)
+ for case, content_type in (
+ ("without_charset_param", "text/xml"),
+ ("with_explicit_charset", "text/html; charset=utf-8"),
+ ):
+ with self.subTest(case=case):
+ decoded = decode_response_bytes(
+ title.encode("utf-8"),
+ content_type=content_type,
+ apparent_encoding=None,
+ )
+ self.assertEqual(title, decoded)
- def test_decode_response_bytes_latin1_charset(self):
- decoded = decode_response_bytes(
- "caf\xe9".encode("latin-1"),
- content_type="text/plain; charset=iso-8859-1",
- apparent_encoding=None,
- )
- self.assertEqual("café", decoded)
+ def test_decode_response_bytes_decodes_declared_charset_and_bare_ascii(self):
+ cases = [
+ ("latin1", "café".encode("latin-1"), "text/plain; charset=iso-8859-1", "café"),
+ ("ascii_without_content_type", b"hello world", None, "hello world"),
+ ]
- def test_decode_response_bytes_ascii_without_content_type(self):
- decoded = decode_response_bytes(
- b"hello world",
- content_type=None,
- apparent_encoding=None,
- )
- self.assertEqual("hello world", decoded)
+ for case, raw, content_type, expected in cases:
+ with self.subTest(case=case):
+ decoded = decode_response_bytes(
+ raw,
+ content_type=content_type,
+ apparent_encoding=None,
+ )
+ self.assertEqual(expected, decoded)
def test_decode_response_bytes_unknown_charset_falls_back_to_utf8(self):
title = "科学的に存在しうるクリーチャー娘の観察日誌"
@@ -1960,6 +1875,14 @@ def test_decode_response_bytes_declared_charset_with_malformed_byte_keeps_charse
self.assertIn("あいう", decoded)
self.assertIn("\ufffd", decoded)
+ def test_decode_response_bytes_declared_charset_strips_leading_bom(self):
+ decoded = decode_response_bytes(
+ "\ufeffあいう".encode("utf-8"),
+ content_type="text/html; charset=utf-8",
+ apparent_encoding=None,
+ )
+ self.assertEqual("あいう", decoded)
+
def test_get_text_decodes_utf8_when_content_type_has_no_charset(self):
title = "科学的に存在しうるクリーチャー娘の観察日誌"
session = Utf8XmlSession(Utf8XmlResponse(title.encode("utf-8")))
diff --git a/tests/test_discord_add.py b/tests/test_discord_add.py
index 97130c4..3e756ed 100644
--- a/tests/test_discord_add.py
+++ b/tests/test_discord_add.py
@@ -70,150 +70,162 @@ def add_subscription(_url, *, watchlist_path=None):
self.assertIn("追加できませんでした", payload["content"])
self.assertIn("Unsupported source host: example.com", payload["content"])
- def test_start_accepts_nicovideo_sp_comic_url_via_watchlist_logic(self):
- with tempfile.TemporaryDirectory() as tmpdir:
- watchlist_path = Path(tmpdir) / "watchlist.json"
- watchlist_path.write_text(
- json.dumps({"version": 2, "works": []}, ensure_ascii=False),
- encoding="utf-8",
- )
+ def test_start_source_and_issue_report_branch_matrix(self):
+ def run_accepts_nicovideo_sp_comic_url_via_watchlist_logic(test):
+ with tempfile.TemporaryDirectory() as tmpdir:
+ watchlist_path = Path(tmpdir) / "watchlist.json"
+ watchlist_path.write_text(
+ json.dumps({"version": 2, "works": []}, ensure_ascii=False),
+ encoding="utf-8",
+ )
- handler = AddCommandHandler(
- add_subscription=lambda url, *, watchlist_path=None: add_watchlist_url(
- url,
- watchlist_path=watchlist_path,
+ handler = AddCommandHandler(
+ add_subscription=lambda url, *, watchlist_path=None: add_watchlist_url(
+ url,
+ watchlist_path=watchlist_path,
+ )
)
- )
- payload = handler.start(
- url="https://sp.manga.nicovideo.jp/comic/53764?track=share",
- watchlist_path=str(watchlist_path),
- )
+ payload = handler.start(
+ url="https://sp.manga.nicovideo.jp/comic/53764?track=share",
+ watchlist_path=str(watchlist_path),
+ )
- self.assertIn("追加しました", payload["content"])
- self.assertIn("nicovideo-manga:53764", payload["content"])
-
- def test_start_accepts_firecross_reader_url_via_watchlist_logic(self):
- with tempfile.TemporaryDirectory() as tmpdir:
- watchlist_path = Path(tmpdir) / "watchlist.json"
- watchlist_path.write_text(
- json.dumps({"version": 2, "works": []}, ensure_ascii=False),
- encoding="utf-8",
- )
+ test.assertIn("追加しました", payload["content"])
+ test.assertIn("nicovideo-manga:53764", payload["content"])
- handler = AddCommandHandler(
- add_subscription=lambda url, *, watchlist_path=None: add_watchlist_url(
- url,
- watchlist_path=watchlist_path,
- http_client=type(
- "StaticHttpClient",
- (),
- {
- "get_text": lambda self, request_url: {
- "https://firecross.jp/reader/19386": """
-
-
- 作品詳細
-
-
- """
- }[request_url]
- },
- )(),
+ def run_accepts_firecross_reader_url_via_watchlist_logic(test):
+ with tempfile.TemporaryDirectory() as tmpdir:
+ watchlist_path = Path(tmpdir) / "watchlist.json"
+ watchlist_path.write_text(
+ json.dumps({"version": 2, "works": []}, ensure_ascii=False),
+ encoding="utf-8",
)
- )
-
- payload = handler.start(
- url="https://firecross.jp/reader/19386?trial=0&token=temp&vertical=0",
- watchlist_path=str(watchlist_path),
- )
- self.assertIn("追加しました", payload["content"])
- self.assertIn("firecross:series-abc", payload["content"])
+ handler = AddCommandHandler(
+ add_subscription=lambda url, *, watchlist_path=None: add_watchlist_url(
+ url,
+ watchlist_path=watchlist_path,
+ http_client=type(
+ "StaticHttpClient",
+ (),
+ {
+ "get_text": lambda self, request_url: {
+ "https://firecross.jp/reader/19386": """
+
+
+ 作品詳細
+
+
+ """
+ }[request_url]
+ },
+ )(),
+ )
+ )
- def test_start_reports_unsupported_source_to_github_issue(self):
- calls = []
+ payload = handler.start(
+ url="https://firecross.jp/reader/19386?trial=0&token=temp&vertical=0",
+ watchlist_path=str(watchlist_path),
+ )
- class FakeIssueReporter:
- def report_unsupported_source(self, *, url, error):
- calls.append({"url": url, "error": error})
- return {
- "action": "created",
- "issue_number": 174,
- "issue_url": "https://github.com/kentoku24/comic_crawler/issues/174",
- }
+ test.assertIn("追加しました", payload["content"])
+ test.assertIn("firecross:series-abc", payload["content"])
+
+ def run_reports_unsupported_source_to_github_issue(test):
+ calls = []
+
+ class FakeIssueReporter:
+ def report_unsupported_source(self, *, url, error):
+ calls.append({"url": url, "error": error})
+ return {
+ "action": "created",
+ "issue_number": 174,
+ "issue_url": "https://github.com/kentoku24/comic_crawler/issues/174",
+ }
+
+ def add_subscription(_url, *, watchlist_path=None):
+ raise WatchlistAddError(
+ "unsupported_source",
+ "Unsupported source host: example.com",
+ "Use one of the supported sources.",
+ )
- def add_subscription(_url, *, watchlist_path=None):
- raise WatchlistAddError(
- "unsupported_source",
- "Unsupported source host: example.com",
- "Use one of the supported sources.",
+ handler = AddCommandHandler(
+ add_subscription=add_subscription,
+ unsupported_source_reporter=FakeIssueReporter(),
)
- handler = AddCommandHandler(
- add_subscription=add_subscription,
- unsupported_source_reporter=FakeIssueReporter(),
- )
+ payload = handler.start(url="https://example.com/work/1")
- payload = handler.start(url="https://example.com/work/1")
+ test.assertEqual("https://example.com/work/1", calls[0]["url"])
+ test.assertEqual("unsupported_source", calls[0]["error"].kind)
+ test.assertIn("追加できませんでした", payload["content"])
+ test.assertIn(UNSUPPORTED_SOURCE_REPORTED_MESSAGE, payload["content"])
+ test.assertIn("#174", payload["content"])
- self.assertEqual("https://example.com/work/1", calls[0]["url"])
- self.assertEqual("unsupported_source", calls[0]["error"].kind)
- self.assertIn("追加できませんでした", payload["content"])
- self.assertIn(UNSUPPORTED_SOURCE_REPORTED_MESSAGE, payload["content"])
- self.assertIn("#174", payload["content"])
+ def run_reports_issue_creation_failure_for_unsupported_source(test):
+ class FailingIssueReporter:
+ def report_unsupported_source(self, *, url, error):
+ raise RuntimeError("GitHub issue creation failed")
- def test_start_reports_issue_creation_failure_for_unsupported_source(self):
- class FailingIssueReporter:
- def report_unsupported_source(self, *, url, error):
- raise RuntimeError("GitHub issue creation failed")
+ def add_subscription(_url, *, watchlist_path=None):
+ raise WatchlistAddError(
+ "unsupported_source",
+ "Unsupported source host: example.com",
+ "Use one of the supported sources.",
+ )
- def add_subscription(_url, *, watchlist_path=None):
- raise WatchlistAddError(
- "unsupported_source",
- "Unsupported source host: example.com",
- "Use one of the supported sources.",
+ handler = AddCommandHandler(
+ add_subscription=add_subscription,
+ unsupported_source_reporter=FailingIssueReporter(),
)
- handler = AddCommandHandler(
- add_subscription=add_subscription,
- unsupported_source_reporter=FailingIssueReporter(),
- )
+ payload = handler.start(url="https://example.com/work/1")
- payload = handler.start(url="https://example.com/work/1")
+ test.assertIn("追加できませんでした", payload["content"])
+ test.assertIn("Unsupported source host: example.com", payload["content"])
+ test.assertIn(UNSUPPORTED_SOURCE_REPORT_FAILURE_MESSAGE, payload["content"])
- self.assertIn("追加できませんでした", payload["content"])
- self.assertIn("Unsupported source host: example.com", payload["content"])
- self.assertIn(UNSUPPORTED_SOURCE_REPORT_FAILURE_MESSAGE, payload["content"])
+ def run_does_not_report_supported_source_url_type_errors(test):
+ class RecordingIssueReporter:
+ def __init__(self):
+ self.calls = 0
- def test_start_does_not_report_supported_source_url_type_errors(self):
- class RecordingIssueReporter:
- def __init__(self):
- self.calls = 0
+ def report_unsupported_source(self, *, url, error):
+ self.calls += 1
+ return {}
- def report_unsupported_source(self, *, url, error):
- self.calls += 1
- return {}
+ reporter = RecordingIssueReporter()
- reporter = RecordingIssueReporter()
+ def add_subscription(_url, *, watchlist_path=None):
+ raise WatchlistAddError(
+ "unsupported_url_type",
+ "comic-action does not support this URL type",
+ "Supported input types for comic-action: episode URL, series feed URL.",
+ )
- def add_subscription(_url, *, watchlist_path=None):
- raise WatchlistAddError(
- "unsupported_url_type",
- "comic-action does not support this URL type",
- "Supported input types for comic-action: episode URL, series feed URL.",
+ handler = AddCommandHandler(
+ add_subscription=add_subscription,
+ unsupported_source_reporter=reporter,
)
- handler = AddCommandHandler(
- add_subscription=add_subscription,
- unsupported_source_reporter=reporter,
- )
-
- payload = handler.start(url="https://comic-action.com/series/123")
-
- self.assertEqual(0, reporter.calls)
- self.assertIn("追加できませんでした", payload["content"])
- self.assertNotIn(UNSUPPORTED_SOURCE_REPORTED_MESSAGE, payload["content"])
+ payload = handler.start(url="https://comic-action.com/series/123")
+
+ test.assertEqual(0, reporter.calls)
+ test.assertIn("追加できませんでした", payload["content"])
+ test.assertNotIn(UNSUPPORTED_SOURCE_REPORTED_MESSAGE, payload["content"])
+
+ cases = [
+ ("nicovideo_sp_comic_url", run_accepts_nicovideo_sp_comic_url_via_watchlist_logic),
+ ("firecross_reader_url", run_accepts_firecross_reader_url_via_watchlist_logic),
+ ("unsupported_source_reported", run_reports_unsupported_source_to_github_issue),
+ ("issue_creation_failure", run_reports_issue_creation_failure_for_unsupported_source),
+ ("url_type_error_not_reported", run_does_not_report_supported_source_url_type_errors),
+ ]
+ for case, run in cases:
+ with self.subTest(case=case):
+ run(self)
if __name__ == "__main__":
diff --git a/tests/test_discord_command_registration.py b/tests/test_discord_command_registration.py
index 3f9737d..6b796a4 100644
--- a/tests/test_discord_command_registration.py
+++ b/tests/test_discord_command_registration.py
@@ -2,14 +2,6 @@
import unittest
from unittest import mock
-from manga_watch.discord_fetch import FETCH_COMMAND
-from manga_watch.discord_latest import LATEST_COMMAND
-from manga_watch.discord_remove import REMOVE_COMMAND
-from manga_watch.discord_search import SEARCH_COMMAND
-from manga_watch.discord_supertwins_manage import SUPERTWINS_MANAGE_COMMAND
-from manga_watch.discord_supertwins_search import SUPERTWINS_SEARCH_COMMAND
-from manga_watch.discord_where import WHERE_COMMAND
-
class FakeResponse:
def __init__(self, status_code=200, text="", json_data=None):
@@ -46,43 +38,22 @@ def test_default_command_definitions_include_supertwins_commands(self):
from manga_watch.discord_command_registration import default_interaction_commands
commands = default_interaction_commands()
+ names = [command["name"] for command in commands]
+ self.assertEqual(8, len(names))
self.assertEqual(
- [
- LATEST_COMMAND,
- FETCH_COMMAND,
+ {
+ "latest",
+ "fetch",
"add",
- SEARCH_COMMAND,
- WHERE_COMMAND,
- REMOVE_COMMAND,
- SUPERTWINS_SEARCH_COMMAND,
- SUPERTWINS_MANAGE_COMMAND,
- ],
- [command["name"] for command in commands],
- )
- add_command = commands[2]
- self.assertEqual("作品URLを追加してクロール対象に登録します。", add_command["description"])
- self.assertEqual(
- [
- {
- "type": 3,
- "name": "url",
- "description": "追加したい作品URL",
- "required": True,
- }
- ],
- add_command["options"],
- )
- self.assertEqual(
- "既存作品を起点に他媒体候補を探して supertwins を作成します。",
- commands[6]["description"],
- )
- self.assertNotIn("options", commands[6])
- self.assertEqual(
- "既存の supertwins を確認して誤登録を解除します。",
- commands[7]["description"],
+ "search",
+ "where",
+ "remove",
+ "supertwins-search",
+ "supertwins-manage",
+ },
+ set(names),
)
- self.assertNotIn("options", commands[7])
def test_ensure_registered_from_env_uses_guild_registration_when_guild_id_is_present(self):
from manga_watch.discord_command_registration import ensure_commands_registered_from_env
diff --git a/tests/test_discord_command_registration_search.py b/tests/test_discord_command_registration_search.py
index 5d231d7..e0c9b24 100644
--- a/tests/test_discord_command_registration_search.py
+++ b/tests/test_discord_command_registration_search.py
@@ -1,11 +1,5 @@
import unittest
-from manga_watch.discord_fetch import FETCH_COMMAND
-from manga_watch.discord_latest import LATEST_COMMAND
-from manga_watch.discord_remove import REMOVE_COMMAND
-from manga_watch.discord_search import SEARCH_COMMAND
-from manga_watch.discord_where import WHERE_COMMAND
-
class DiscordSearchCommandRegistrationTests(unittest.TestCase):
def test_default_command_definitions_include_search(self):
@@ -13,19 +7,6 @@ def test_default_command_definitions_include_search(self):
commands = default_interaction_commands()
- self.assertEqual(
- [
- LATEST_COMMAND,
- FETCH_COMMAND,
- "add",
- SEARCH_COMMAND,
- WHERE_COMMAND,
- REMOVE_COMMAND,
- "supertwins-search",
- "supertwins-manage",
- ],
- [command["name"] for command in commands],
- )
search_command = commands[3]
self.assertEqual("媒体ごとに作品名で検索します。", search_command["description"])
self.assertEqual(
diff --git a/tests/test_discord_interactions.py b/tests/test_discord_interactions.py
index fd52b04..d0794a7 100644
--- a/tests/test_discord_interactions.py
+++ b/tests/test_discord_interactions.py
@@ -12,7 +12,6 @@
DiscordRequestVerifier,
InProcessFetchDispatcher,
build_interaction_service_from_env,
- build_manual_run_request_body,
)
from manga_watch.discord_add import AddCommandHandler
from manga_watch.discord_remove import REMOVE_COMMAND
@@ -349,18 +348,6 @@ def test_cloud_run_job_fetch_dispatcher_posts_manual_override(self):
)
self.assertIn("/jobs/comic-crawler-job:run", session.posts[0]["url"])
- def test_build_manual_run_request_body_uses_manual_alias(self):
- self.assertEqual(
- {
- "overrides": {
- "containerOverrides": [
- {"env": [{"name": "MANGA_WATCH_TRIGGER_SOURCE", "value": "manual"}]}
- ]
- }
- },
- build_manual_run_request_body(),
- )
-
def test_discord_interaction_callback_client_uses_shorter_timeout_for_defer(self):
from manga_watch.discord_interactions import DiscordInteractionCallbackClient
diff --git a/tests/test_discord_interactions_search.py b/tests/test_discord_interactions_search.py
index 839c58b..30525d3 100644
--- a/tests/test_discord_interactions_search.py
+++ b/tests/test_discord_interactions_search.py
@@ -110,52 +110,6 @@ def test_search_command_routes_to_search_handler(self):
self.assertEqual("champion-cross", search_handler.start_calls[0]["source"])
self.assertEqual("まんが", search_handler.start_calls[0]["query"])
- def test_search_command_routes_to_search_handler_without_source(self):
- search_handler = RecordingSearchHandler()
- callback_client = RecordingInteractionCallbackClient()
- service = DiscordInteractionService(
- timezone_name="Asia/Tokyo",
- fetch_dispatcher=RecordingFetchDispatcher(),
- verification_disabled=True,
- search_handler=search_handler,
- interaction_callback_client=callback_client,
- )
-
- response = service.handle_request(
- method="POST",
- path="/",
- headers={},
- body=json.dumps(self.signed_command_payload(SEARCH_COMMAND)).encode("utf-8"),
- )
-
- self.assertEqual(202, response.status_code)
- self.assertEqual("検索結果を選んでください。", callback_client.edits[0]["data"]["content"])
- self.assertIsNone(search_handler.start_calls[0]["source"])
- self.assertEqual("まんが", search_handler.start_calls[0]["query"])
-
- def test_search_component_routes_to_search_handler(self):
- search_handler = RecordingSearchHandler()
- service = DiscordInteractionService(
- timezone_name="Asia/Tokyo",
- fetch_dispatcher=RecordingFetchDispatcher(),
- verification_disabled=True,
- search_handler=search_handler,
- )
-
- response = service.handle_request(
- method="POST",
- path="/",
- headers={},
- body=json.dumps(
- {"type": 3, "data": {"custom_id": "search_select:visible", "values": ["https://example.com/a"]}}
- ).encode("utf-8"),
- )
-
- payload = json.loads(response.body)
- self.assertEqual(7, payload["type"])
- self.assertEqual("追加しました: 作品A", payload["data"]["content"])
- self.assertEqual("search_select:visible", search_handler.component_calls[0]["data"]["custom_id"])
-
if __name__ == "__main__":
unittest.main()
diff --git a/tests/test_discord_interactions_where.py b/tests/test_discord_interactions_where.py
index d19c965..fb054d5 100644
--- a/tests/test_discord_interactions_where.py
+++ b/tests/test_discord_interactions_where.py
@@ -86,35 +86,6 @@ def edit_original_response(self, *, application_id: str, interaction_token: str,
class DiscordInteractionWhereTests(unittest.TestCase):
- def test_where_command_routes_to_where_handler(self):
- where_handler = RecordingWhereHandler()
- service = DiscordInteractionService(
- timezone_name="Asia/Tokyo",
- fetch_dispatcher=RecordingFetchDispatcher(),
- verification_disabled=True,
- where_handler=where_handler,
- )
- payload = {
- "type": 2,
- "data": {
- "name": WHERE_COMMAND,
- "options": [
- {"name": "query", "type": 3, "value": "ニセモノの錬金術師"},
- {"name": "episode", "type": 3, "value": "第1話"},
- ],
- },
- }
-
- response = service.handle_request(method="POST", path="/", headers={}, body=json.dumps(payload).encode())
-
- body = json.loads(response.body)
- self.assertEqual(4, body["type"])
- self.assertEqual("where 候補を選んでください。", body["data"]["content"])
- self.assertEqual(
- [{"query": "ニセモノの錬金術師", "episode": "第1話"}],
- where_handler.start_calls,
- )
-
def test_where_command_defers_when_callback_client_is_available(self):
events = []
where_handler = RecordingWhereHandler(events=events)
@@ -162,73 +133,5 @@ def test_where_command_defers_when_callback_client_is_available(self):
events,
)
- def test_where_component_routes_to_where_handler(self):
- where_handler = RecordingWhereHandler()
- service = DiscordInteractionService(
- timezone_name="Asia/Tokyo",
- fetch_dispatcher=RecordingFetchDispatcher(),
- verification_disabled=True,
- where_handler=where_handler,
- )
-
- response = service.handle_request(
- method="POST",
- path="/",
- headers={},
- body=json.dumps(
- {"type": 3, "data": {"custom_id": "where_select:token", "values": ["0"]}}
- ).encode(),
- )
-
- body = json.loads(response.body)
- self.assertEqual(7, body["type"])
- self.assertEqual("ComicWalker: 今すぐ無料", body["data"]["content"])
- self.assertEqual("where_select:token", where_handler.component_calls[0]["data"]["custom_id"])
-
- def test_where_component_defers_when_callback_client_is_available(self):
- events = []
- where_handler = RecordingWhereHandler(events=events)
- callback_client = RecordingInteractionCallbackClient(events=events)
- service = DiscordInteractionService(
- timezone_name="Asia/Tokyo",
- fetch_dispatcher=RecordingFetchDispatcher(),
- verification_disabled=True,
- where_handler=where_handler,
- interaction_callback_client=callback_client,
- )
-
- response = service.handle_request(
- method="POST",
- path="/",
- headers={},
- body=json.dumps(
- {
- "id": "interaction-1",
- "application_id": "app-1",
- "token": "token-1",
- "type": 3,
- "data": {"custom_id": "where_select:token", "values": ["0"]},
- }
- ).encode(),
- )
-
- self.assertEqual(202, response.status_code)
- self.assertEqual(b"", response.body)
- self.assertEqual(
- [{"interaction_id": "interaction-1", "interaction_token": "token-1"}],
- callback_client.deferred_components,
- )
- self.assertEqual("ComicWalker: 今すぐ無料", callback_client.edits[0]["data"]["content"])
- self.assertEqual("where_select:token", where_handler.component_calls[0]["data"]["custom_id"])
- self.assertEqual(
- [
- "callback.defer_component",
- "where.handle_component",
- "callback.edit_original_response",
- ],
- events,
- )
-
-
if __name__ == "__main__":
unittest.main()
diff --git a/tests/test_discord_latest.py b/tests/test_discord_latest.py
index a3fe81f..434bf69 100644
--- a/tests/test_discord_latest.py
+++ b/tests/test_discord_latest.py
@@ -86,304 +86,319 @@ def test_handle_latest_query_routes_only_trimmed_exact_latest(self):
)
)
- def test_build_latest_query_response_uses_watchlist_order_and_ignores_disabled_and_orphans(self):
- watchlist = self.make_watchlist(
- [
- {
- "id": "work-b",
- "source": "comic-walker",
- "seed_url": "https://example.com/work-b",
- "enabled": True,
- "notification_policy": {"mode": "all", "allowed_update_types": None},
- },
+ def test_build_latest_query_response_matrix(self):
+ def run_watchlist_order_ignores_disabled_and_orphans(test):
+ watchlist = test.make_watchlist(
+ [
+ {
+ "id": "work-b",
+ "source": "comic-walker",
+ "seed_url": "https://example.com/work-b",
+ "enabled": True,
+ "notification_policy": {"mode": "all", "allowed_update_types": None},
+ },
+ {
+ "id": "work-a",
+ "source": "comic-walker",
+ "seed_url": "https://example.com/work-a",
+ "enabled": True,
+ "notification_policy": {"mode": "all", "allowed_update_types": None},
+ },
+ {
+ "id": "work-disabled",
+ "source": "comic-walker",
+ "seed_url": "https://example.com/work-disabled",
+ "enabled": False,
+ "notification_policy": {"mode": "all", "allowed_update_types": None},
+ },
+ ]
+ )
+ state = test.make_state(
{
- "id": "work-a",
- "source": "comic-walker",
- "seed_url": "https://example.com/work-a",
- "enabled": True,
- "notification_policy": {"mode": "all", "allowed_update_types": None},
+ "work-a": {
+ "latest": {"series_title": "作品A", "episode_title": "第1話", "url": "https://example.com/a"},
+ "history": [],
+ "health": {"consecutive_failures": 0},
+ },
+ "work-b": {
+ "latest": {"series_title": "作品B", "episode_title": "第8話", "url": "https://example.com/b"},
+ "history": [],
+ "health": {"consecutive_failures": 0},
+ },
+ "work-disabled": {
+ "latest": {"series_title": "作品C", "episode_title": "第9話", "url": "https://example.com/c"},
+ "history": [],
+ "health": {"consecutive_failures": 0},
+ },
+ "orphan": {
+ "latest": {"series_title": "孤児", "episode_title": "第99話", "url": "https://example.com/orphan"},
+ "history": [],
+ "health": {"consecutive_failures": 0},
+ },
},
+ last_run_at=1_700_000_000,
+ )
+
+ response = build_latest_query_response(watchlist, state, timezone_name="Asia/Tokyo")
+ lines = response.splitlines()
+
+ test.assertEqual("現在のリスト:", lines[2])
+ test.assertEqual("[第8話]() 作品B", lines[3])
+ test.assertEqual("[第1話]() 作品A", lines[4])
+ test.assertNotIn("作品C", response)
+ test.assertNotIn("孤児", response)
+
+ def run_excludes_hidden_entries(test):
+ watchlist = test.make_watchlist(
+ [
+ {
+ "id": "work-visible",
+ "source": "comic-walker",
+ "seed_url": "https://example.com/work-visible",
+ "enabled": True,
+ "notification_policy": {"mode": "all", "allowed_update_types": None},
+ },
+ {
+ "id": "work-hidden",
+ "source": "comic-walker",
+ "seed_url": "https://example.com/work-hidden",
+ "enabled": True,
+ "hidden": True,
+ "notification_policy": {"mode": "all", "allowed_update_types": None},
+ },
+ ]
+ )
+ state = test.make_state(
{
- "id": "work-disabled",
- "source": "comic-walker",
- "seed_url": "https://example.com/work-disabled",
- "enabled": False,
- "notification_policy": {"mode": "all", "allowed_update_types": None},
- },
- ]
- )
- state = self.make_state(
- {
- "work-a": {
- "latest": {"series_title": "作品A", "episode_title": "第1話", "url": "https://example.com/a"},
- "history": [],
- "health": {"consecutive_failures": 0},
- },
- "work-b": {
- "latest": {"series_title": "作品B", "episode_title": "第8話", "url": "https://example.com/b"},
- "history": [],
- "health": {"consecutive_failures": 0},
- },
- "work-disabled": {
- "latest": {"series_title": "作品C", "episode_title": "第9話", "url": "https://example.com/c"},
- "history": [],
- "health": {"consecutive_failures": 0},
- },
- "orphan": {
- "latest": {"series_title": "孤児", "episode_title": "第99話", "url": "https://example.com/orphan"},
- "history": [],
- "health": {"consecutive_failures": 0},
+ "work-visible": {
+ "latest": {"series_title": "表示作品", "episode_title": "第3話", "url": "https://example.com/visible"},
+ "history": [],
+ "health": {"consecutive_failures": 0},
+ },
+ "work-hidden": {
+ "latest": {"series_title": "非表示作品", "episode_title": "第9話", "url": "https://example.com/hidden"},
+ "history": [],
+ "health": {"consecutive_failures": 0},
+ },
},
- },
- last_run_at=1_700_000_000,
- )
+ last_run_at=1_700_000_000,
+ )
- response = build_latest_query_response(watchlist, state, timezone_name="Asia/Tokyo")
- lines = response.splitlines()
+ response = build_latest_query_response(watchlist, state, timezone_name="Asia/Tokyo")
- self.assertEqual("現在のリスト:", lines[2])
- self.assertEqual("[第8話]() 作品B", lines[3])
- self.assertEqual("[第1話]() 作品A", lines[4])
- self.assertNotIn("作品C", response)
- self.assertNotIn("孤児", response)
+ test.assertIn("表示作品", response)
+ test.assertNotIn("非表示作品", response)
- def test_build_latest_query_response_excludes_hidden_entries(self):
- watchlist = self.make_watchlist(
- [
- {
- "id": "work-visible",
- "source": "comic-walker",
- "seed_url": "https://example.com/work-visible",
- "enabled": True,
- "notification_policy": {"mode": "all", "allowed_update_types": None},
- },
+ def run_empty_message_when_all_works_are_unfetched(test):
+ watchlist = test.make_watchlist(
+ [
+ {
+ "id": "work-1",
+ "source": "comic-walker",
+ "seed_url": "https://example.com/work-1",
+ "enabled": True,
+ "notification_policy": {"mode": "all", "allowed_update_types": None},
+ },
+ {
+ "id": "work-2",
+ "source": "comic-walker",
+ "seed_url": "https://example.com/work-2",
+ "enabled": True,
+ "notification_policy": {"mode": "all", "allowed_update_types": None},
+ },
+ ]
+ )
+ state = test.make_state(
{
- "id": "work-hidden",
- "source": "comic-walker",
- "seed_url": "https://example.com/work-hidden",
- "enabled": True,
- "hidden": True,
- "notification_policy": {"mode": "all", "allowed_update_types": None},
- },
- ]
- )
- state = self.make_state(
- {
- "work-visible": {
- "latest": {"series_title": "表示作品", "episode_title": "第3話", "url": "https://example.com/visible"},
- "history": [],
- "health": {"consecutive_failures": 0},
- },
- "work-hidden": {
- "latest": {"series_title": "非表示作品", "episode_title": "第9話", "url": "https://example.com/hidden"},
- "history": [],
- "health": {"consecutive_failures": 0},
- },
- },
- last_run_at=1_700_000_000,
- )
-
- response = build_latest_query_response(watchlist, state, timezone_name="Asia/Tokyo")
-
- self.assertIn("表示作品", response)
- self.assertNotIn("非表示作品", response)
+ "work-1": {"latest": {}, "history": [], "health": {"consecutive_failures": 0}},
+ "work-2": {"latest": {}, "history": [], "health": {"consecutive_failures": 0}},
+ }
+ )
- def test_build_latest_query_response_returns_empty_message_when_all_works_are_unfetched(self):
- watchlist = self.make_watchlist(
- [
+ response = build_latest_query_response(watchlist, state, timezone_name="Asia/Tokyo")
+
+ test.assertIn("最終巡回: まだ実行されていません", response)
+ test.assertTrue(response.endswith(EMPTY_LINE))
+ test.assertNotIn("(未取得)", response)
+
+ def run_unfetched_rows_when_mixed_with_saved_results(test):
+ watchlist = test.make_watchlist(
+ [
+ {
+ "id": "work-1",
+ "source": "comic-walker",
+ "seed_url": "https://example.com/work-1",
+ "enabled": True,
+ "notification_policy": {"mode": "all", "allowed_update_types": None},
+ },
+ {
+ "id": "work-2",
+ "source": "comic-walker",
+ "seed_url": "https://example.com/work-2",
+ "enabled": True,
+ "notification_policy": {"mode": "all", "allowed_update_types": None},
+ },
+ ]
+ )
+ state = test.make_state(
{
- "id": "work-1",
- "source": "comic-walker",
- "seed_url": "https://example.com/work-1",
- "enabled": True,
- "notification_policy": {"mode": "all", "allowed_update_types": None},
- },
+ "work-1": {
+ "latest": {
+ "series_title": "作品A",
+ "episode_title": "第71話 abcdefghijk",
+ "url": "https://example.com/71",
+ },
+ "history": [],
+ "health": {"consecutive_failures": 1},
+ },
+ "work-2": {"latest": {}, "history": [], "health": {"consecutive_failures": 0}},
+ }
+ )
+
+ response = build_latest_query_response(watchlist, state, timezone_name="Asia/Tokyo")
+
+ test.assertIn("[第71話 abcdefg…]() 作品A", response)
+ test.assertIn("(未取得) work-2", response)
+ test.assertTrue(response.endswith(PARTIAL_FAILURE_WARNING))
+
+ def run_saved_next_update_label(test):
+ watchlist = test.make_watchlist(
+ [
+ {
+ "id": "work-1",
+ "source": "comic-walker",
+ "seed_url": "https://example.com/work-1",
+ "enabled": True,
+ "notification_policy": {"mode": "all", "allowed_update_types": None},
+ }
+ ]
+ )
+ state = test.make_state(
{
- "id": "work-2",
- "source": "comic-walker",
- "seed_url": "https://example.com/work-2",
- "enabled": True,
- "notification_policy": {"mode": "all", "allowed_update_types": None},
+ "work-1": {
+ "latest": {
+ "series_title": "作品A",
+ "episode_title": "第2話",
+ "next_update_label": "次回更新予定 3/15",
+ "url": "https://example.com/2",
+ },
+ "history": [],
+ "health": {"consecutive_failures": 0},
+ }
},
- ]
- )
- state = self.make_state(
- {
- "work-1": {"latest": {}, "history": [], "health": {"consecutive_failures": 0}},
- "work-2": {"latest": {}, "history": [], "health": {"consecutive_failures": 0}},
- }
- )
+ last_run_at=1_700_000_000,
+ )
- response = build_latest_query_response(watchlist, state, timezone_name="Asia/Tokyo")
+ response = build_latest_query_response(watchlist, state, timezone_name="Asia/Tokyo")
- self.assertIn("最終巡回: まだ実行されていません", response)
- self.assertTrue(response.endswith(EMPTY_LINE))
- self.assertNotIn("(未取得)", response)
+ test.assertIn(
+ "[第2話(次回更新予定 3/15)]() 作品A",
+ response,
+ )
- def test_build_latest_query_response_includes_unfetched_rows_when_mixed_with_saved_results(self):
- watchlist = self.make_watchlist(
- [
+ def run_plain_text_and_fallback_labels_without_url(test):
+ watchlist = test.make_watchlist(
+ [
+ {
+ "id": "work-1",
+ "source": "comic-walker",
+ "seed_url": "https://example.com/work-1",
+ "enabled": True,
+ "notification_policy": {"mode": "all", "allowed_update_types": None},
+ }
+ ]
+ )
+ state = test.make_state(
{
- "id": "work-1",
- "source": "comic-walker",
- "seed_url": "https://example.com/work-1",
- "enabled": True,
- "notification_policy": {"mode": "all", "allowed_update_types": None},
+ "work-1": {
+ "latest": {
+ "series": "作品A fallback",
+ "episode_code": "ep-2",
+ },
+ "history": [],
+ "health": {"consecutive_failures": 0},
+ }
},
+ last_run_at=1_700_000_000,
+ )
+
+ response = build_latest_query_response(watchlist, state, timezone_name="Asia/Tokyo")
+
+ test.assertIn("ep-2 作品A fallback", response)
+ test.assertNotIn("[ep-2]", response)
+
+ def run_stale_saved_data_warning(test):
+ watchlist = test.make_watchlist(
+ [
+ {
+ "id": "work-1",
+ "source": "comic-walker",
+ "seed_url": "https://example.com/work-1",
+ "enabled": True,
+ "health_policy": {"expected_interval_seconds": 3_600},
+ "notification_policy": {"mode": "all", "allowed_update_types": None},
+ }
+ ]
+ )
+ state = test.make_state(
{
- "id": "work-2",
- "source": "comic-walker",
- "seed_url": "https://example.com/work-2",
- "enabled": True,
- "notification_policy": {"mode": "all", "allowed_update_types": None},
- },
- ]
- )
- state = self.make_state(
- {
- "work-1": {
- "latest": {
- "series_title": "作品A",
- "episode_title": "第71話 abcdefghijk",
- "url": "https://example.com/71",
- },
- "history": [],
- "health": {"consecutive_failures": 1},
+ "work-1": {
+ "latest": {
+ "series_title": "作品A",
+ "episode_title": "第2話",
+ "url": "https://example.com/2",
+ },
+ "history": [],
+ "health": {
+ "last_checked_at": 1_700_010_000,
+ "last_success_at": 1_700_000_000,
+ "consecutive_failures": 0,
+ },
+ }
},
- "work-2": {"latest": {}, "history": [], "health": {"consecutive_failures": 0}},
- }
- )
-
- response = build_latest_query_response(watchlist, state, timezone_name="Asia/Tokyo")
+ last_run_at=1_700_010_000,
+ )
- self.assertIn("[第71話 abcdefg…]() 作品A", response)
- self.assertIn("(未取得) work-2", response)
- self.assertTrue(response.endswith(PARTIAL_FAILURE_WARNING))
+ response = build_latest_query_response(
+ watchlist,
+ state,
+ timezone_name="Asia/Tokyo",
+ now=1_700_008_000,
+ )
- def test_build_latest_query_response_appends_saved_next_update_label(self):
- watchlist = self.make_watchlist(
- [
- {
- "id": "work-1",
- "source": "comic-walker",
- "seed_url": "https://example.com/work-1",
- "enabled": True,
- "notification_policy": {"mode": "all", "allowed_update_types": None},
- }
- ]
- )
- state = self.make_state(
- {
- "work-1": {
- "latest": {
- "series_title": "作品A",
- "episode_title": "第2話",
+ test.assertTrue(response.endswith(PARTIAL_FAILURE_WARNING))
+
+ def run_episode_label_truncation_spec_examples(test):
+ test.assertEqual("第71話 abcdefg…", truncate_episode_label("第71話 abcdefghijk"))
+ test.assertEqual("第71話 あいうえおかき…", truncate_episode_label("第71話 あいうえおかきくけ"))
+ test.assertEqual("第71話 abあいうcd…", truncate_episode_label("第71話 abあいうcdef"))
+ test.assertEqual("abcdefghijklmnopqrs…", truncate_episode_label("abcdefghijklmnopqrstu"))
+ test.assertEqual("第55話後編", truncate_episode_label("第55話後編"))
+ test.assertEqual("[第71話 abcdefg…]()", format_discord_link("第71話 abcdefghijk", "https://example.com/71"))
+ test.assertEqual(
+ "第71話 abcdefg…(次回更新予定 3/15)",
+ latest_display_label_for_snapshot(
+ {
+ "episode_title": "第71話 abcdefghijk",
"next_update_label": "次回更新予定 3/15",
- "url": "https://example.com/2",
- },
- "history": [],
- "health": {"consecutive_failures": 0},
- }
- },
- last_run_at=1_700_000_000,
- )
-
- response = build_latest_query_response(watchlist, state, timezone_name="Asia/Tokyo")
-
- self.assertIn(
- "[第2話(次回更新予定 3/15)]() 作品A",
- response,
- )
-
- def test_build_latest_query_response_uses_plain_text_and_fallback_labels_without_url(self):
- watchlist = self.make_watchlist(
- [
- {
- "id": "work-1",
- "source": "comic-walker",
- "seed_url": "https://example.com/work-1",
- "enabled": True,
- "notification_policy": {"mode": "all", "allowed_update_types": None},
- }
- ]
- )
- state = self.make_state(
- {
- "work-1": {
- "latest": {
- "series": "作品A fallback",
- "episode_code": "ep-2",
},
- "history": [],
- "health": {"consecutive_failures": 0},
- }
- },
- last_run_at=1_700_000_000,
- )
-
- response = build_latest_query_response(watchlist, state, timezone_name="Asia/Tokyo")
-
- self.assertIn("ep-2 作品A fallback", response)
- self.assertNotIn("[ep-2]", response)
-
- def test_build_latest_query_response_warns_for_stale_saved_data(self):
- watchlist = self.make_watchlist(
- [
- {
- "id": "work-1",
- "source": "comic-walker",
- "seed_url": "https://example.com/work-1",
- "enabled": True,
- "health_policy": {"expected_interval_seconds": 3_600},
- "notification_policy": {"mode": "all", "allowed_update_types": None},
- }
- ]
- )
- state = self.make_state(
- {
- "work-1": {
- "latest": {
- "series_title": "作品A",
- "episode_title": "第2話",
- "url": "https://example.com/2",
- },
- "history": [],
- "health": {
- "last_checked_at": 1_700_010_000,
- "last_success_at": 1_700_000_000,
- "consecutive_failures": 0,
- },
- }
- },
- last_run_at=1_700_010_000,
- )
-
- response = build_latest_query_response(
- watchlist,
- state,
- timezone_name="Asia/Tokyo",
- now=1_700_008_000,
- )
+ truncate_episode=True,
+ ),
+ )
- self.assertTrue(response.endswith(PARTIAL_FAILURE_WARNING))
-
- def test_episode_label_truncation_matches_spec_examples(self):
- self.assertEqual("第71話 abcdefg…", truncate_episode_label("第71話 abcdefghijk"))
- self.assertEqual("第71話 あいうえおかき…", truncate_episode_label("第71話 あいうえおかきくけ"))
- self.assertEqual("第71話 abあいうcd…", truncate_episode_label("第71話 abあいうcdef"))
- self.assertEqual("abcdefghijklmnopqrs…", truncate_episode_label("abcdefghijklmnopqrstu"))
- self.assertEqual("第55話後編", truncate_episode_label("第55話後編"))
- self.assertEqual("[第71話 abcdefg…]()", format_discord_link("第71話 abcdefghijk", "https://example.com/71"))
- self.assertEqual(
- "第71話 abcdefg…(次回更新予定 3/15)",
- latest_display_label_for_snapshot(
- {
- "episode_title": "第71話 abcdefghijk",
- "next_update_label": "次回更新予定 3/15",
- },
- truncate_episode=True,
- ),
- )
+ cases = [
+ ("watchlist_order_ignores_disabled_and_orphans", run_watchlist_order_ignores_disabled_and_orphans),
+ ("excludes_hidden_entries", run_excludes_hidden_entries),
+ ("empty_message_when_all_works_unfetched", run_empty_message_when_all_works_are_unfetched),
+ ("unfetched_rows_mixed_with_saved_results", run_unfetched_rows_when_mixed_with_saved_results),
+ ("saved_next_update_label", run_saved_next_update_label),
+ ("plain_text_and_fallback_labels_without_url", run_plain_text_and_fallback_labels_without_url),
+ ("stale_saved_data_warning", run_stale_saved_data_warning),
+ ("episode_label_truncation_spec_examples", run_episode_label_truncation_spec_examples),
+ ]
+ for case, run in cases:
+ with self.subTest(case=case):
+ run(self)
def test_handle_latest_query_is_read_only_and_uses_only_injected_loaders(self):
watchlist = self.make_watchlist(
diff --git a/tests/test_discord_outbound.py b/tests/test_discord_outbound.py
index 722401b..1005c3d 100644
--- a/tests/test_discord_outbound.py
+++ b/tests/test_discord_outbound.py
@@ -96,105 +96,115 @@ def test_build_daily_notification_message_formats_lines_and_truncates_labels(sel
self.assertEqual("[作品A:第71話 abcdefg…]()←第1話", lines[1])
self.assertEqual("[作品B:第3話]()←第2話", lines[2])
- def test_enqueue_daily_notification_skips_updates_without_latest_key(self):
- state = {
- "discord_delivery": {
- "daily_notification": {
- "delivered_latest_keys": {},
- "pending_messages": [],
+ def test_enqueue_daily_notification_dedupe_matrix(self):
+ def run_skips_updates_without_latest_key(test):
+ state = {
+ "discord_delivery": {
+ "daily_notification": {
+ "delivered_latest_keys": {},
+ "pending_messages": [],
+ }
}
}
- }
- update = {
- "id": "work-1",
- "from": {
- "seriesTitle": "作品A",
- "episodeTitle": "第1話",
- "latestKey": "episode-1",
- },
- "to": {
- "series_title": "作品A",
- "episode_title": "第2話 改題",
- "episode_code": "episode-2",
- "url": "https://example.com/latest",
- },
- }
-
- result = enqueue_daily_notification(
- state,
- updates=[update],
- channel_id="main-channel",
- now_ts=1_700_000_000,
- timezone_name="Asia/Tokyo",
- created_at="2023-11-14T22:13:20Z",
- )
-
- self.assertEqual({"queued": False, "candidateUpdateCount": 0}, result)
- self.assertEqual([], state["discord_delivery"]["daily_notification"]["pending_messages"])
+ update = {
+ "id": "work-1",
+ "from": {
+ "seriesTitle": "作品A",
+ "episodeTitle": "第1話",
+ "latestKey": "episode-1",
+ },
+ "to": {
+ "series_title": "作品A",
+ "episode_title": "第2話 改題",
+ "episode_code": "episode-2",
+ "url": "https://example.com/latest",
+ },
+ }
- def test_enqueue_daily_notification_dedupes_by_work_id_and_latest_key_even_if_metadata_changes(self):
- state = {
- "discord_delivery": {
- "daily_notification": {
- "delivered_latest_keys": {
- "work-1": {
- "latest_key": "episode-2",
- "delivered_at": "2023-11-14T22:13:20Z",
- }
- },
- "pending_messages": [],
+ result = enqueue_daily_notification(
+ state,
+ updates=[update],
+ channel_id="main-channel",
+ now_ts=1_700_000_000,
+ timezone_name="Asia/Tokyo",
+ created_at="2023-11-14T22:13:20Z",
+ )
+
+ test.assertEqual({"queued": False, "candidateUpdateCount": 0}, result)
+ test.assertEqual([], state["discord_delivery"]["daily_notification"]["pending_messages"])
+
+ def run_dedupes_by_work_id_and_latest_key_even_if_metadata_changes(test):
+ state = {
+ "discord_delivery": {
+ "daily_notification": {
+ "delivered_latest_keys": {
+ "work-1": {
+ "latest_key": "episode-2",
+ "delivered_at": "2023-11-14T22:13:20Z",
+ }
+ },
+ "pending_messages": [],
+ }
}
}
- }
-
- result = enqueue_daily_notification(
- state,
- updates=[
- self.make_update(
- episode_title="第2話 改題",
- previous="第2話",
- )
- ],
- channel_id="main-channel",
- now_ts=1_700_000_300,
- timezone_name="Asia/Tokyo",
- created_at="2023-11-14T22:18:20Z",
- )
-
- self.assertEqual({"queued": False, "candidateUpdateCount": 0}, result)
- self.assertEqual([], state["discord_delivery"]["daily_notification"]["pending_messages"])
- def test_enqueue_daily_notification_dedupes_against_legacy_camel_case_delivery_state(self):
- state = {
- "discordDelivery": {
- "dailyNotification": {
- "deliveredLatestKeys": {
- "work-1": {
- "latestKey": "episode-2",
- "deliveredAt": "2023-11-14T22:13:20Z",
- }
- },
- "pendingMessages": [],
+ result = enqueue_daily_notification(
+ state,
+ updates=[
+ test.make_update(
+ episode_title="第2話 改題",
+ previous="第2話",
+ )
+ ],
+ channel_id="main-channel",
+ now_ts=1_700_000_300,
+ timezone_name="Asia/Tokyo",
+ created_at="2023-11-14T22:18:20Z",
+ )
+
+ test.assertEqual({"queued": False, "candidateUpdateCount": 0}, result)
+ test.assertEqual([], state["discord_delivery"]["daily_notification"]["pending_messages"])
+
+ def run_dedupes_against_legacy_camel_case_delivery_state(test):
+ state = {
+ "discordDelivery": {
+ "dailyNotification": {
+ "deliveredLatestKeys": {
+ "work-1": {
+ "latestKey": "episode-2",
+ "deliveredAt": "2023-11-14T22:13:20Z",
+ }
+ },
+ "pendingMessages": [],
+ }
}
}
- }
- result = enqueue_daily_notification(
- state,
- updates=[
- self.make_update(
- episode_title="第2話 改題",
- previous="第2話",
- )
- ],
- channel_id="main-channel",
- now_ts=1_700_000_300,
- timezone_name="Asia/Tokyo",
- created_at="2023-11-14T22:18:20Z",
- )
-
- self.assertEqual({"queued": False, "candidateUpdateCount": 0}, result)
- self.assertEqual([], state["discord_delivery"]["daily_notification"]["pending_messages"])
+ result = enqueue_daily_notification(
+ state,
+ updates=[
+ test.make_update(
+ episode_title="第2話 改題",
+ previous="第2話",
+ )
+ ],
+ channel_id="main-channel",
+ now_ts=1_700_000_300,
+ timezone_name="Asia/Tokyo",
+ created_at="2023-11-14T22:18:20Z",
+ )
+
+ test.assertEqual({"queued": False, "candidateUpdateCount": 0}, result)
+ test.assertEqual([], state["discord_delivery"]["daily_notification"]["pending_messages"])
+
+ cases = [
+ ("skips_updates_without_latest_key", run_skips_updates_without_latest_key),
+ ("dedupes_by_work_id_and_latest_key", run_dedupes_by_work_id_and_latest_key_even_if_metadata_changes),
+ ("dedupes_against_legacy_camel_case_state", run_dedupes_against_legacy_camel_case_delivery_state),
+ ]
+ for case, run in cases:
+ with self.subTest(case=case):
+ run(self)
def test_filter_updates_for_daily_notifications_excludes_hidden_watchlist_entries(self):
updates = [
@@ -287,43 +297,52 @@ def test_discord_channel_client_reads_current_user_and_channel_messages(self):
self.assertEqual({"limit": 100, "after": "10"}, session.calls[1]["params"])
self.assertEqual("latest", messages[0]["content"])
- def test_discord_channel_client_masks_bot_token_in_transport_error(self):
- token = "discord-bot-token"
- session = FakeSession(error=requests.Timeout(f"Authorization: Bot {token}"))
- client = DiscordChannelClient(
- DiscordOutboundConfig(
- bot_token=token,
- main_channel_id="main-channel",
- run_report_channel_id="run-report-channel",
- ),
- session=session,
- )
-
- with self.assertRaises(RuntimeError) as exc_info:
- client.send_message("run-report-channel", "hello")
-
- self.assertNotIn(token, str(exc_info.exception))
- self.assertIn("[REDACTED_BOT_TOKEN]", str(exc_info.exception))
+ def test_discord_channel_client_masks_bot_token_matrix(self):
+ def run_masks_bot_token_in_transport_error(test):
+ token = "discord-bot-token"
+ session = FakeSession(error=requests.Timeout(f"Authorization: Bot {token}"))
+ client = DiscordChannelClient(
+ DiscordOutboundConfig(
+ bot_token=token,
+ main_channel_id="main-channel",
+ run_report_channel_id="run-report-channel",
+ ),
+ session=session,
+ )
+
+ with test.assertRaises(RuntimeError) as exc_info:
+ client.send_message("run-report-channel", "hello")
+
+ test.assertNotIn(token, str(exc_info.exception))
+ test.assertIn("[REDACTED_BOT_TOKEN]", str(exc_info.exception))
+
+ def run_masks_bot_token_in_error_response(test):
+ token = "discord-bot-token"
+ session = FakeSession(
+ responses=[FakeResponse(401, text=f"Authorization header was Bot {token}")]
+ )
+ client = DiscordChannelClient(
+ DiscordOutboundConfig(
+ bot_token=token,
+ main_channel_id="main-channel",
+ run_report_channel_id="run-report-channel",
+ ),
+ session=session,
+ )
- def test_discord_channel_client_masks_bot_token_in_error_response(self):
- token = "discord-bot-token"
- session = FakeSession(
- responses=[FakeResponse(401, text=f"Authorization header was Bot {token}")]
- )
- client = DiscordChannelClient(
- DiscordOutboundConfig(
- bot_token=token,
- main_channel_id="main-channel",
- run_report_channel_id="run-report-channel",
- ),
- session=session,
- )
+ with test.assertRaises(RuntimeError) as exc_info:
+ client.send_message("run-report-channel", "hello")
- with self.assertRaises(RuntimeError) as exc_info:
- client.send_message("run-report-channel", "hello")
+ test.assertNotIn(token, str(exc_info.exception))
+ test.assertIn("[REDACTED_BOT_TOKEN]", str(exc_info.exception))
- self.assertNotIn(token, str(exc_info.exception))
- self.assertIn("[REDACTED_BOT_TOKEN]", str(exc_info.exception))
+ cases = [
+ ("transport_error", run_masks_bot_token_in_transport_error),
+ ("error_response", run_masks_bot_token_in_error_response),
+ ]
+ for case, run in cases:
+ with self.subTest(case=case):
+ run(self)
def test_state_round_trip_preserves_discord_delivery_state(self):
state = {
diff --git a/tests/test_discord_remove.py b/tests/test_discord_remove.py
index cd5df48..096e601 100644
--- a/tests/test_discord_remove.py
+++ b/tests/test_discord_remove.py
@@ -11,7 +11,7 @@
build_remove_token,
remove_watch_subscription,
)
-from tests.test_firestore_storage import FakeFirestoreClient, make_state, make_watchlist
+from tests._firestore_fakes import FakeFirestoreClient, make_state, make_watchlist
def write_json(path: Path, payload):
diff --git a/tests/test_discord_search.py b/tests/test_discord_search.py
index b2cad13..ea55d8c 100644
--- a/tests/test_discord_search.py
+++ b/tests/test_discord_search.py
@@ -1,7 +1,6 @@
import unittest
from manga_watch.discord_search import (
- SEARCH_COMMAND,
SEARCH_MISSING_SOURCE_MESSAGE,
SEARCH_NO_RESULTS_MESSAGE,
SearchCommandHandler,
@@ -87,9 +86,6 @@ def __call__(self, url, *, watchlist_path=None, hidden=False):
class DiscordSearchTests(unittest.TestCase):
- def test_search_command_name_is_exported(self):
- self.assertEqual("search", SEARCH_COMMAND)
-
def test_start_returns_ephemeral_select_menu_for_results(self):
search_source = FakeSearchSource(
[
@@ -196,210 +192,251 @@ def test_start_accepts_missing_source_without_old_error(self):
self.assertNotEqual(SEARCH_MISSING_SOURCE_MESSAGE, response["content"])
self.assertEqual([], search_source.calls)
- def test_start_aggregates_cross_source_results_with_interleaving_and_dedupe(self):
- search_source = MultiSourceSearchSource(
- {
- "comic-walker": [
- SearchResult(
- source="comic-walker",
- title="作品A walker 1",
- seed_url="https://comic-walker.com/detail/a",
- ),
- SearchResult(
- source="comic-walker",
- title="作品A walker duplicate",
- seed_url="https://comic-walker.com/detail/a",
- ),
- SearchResult(
- source="comic-walker",
- title="作品A walker 2",
- seed_url="https://comic-walker.com/detail/b",
- ),
- ],
- "champion-cross": [
- SearchResult(
- source="champion-cross",
- title="作品B cross 1",
- seed_url="https://championcross.jp/series/a",
- ),
- SearchResult(
- source="champion-cross",
- title="作品B cross 2",
- seed_url="https://championcross.jp/series/b",
- ),
- ],
- "takecomic": [
- SearchResult(
- source="takecomic",
- title="作品C take 1",
- seed_url="https://takecomic.jp/comics/1",
- )
- ],
- }
- )
- handler = SearchCommandHandler(
- search_source=search_source,
- supported_sources=lambda: ("comic-walker", "champion-cross", "takecomic"),
- )
-
- response = handler.start(source=None, query="まんが", visibility="visible")
-
- self.assertEqual(
- [
- {
- "source": "comic-walker",
- "query": "まんが",
- "http_client": None,
- "limit": 3,
- },
- {
- "source": "champion-cross",
- "query": "まんが",
- "http_client": None,
- "limit": 3,
- },
+ def test_start_cross_source_matrix(self):
+ def run_interleaving_and_dedupe(test):
+ search_source = MultiSourceSearchSource(
{
- "source": "takecomic",
- "query": "まんが",
- "http_client": None,
- "limit": 3,
- },
- ],
- search_source.calls,
- )
- self.assertEqual("横断検索結果です。1件選んでください。", response["content"])
- self.assertEqual(
- [
- {
- "label": "作品A walker 1",
- "value": "https://comic-walker.com/detail/a",
- "description": "comic-walker",
- },
- {
- "label": "作品B cross 1",
- "value": "https://championcross.jp/series/a",
- "description": "champion-cross",
- },
- {
- "label": "作品C take 1",
- "value": "https://takecomic.jp/comics/1",
- "description": "takecomic",
- },
- {
- "label": "作品A walker 2",
- "value": "https://comic-walker.com/detail/b",
- "description": "comic-walker",
- },
- {
- "label": "作品B cross 2",
- "value": "https://championcross.jp/series/b",
- "description": "champion-cross",
- },
- ],
- response["components"][0]["components"][0]["options"],
- )
-
- def test_start_caps_cross_source_results_to_select_limit(self):
- sources = tuple(f"source-{index}" for index in range(9))
- search_source = MultiSourceSearchSource(
- {
- source_name: [
- SearchResult(
- source=source_name,
- title=f"{source_name}-{index}",
- seed_url=f"https://example.com/{source_name}/{index}",
- )
- for index in range(10)
- ]
- for source_name in sources
- }
- )
- handler = SearchCommandHandler(
- search_source=search_source,
- supported_sources=lambda: sources,
- )
-
- response = handler.start(source=None, query="まんが")
-
- options = response["components"][0]["components"][0]["options"]
- self.assertEqual(25, len(options))
- self.assertEqual("source-0-0", options[0]["label"])
- self.assertEqual("source-1-0", options[1]["label"])
- self.assertEqual("source-2-0", options[2]["label"])
-
- def test_start_returns_partial_cross_source_results_when_some_sources_fail(self):
- search_source = MultiSourceSearchSourceWithFailures(
- results_by_source={
- "comic-walker": [
- SearchResult(
- source="comic-walker",
- title="walker only",
- seed_url="https://comic-walker.com/detail/1",
- )
+ "comic-walker": [
+ SearchResult(
+ source="comic-walker",
+ title="作品A walker 1",
+ seed_url="https://comic-walker.com/detail/a",
+ ),
+ SearchResult(
+ source="comic-walker",
+ title="作品A walker duplicate",
+ seed_url="https://comic-walker.com/detail/a",
+ ),
+ SearchResult(
+ source="comic-walker",
+ title="作品A walker 2",
+ seed_url="https://comic-walker.com/detail/b",
+ ),
+ ],
+ "champion-cross": [
+ SearchResult(
+ source="champion-cross",
+ title="作品B cross 1",
+ seed_url="https://championcross.jp/series/a",
+ ),
+ SearchResult(
+ source="champion-cross",
+ title="作品B cross 2",
+ seed_url="https://championcross.jp/series/b",
+ ),
+ ],
+ "takecomic": [
+ SearchResult(
+ source="takecomic",
+ title="作品C take 1",
+ seed_url="https://takecomic.jp/comics/1",
+ )
+ ],
+ }
+ )
+ handler = SearchCommandHandler(
+ search_source=search_source,
+ supported_sources=lambda: ("comic-walker", "champion-cross", "takecomic"),
+ )
+
+ response = handler.start(source=None, query="まんが", visibility="visible")
+
+ test.assertEqual(
+ [
+ {
+ "source": "comic-walker",
+ "query": "まんが",
+ "http_client": None,
+ "limit": 3,
+ },
+ {
+ "source": "champion-cross",
+ "query": "まんが",
+ "http_client": None,
+ "limit": 3,
+ },
+ {
+ "source": "takecomic",
+ "query": "まんが",
+ "http_client": None,
+ "limit": 3,
+ },
],
- "takecomic": [
- SearchResult(
- source="takecomic",
- title="take only",
- seed_url="https://takecomic.jp/comics/1",
- )
+ search_source.calls,
+ )
+ test.assertEqual("横断検索結果です。1件選んでください。", response["content"])
+ test.assertEqual(
+ [
+ {
+ "label": "作品A walker 1",
+ "value": "https://comic-walker.com/detail/a",
+ "description": "comic-walker",
+ },
+ {
+ "label": "作品B cross 1",
+ "value": "https://championcross.jp/series/a",
+ "description": "champion-cross",
+ },
+ {
+ "label": "作品C take 1",
+ "value": "https://takecomic.jp/comics/1",
+ "description": "takecomic",
+ },
+ {
+ "label": "作品A walker 2",
+ "value": "https://comic-walker.com/detail/b",
+ "description": "comic-walker",
+ },
+ {
+ "label": "作品B cross 2",
+ "value": "https://championcross.jp/series/b",
+ "description": "champion-cross",
+ },
],
- },
- failing_sources={"champion-cross"},
- )
- handler = SearchCommandHandler(
- search_source=search_source,
- supported_sources=lambda: ("comic-walker", "champion-cross", "takecomic"),
- )
+ response["components"][0]["components"][0]["options"],
+ )
- response = handler.start(source=None, query="まんが")
-
- self.assertEqual("横断検索結果です。1件選んでください。", response["content"])
- self.assertEqual(
- [
+ def run_select_limit_cap(test):
+ sources = tuple(f"source-{index}" for index in range(9))
+ search_source = MultiSourceSearchSource(
{
- "label": "walker only",
- "value": "https://comic-walker.com/detail/1",
- "description": "comic-walker",
+ source_name: [
+ SearchResult(
+ source=source_name,
+ title=f"{source_name}-{index}",
+ seed_url=f"https://example.com/{source_name}/{index}",
+ )
+ for index in range(10)
+ ]
+ for source_name in sources
+ }
+ )
+ handler = SearchCommandHandler(
+ search_source=search_source,
+ supported_sources=lambda: sources,
+ )
+
+ response = handler.start(source=None, query="まんが")
+
+ options = response["components"][0]["components"][0]["options"]
+ test.assertEqual(25, len(options))
+ test.assertEqual("source-0-0", options[0]["label"])
+ test.assertEqual("source-1-0", options[1]["label"])
+ test.assertEqual("source-2-0", options[2]["label"])
+
+ def run_partial_results_when_some_sources_fail(test):
+ search_source = MultiSourceSearchSourceWithFailures(
+ results_by_source={
+ "comic-walker": [
+ SearchResult(
+ source="comic-walker",
+ title="walker only",
+ seed_url="https://comic-walker.com/detail/1",
+ )
+ ],
+ "takecomic": [
+ SearchResult(
+ source="takecomic",
+ title="take only",
+ seed_url="https://takecomic.jp/comics/1",
+ )
+ ],
},
- {
- "label": "take only",
- "value": "https://takecomic.jp/comics/1",
- "description": "takecomic",
+ failing_sources={"champion-cross"},
+ )
+ handler = SearchCommandHandler(
+ search_source=search_source,
+ supported_sources=lambda: ("comic-walker", "champion-cross", "takecomic"),
+ )
+
+ response = handler.start(source=None, query="まんが")
+
+ test.assertEqual("横断検索結果です。1件選んでください。", response["content"])
+ test.assertEqual(
+ [
+ {
+ "label": "walker only",
+ "value": "https://comic-walker.com/detail/1",
+ "description": "comic-walker",
+ },
+ {
+ "label": "take only",
+ "value": "https://takecomic.jp/comics/1",
+ "description": "takecomic",
+ },
+ ],
+ response["components"][0]["components"][0]["options"],
+ )
+
+ def run_failure_message_when_all_sources_fail(test):
+ search_source = MultiSourceSearchSourceWithFailures(
+ failing_sources={"comic-walker", "champion-cross", "takecomic"}
+ )
+ handler = SearchCommandHandler(
+ search_source=search_source,
+ supported_sources=lambda: ("comic-walker", "champion-cross", "takecomic"),
+ )
+
+ response = handler.start(source=None, query="まんが")
+
+ test.assertEqual({"content": "作品検索に失敗しました。サーバーログを確認してください。", "components": []}, response)
+
+ def run_no_results_when_failures_mix_with_empty_successes(test):
+ search_source = MultiSourceSearchSourceWithFailures(
+ results_by_source={
+ "comic-walker": [],
+ "takecomic": [],
},
- ],
- response["components"][0]["components"][0]["options"],
- )
+ failing_sources={"champion-cross"},
+ )
+ handler = SearchCommandHandler(
+ search_source=search_source,
+ supported_sources=lambda: ("comic-walker", "champion-cross", "takecomic"),
+ )
- def test_start_returns_failure_message_when_all_cross_source_searches_fail(self):
- search_source = MultiSourceSearchSourceWithFailures(
- failing_sources={"comic-walker", "champion-cross", "takecomic"}
- )
- handler = SearchCommandHandler(
- search_source=search_source,
- supported_sources=lambda: ("comic-walker", "champion-cross", "takecomic"),
- )
+ response = handler.start(source=None, query="まんが")
- response = handler.start(source=None, query="まんが")
+ test.assertEqual({"content": SEARCH_NO_RESULTS_MESSAGE, "components": []}, response)
- self.assertEqual({"content": "作品検索に失敗しました。サーバーログを確認してください。", "components": []}, response)
-
- def test_start_returns_no_results_when_failures_mix_with_empty_successes(self):
- search_source = MultiSourceSearchSourceWithFailures(
- results_by_source={
- "comic-walker": [],
- "takecomic": [],
+ scenarios = [
+ {
+ "scenario": "interleaving_and_dedupe",
+ "sources": ("comic-walker", "champion-cross", "takecomic"),
+ "limit": 3,
+ "run": run_interleaving_and_dedupe,
},
- failing_sources={"champion-cross"},
- )
- handler = SearchCommandHandler(
- search_source=search_source,
- supported_sources=lambda: ("comic-walker", "champion-cross", "takecomic"),
- )
-
- response = handler.start(source=None, query="まんが")
-
- self.assertEqual({"content": SEARCH_NO_RESULTS_MESSAGE, "components": []}, response)
+ {
+ "scenario": "select_limit_cap",
+ "sources": tuple(f"source-{index}" for index in range(9)),
+ "limit": 3,
+ "run": run_select_limit_cap,
+ },
+ {
+ "scenario": "partial_results_with_failures",
+ "sources": ("comic-walker", "champion-cross", "takecomic"),
+ "limit": 3,
+ "run": run_partial_results_when_some_sources_fail,
+ },
+ {
+ "scenario": "all_sources_fail",
+ "sources": ("comic-walker", "champion-cross", "takecomic"),
+ "limit": 3,
+ "run": run_failure_message_when_all_sources_fail,
+ },
+ {
+ "scenario": "failures_mix_with_empty_successes",
+ "sources": ("comic-walker", "champion-cross", "takecomic"),
+ "limit": 3,
+ "run": run_no_results_when_failures_mix_with_empty_successes,
+ },
+ ]
+ for scenario in scenarios:
+ with self.subTest(
+ scenario=scenario["scenario"],
+ sources=scenario["sources"],
+ limit=scenario["limit"],
+ ):
+ scenario["run"](self)
def test_handle_component_adds_selected_result_with_hidden_flag(self):
add_subscription = FakeAddSubscription()
diff --git a/tests/test_discord_supertwins_interactions.py b/tests/test_discord_supertwins_interactions.py
index a5b6e4d..18ba2b9 100644
--- a/tests/test_discord_supertwins_interactions.py
+++ b/tests/test_discord_supertwins_interactions.py
@@ -9,7 +9,6 @@
DiscordRequestVerifier,
)
from manga_watch.discord_supertwins_manage import SUPERTWINS_MANAGE_COMMAND
-from manga_watch.discord_supertwins_search import SUPERTWINS_SEARCH_COMMAND
class RecordingFetchDispatcher:
@@ -73,30 +72,6 @@ def signed_request(self, payload, signing_key):
}
return headers, body
- def test_supertwins_search_command_routes_to_ephemeral_handler(self):
- class FakeSearchHandler:
- def __init__(self):
- self.calls = []
-
- def start(self, **kwargs):
- self.calls.append(kwargs)
- return {"content": "候補検索はまだ有効化されていません。", "components": []}
-
- handler = FakeSearchHandler()
- service, signing_key = self.make_service(supertwins_search_handler=handler)
- headers, body = self.signed_request(
- {"type": 2, "data": {"name": SUPERTWINS_SEARCH_COMMAND}},
- signing_key,
- )
-
- response = service.handle_request(method="POST", path="/", headers=headers, body=body)
-
- payload = json.loads(response.body)
- self.assertEqual(4, payload["type"])
- self.assertEqual(64, payload["data"]["flags"])
- self.assertEqual("候補検索はまだ有効化されていません。", payload["data"]["content"])
- self.assertEqual(1, len(handler.calls))
-
def test_supertwins_manage_command_routes_to_ephemeral_handler(self):
class FakeManageHandler:
def __init__(self):
@@ -121,29 +96,6 @@ def start(self, **kwargs):
self.assertEqual("supertwins 管理はまだ有効化されていません。", payload["data"]["content"])
self.assertEqual(1, len(handler.calls))
- def test_supertwins_search_component_prefix_routes_to_handler(self):
- class FakeSearchHandler:
- def __init__(self):
- self.calls = []
-
- def handle_component(self, data, **kwargs):
- self.calls.append({"data": data, **kwargs})
- return {"content": "updated-search", "components": []}
-
- handler = FakeSearchHandler()
- service, signing_key = self.make_service(supertwins_search_handler=handler)
- headers, body = self.signed_request(
- {"type": 3, "data": {"custom_id": "supertwins_search:placeholder"}},
- signing_key,
- )
-
- response = service.handle_request(method="POST", path="/", headers=headers, body=body)
-
- payload = json.loads(response.body)
- self.assertEqual(7, payload["type"])
- self.assertEqual("updated-search", payload["data"]["content"])
- self.assertEqual("supertwins_search:placeholder", handler.calls[0]["data"]["custom_id"])
-
def test_supertwins_search_root_select_defers_and_edits_original_message(self):
class FakeSearchHandler:
def __init__(self):
@@ -203,66 +155,6 @@ def handle_component(self, data, **kwargs):
)
self.assertEqual("supertwins_search_work_select", handler.calls[0]["data"]["custom_id"])
- def test_supertwins_search_result_select_still_uses_inline_update_message(self):
- class FakeSearchHandler:
- def __init__(self):
- self.calls = []
-
- def handle_component(self, data, **kwargs):
- self.calls.append({"data": data, **kwargs})
- return {"content": "updated-search", "components": []}
-
- callback_client = RecordingInteractionCallbackClient()
- handler = FakeSearchHandler()
- service, signing_key = self.make_service(
- supertwins_search_handler=handler,
- interaction_callback_client=callback_client,
- )
- headers, body = self.signed_request(
- {
- "id": "interaction-1",
- "application_id": "app-1",
- "token": "token-1",
- "type": 3,
- "data": {
- "custom_id": "supertwins_search:results:token-1",
- "values": ["candidate-1"],
- },
- },
- signing_key,
- )
-
- response = service.handle_request(method="POST", path="/", headers=headers, body=body)
-
- payload = json.loads(response.body)
- self.assertEqual(7, payload["type"])
- self.assertEqual("updated-search", payload["data"]["content"])
- self.assertEqual([], callback_client.defer_calls)
- self.assertEqual([], callback_client.edit_calls)
-
- def test_supertwins_manage_component_prefix_routes_to_handler(self):
- class FakeManageHandler:
- def __init__(self):
- self.calls = []
-
- def handle_component(self, data, **kwargs):
- self.calls.append({"data": data, **kwargs})
- return {"content": "updated-manage", "components": []}
-
- handler = FakeManageHandler()
- service, signing_key = self.make_service(supertwins_manage_handler=handler)
- headers, body = self.signed_request(
- {"type": 3, "data": {"custom_id": "supertwins_manage:placeholder"}},
- signing_key,
- )
-
- response = service.handle_request(method="POST", path="/", headers=headers, body=body)
-
- payload = json.loads(response.body)
- self.assertEqual(7, payload["type"])
- self.assertEqual("updated-manage", payload["data"]["content"])
- self.assertEqual("supertwins_manage:placeholder", handler.calls[0]["data"]["custom_id"])
-
if __name__ == "__main__":
unittest.main()
diff --git a/tests/test_discord_title_search.py b/tests/test_discord_title_search.py
index 9d9d9ad..535505f 100644
--- a/tests/test_discord_title_search.py
+++ b/tests/test_discord_title_search.py
@@ -1,6 +1,6 @@
import unittest
-from manga_watch.discord_title_search import TITLE_COMMAND, TITLE_USAGE_MESSAGE, handle_title_query
+from manga_watch.discord_title_search import TITLE_USAGE_MESSAGE, handle_title_query
class RecordingSearchSource:
@@ -38,9 +38,6 @@ def __call__(self, source, query, *, limit=10, http_client=None):
class DiscordTitleSearchTests(unittest.TestCase):
- def test_title_command_name_is_exported(self):
- self.assertEqual("title", TITLE_COMMAND)
-
def test_handle_title_query_returns_none_for_non_title_messages(self):
self.assertIsNone(handle_title_query("latest"))
self.assertIsNone(handle_title_query("titleworks"))
diff --git a/tests/test_discord_where.py b/tests/test_discord_where.py
index 3a4af40..a2983ee 100644
--- a/tests/test_discord_where.py
+++ b/tests/test_discord_where.py
@@ -4,7 +4,6 @@
from manga_watch.discord_where import (
StoredWhereContextStore,
- WHERE_COMMAND,
WHERE_NO_RESULTS_MESSAGE,
WhereCommandHandler,
)
@@ -88,9 +87,6 @@ def __call__(self, source, seed_url, episode, *, http_client=None):
class DiscordWhereTests(unittest.TestCase):
- def test_where_command_name_is_exported(self):
- self.assertEqual("where", WHERE_COMMAND)
-
def test_start_searches_availability_sources_and_returns_select_menu(self):
search_source = MultiSourceSearchSource(
{
@@ -226,131 +222,141 @@ def test_handle_component_uses_selected_candidate_when_same_source_titles_collid
self.assertEqual("https://comic-walker.com/detail/selected", resolver.calls[0]["seed_url"])
self.assertEqual("https://manga.nicovideo.jp/comic/selected", resolver.calls[1]["seed_url"])
- def test_handle_component_loads_context_from_storage_across_handler_instances(self):
- search_source = MultiSourceSearchSource(
- {
- "comic-walker": [
- SearchResult(
- source="comic-walker",
- title="ニセモノの錬金術師",
- seed_url="https://comic-walker.com/detail/KC_004800_S",
- )
- ],
- "nicovideo-manga": [
- SearchResult(
- source="nicovideo-manga",
- title="ニセモノの錬金術師",
- seed_url="https://manga.nicovideo.jp/comic/62782",
- )
- ],
- }
- )
- resolver = FakeAvailabilityResolver()
- with tempfile.TemporaryDirectory() as tmpdir:
- state_path = str(Path(tmpdir) / "state.json")
- first_handler = WhereCommandHandler(
- search_source=search_source,
- context_store=StoredWhereContextStore(state_path=state_path, backend="json"),
- )
- start_response = first_handler.start(query="ニセモノの錬金術師", episode="1話")
- select = start_response["components"][0]["components"][0]
- second_handler = WhereCommandHandler(
- availability_resolver=resolver,
- context_store=StoredWhereContextStore(state_path=state_path, backend="json"),
+ def test_where_context_store_lifecycle_matrix(self):
+ def run_loads_context_across_handler_instances(test):
+ search_source = MultiSourceSearchSource(
+ {
+ "comic-walker": [
+ SearchResult(
+ source="comic-walker",
+ title="ニセモノの錬金術師",
+ seed_url="https://comic-walker.com/detail/KC_004800_S",
+ )
+ ],
+ "nicovideo-manga": [
+ SearchResult(
+ source="nicovideo-manga",
+ title="ニセモノの錬金術師",
+ seed_url="https://manga.nicovideo.jp/comic/62782",
+ )
+ ],
+ }
)
-
- response = second_handler.handle_component(
- {"custom_id": select["custom_id"], "values": ["0"]},
+ resolver = FakeAvailabilityResolver()
+ with tempfile.TemporaryDirectory() as tmpdir:
+ state_path = str(Path(tmpdir) / "state.json")
+ first_handler = WhereCommandHandler(
+ search_source=search_source,
+ context_store=StoredWhereContextStore(state_path=state_path, backend="json"),
+ )
+ start_response = first_handler.start(query="ニセモノの錬金術師", episode="1話")
+ select = start_response["components"][0]["components"][0]
+ second_handler = WhereCommandHandler(
+ availability_resolver=resolver,
+ context_store=StoredWhereContextStore(state_path=state_path, backend="json"),
+ )
+
+ response = second_handler.handle_component(
+ {"custom_id": select["custom_id"], "values": ["0"]},
+ )
+
+ test.assertIn("ComicWalker: 今すぐ無料", response["content"])
+ test.assertIn("ニコニコ漫画: 今すぐ無料", response["content"])
+ test.assertEqual(
+ [
+ {
+ "source": "comic-walker",
+ "seed_url": "https://comic-walker.com/detail/KC_004800_S",
+ "episode": "1話",
+ "http_client": None,
+ },
+ {
+ "source": "nicovideo-manga",
+ "seed_url": "https://manga.nicovideo.jp/comic/62782",
+ "episode": "1話",
+ "http_client": None,
+ },
+ ],
+ resolver.calls,
)
- self.assertIn("ComicWalker: 今すぐ無料", response["content"])
- self.assertIn("ニコニコ漫画: 今すぐ無料", response["content"])
- self.assertEqual(
- [
+ def run_deletes_storage_context_after_selection(test):
+ search_source = MultiSourceSearchSource(
{
- "source": "comic-walker",
- "seed_url": "https://comic-walker.com/detail/KC_004800_S",
- "episode": "1話",
- "http_client": None,
- },
+ "comic-walker": [
+ SearchResult(
+ source="comic-walker",
+ title="ニセモノの錬金術師",
+ seed_url="https://comic-walker.com/detail/KC_004800_S",
+ )
+ ],
+ "nicovideo-manga": [
+ SearchResult(
+ source="nicovideo-manga",
+ title="ニセモノの錬金術師",
+ seed_url="https://manga.nicovideo.jp/comic/62782",
+ )
+ ],
+ }
+ )
+ resolver = FakeAvailabilityResolver()
+ with tempfile.TemporaryDirectory() as tmpdir:
+ state_path = str(Path(tmpdir) / "state.json")
+ handler = WhereCommandHandler(
+ search_source=search_source,
+ availability_resolver=resolver,
+ context_store=StoredWhereContextStore(state_path=state_path, backend="json"),
+ )
+ start_response = handler.start(query="ニセモノの錬金術師", episode="1話")
+ select = start_response["components"][0]["components"][0]
+
+ first_response = handler.handle_component({"custom_id": select["custom_id"], "values": ["0"]})
+ second_response = handler.handle_component({"custom_id": select["custom_id"], "values": ["0"]})
+
+ test.assertIn("ComicWalker: 今すぐ無料", first_response["content"])
+ test.assertIn("有効期限が切れた", second_response["content"])
+
+ def run_unique_context_token_for_identical_searches(test):
+ search_source = MultiSourceSearchSource(
{
- "source": "nicovideo-manga",
- "seed_url": "https://manga.nicovideo.jp/comic/62782",
- "episode": "1話",
- "http_client": None,
- },
- ],
- resolver.calls,
- )
-
- def test_handle_component_deletes_storage_context_after_selection(self):
- search_source = MultiSourceSearchSource(
- {
- "comic-walker": [
- SearchResult(
- source="comic-walker",
- title="ニセモノの錬金術師",
- seed_url="https://comic-walker.com/detail/KC_004800_S",
- )
- ],
- "nicovideo-manga": [
- SearchResult(
- source="nicovideo-manga",
- title="ニセモノの錬金術師",
- seed_url="https://manga.nicovideo.jp/comic/62782",
- )
- ],
- }
- )
- resolver = FakeAvailabilityResolver()
- with tempfile.TemporaryDirectory() as tmpdir:
- state_path = str(Path(tmpdir) / "state.json")
- handler = WhereCommandHandler(
- search_source=search_source,
- availability_resolver=resolver,
- context_store=StoredWhereContextStore(state_path=state_path, backend="json"),
+ "comic-walker": [
+ SearchResult(
+ source="comic-walker",
+ title="ニセモノの錬金術師",
+ seed_url="https://comic-walker.com/detail/KC_004800_S",
+ )
+ ],
+ "nicovideo-manga": [
+ SearchResult(
+ source="nicovideo-manga",
+ title="ニセモノの錬金術師",
+ seed_url="https://manga.nicovideo.jp/comic/62782",
+ )
+ ],
+ }
)
- start_response = handler.start(query="ニセモノの錬金術師", episode="1話")
- select = start_response["components"][0]["components"][0]
-
- first_response = handler.handle_component({"custom_id": select["custom_id"], "values": ["0"]})
- second_response = handler.handle_component({"custom_id": select["custom_id"], "values": ["0"]})
-
- self.assertIn("ComicWalker: 今すぐ無料", first_response["content"])
- self.assertIn("有効期限が切れた", second_response["content"])
+ handler = WhereCommandHandler(search_source=search_source, availability_resolver=FakeAvailabilityResolver())
+ first_response = handler.start(query="ニセモノの錬金術師", episode="1話")
+ second_response = handler.start(query="ニセモノの錬金術師", episode="1話")
+ first_select = first_response["components"][0]["components"][0]
+ second_select = second_response["components"][0]["components"][0]
- def test_start_generates_unique_context_token_for_identical_searches(self):
- search_source = MultiSourceSearchSource(
- {
- "comic-walker": [
- SearchResult(
- source="comic-walker",
- title="ニセモノの錬金術師",
- seed_url="https://comic-walker.com/detail/KC_004800_S",
- )
- ],
- "nicovideo-manga": [
- SearchResult(
- source="nicovideo-manga",
- title="ニセモノの錬金術師",
- seed_url="https://manga.nicovideo.jp/comic/62782",
- )
- ],
- }
- )
- handler = WhereCommandHandler(search_source=search_source, availability_resolver=FakeAvailabilityResolver())
- first_response = handler.start(query="ニセモノの錬金術師", episode="1話")
- second_response = handler.start(query="ニセモノの錬金術師", episode="1話")
- first_select = first_response["components"][0]["components"][0]
- second_select = second_response["components"][0]["components"][0]
+ test.assertNotEqual(first_select["custom_id"], second_select["custom_id"])
- self.assertNotEqual(first_select["custom_id"], second_select["custom_id"])
+ first_result = handler.handle_component({"custom_id": first_select["custom_id"], "values": ["0"]})
+ second_result = handler.handle_component({"custom_id": second_select["custom_id"], "values": ["0"]})
- first_result = handler.handle_component({"custom_id": first_select["custom_id"], "values": ["0"]})
- second_result = handler.handle_component({"custom_id": second_select["custom_id"], "values": ["0"]})
+ test.assertIn("ComicWalker: 今すぐ無料", first_result["content"])
+ test.assertIn("ComicWalker: 今すぐ無料", second_result["content"])
- self.assertIn("ComicWalker: 今すぐ無料", first_result["content"])
- self.assertIn("ComicWalker: 今すぐ無料", second_result["content"])
+ cases = [
+ ("loads_context_across_handler_instances", run_loads_context_across_handler_instances),
+ ("deletes_storage_context_after_selection", run_deletes_storage_context_after_selection),
+ ("unique_context_token_for_identical_searches", run_unique_context_token_for_identical_searches),
+ ]
+ for case, run in cases:
+ with self.subTest(case=case):
+ run(self)
def test_start_returns_no_results_when_supported_sources_have_no_candidates(self):
handler = WhereCommandHandler(search_source=MultiSourceSearchSource({}))
@@ -359,111 +365,121 @@ def test_start_returns_no_results_when_supported_sources_have_no_candidates(self
self.assertEqual({"content": WHERE_NO_RESULTS_MESSAGE, "components": []}, response)
- def test_handle_component_renders_not_found_when_episode_is_missing(self):
- search_source = MultiSourceSearchSource(
- {
- "comic-walker": [
- SearchResult(
- source="comic-walker",
- title="ニセモノの錬金術師",
- seed_url="https://comic-walker.com/detail/KC_004800_S",
- )
- ],
- "nicovideo-manga": [
- SearchResult(
- source="nicovideo-manga",
- title="ニセモノの錬金術師",
- seed_url="https://manga.nicovideo.jp/comic/62782",
- )
- ],
- }
- )
- resolver = StaticAvailabilityResolver(
- {
- "comic-walker": {"source": "comic-walker", "status": "not_found", "url": None},
- "nicovideo-manga": {"source": "nicovideo-manga", "status": "not_found", "url": None},
- }
- )
- handler = WhereCommandHandler(search_source=search_source, availability_resolver=resolver)
- start_response = handler.start(query="ニセモノの錬金術師", episode="第99話")
- select = start_response["components"][0]["components"][0]
+ def test_handle_component_availability_render_matrix(self):
+ def run_not_found_when_episode_is_missing(test):
+ search_source = MultiSourceSearchSource(
+ {
+ "comic-walker": [
+ SearchResult(
+ source="comic-walker",
+ title="ニセモノの錬金術師",
+ seed_url="https://comic-walker.com/detail/KC_004800_S",
+ )
+ ],
+ "nicovideo-manga": [
+ SearchResult(
+ source="nicovideo-manga",
+ title="ニセモノの錬金術師",
+ seed_url="https://manga.nicovideo.jp/comic/62782",
+ )
+ ],
+ }
+ )
+ resolver = StaticAvailabilityResolver(
+ {
+ "comic-walker": {"source": "comic-walker", "status": "not_found", "url": None},
+ "nicovideo-manga": {"source": "nicovideo-manga", "status": "not_found", "url": None},
+ }
+ )
+ handler = WhereCommandHandler(search_source=search_source, availability_resolver=resolver)
+ start_response = handler.start(query="ニセモノの錬金術師", episode="第99話")
+ select = start_response["components"][0]["components"][0]
- response = handler.handle_component({"custom_id": select["custom_id"], "values": ["0"]})
+ response = handler.handle_component({"custom_id": select["custom_id"], "values": ["0"]})
- self.assertIn("ComicWalker: 見つからない", response["content"])
- self.assertIn("ニコニコ漫画: 見つからない", response["content"])
+ test.assertIn("ComicWalker: 見つからない", response["content"])
+ test.assertIn("ニコニコ漫画: 見つからない", response["content"])
- def test_handle_component_renders_needs_check_for_failed_source_search(self):
- search_source = FailingSourceSearchSource(
- {
- "nicovideo-manga": [
- SearchResult(
- source="nicovideo-manga",
- title="ニセモノの錬金術師",
- seed_url="https://manga.nicovideo.jp/comic/62782",
- )
- ],
- },
- failing_sources={"comic-walker"},
- )
- resolver = FakeAvailabilityResolver()
- handler = WhereCommandHandler(search_source=search_source, availability_resolver=resolver)
- start_response = handler.start(query="ニセモノの錬金術師", episode="1話")
- select = start_response["components"][0]["components"][0]
+ def run_needs_check_for_failed_source_search(test):
+ search_source = FailingSourceSearchSource(
+ {
+ "nicovideo-manga": [
+ SearchResult(
+ source="nicovideo-manga",
+ title="ニセモノの錬金術師",
+ seed_url="https://manga.nicovideo.jp/comic/62782",
+ )
+ ],
+ },
+ failing_sources={"comic-walker"},
+ )
+ resolver = FakeAvailabilityResolver()
+ handler = WhereCommandHandler(search_source=search_source, availability_resolver=resolver)
+ start_response = handler.start(query="ニセモノの錬金術師", episode="1話")
+ select = start_response["components"][0]["components"][0]
- response = handler.handle_component({"custom_id": select["custom_id"], "values": ["0"]})
+ response = handler.handle_component({"custom_id": select["custom_id"], "values": ["0"]})
+
+ test.assertIn("ComicWalker: 要確認", response["content"])
+ test.assertIn("ニコニコ漫画: 今すぐ無料", response["content"])
+ test.assertEqual(
+ [
+ {
+ "source": "nicovideo-manga",
+ "seed_url": "https://manga.nicovideo.jp/comic/62782",
+ "episode": "1話",
+ "http_client": None,
+ }
+ ],
+ resolver.calls,
+ )
- self.assertIn("ComicWalker: 要確認", response["content"])
- self.assertIn("ニコニコ漫画: 今すぐ無料", response["content"])
- self.assertEqual(
- [
+ def run_needs_check_with_seed_url_when_resolution_fails(test):
+ search_source = MultiSourceSearchSource(
{
- "source": "nicovideo-manga",
- "seed_url": "https://manga.nicovideo.jp/comic/62782",
- "episode": "1話",
- "http_client": None,
+ "comic-walker": [
+ SearchResult(
+ source="comic-walker",
+ title="ニセモノの錬金術師",
+ seed_url="https://comic-walker.com/detail/KC_004800_S",
+ )
+ ],
+ "nicovideo-manga": [
+ SearchResult(
+ source="nicovideo-manga",
+ title="ニセモノの錬金術師",
+ seed_url="https://manga.nicovideo.jp/comic/62782",
+ )
+ ],
}
- ],
- resolver.calls,
- )
+ )
+ resolver = StaticAvailabilityResolver(
+ {
+ "comic-walker": RuntimeError("temporary fetch failure"),
+ "nicovideo-manga": {
+ "source": "nicovideo-manga",
+ "status": "free_now",
+ "url": "https://manga.nicovideo.jp/watch/mg1000001",
+ },
+ }
+ )
+ handler = WhereCommandHandler(search_source=search_source, availability_resolver=resolver)
+ start_response = handler.start(query="ニセモノの錬金術師", episode="第1話")
+ select = start_response["components"][0]["components"][0]
- def test_handle_component_renders_needs_check_with_seed_url_when_resolution_fails(self):
- search_source = MultiSourceSearchSource(
- {
- "comic-walker": [
- SearchResult(
- source="comic-walker",
- title="ニセモノの錬金術師",
- seed_url="https://comic-walker.com/detail/KC_004800_S",
- )
- ],
- "nicovideo-manga": [
- SearchResult(
- source="nicovideo-manga",
- title="ニセモノの錬金術師",
- seed_url="https://manga.nicovideo.jp/comic/62782",
- )
- ],
- }
- )
- resolver = StaticAvailabilityResolver(
- {
- "comic-walker": RuntimeError("temporary fetch failure"),
- "nicovideo-manga": {
- "source": "nicovideo-manga",
- "status": "free_now",
- "url": "https://manga.nicovideo.jp/watch/mg1000001",
- },
- }
- )
- handler = WhereCommandHandler(search_source=search_source, availability_resolver=resolver)
- start_response = handler.start(query="ニセモノの錬金術師", episode="第1話")
- select = start_response["components"][0]["components"][0]
+ response = handler.handle_component({"custom_id": select["custom_id"], "values": ["0"]})
- response = handler.handle_component({"custom_id": select["custom_id"], "values": ["0"]})
+ test.assertIn("ComicWalker: 要確認", response["content"])
+ test.assertIn("https://comic-walker.com/detail/KC_004800_S", response["content"])
- self.assertIn("ComicWalker: 要確認", response["content"])
- self.assertIn("https://comic-walker.com/detail/KC_004800_S", response["content"])
+ cases = [
+ ("not_found", run_not_found_when_episode_is_missing),
+ ("failed_source_search", run_needs_check_for_failed_source_search),
+ ("resolution_failure", run_needs_check_with_seed_url_when_resolution_fails),
+ ]
+ for case, run in cases:
+ with self.subTest(case=case):
+ run(self)
if __name__ == "__main__":
diff --git a/tests/test_firestore_storage.py b/tests/test_firestore_storage.py
index a4a23e9..7ff343e 100644
--- a/tests/test_firestore_storage.py
+++ b/tests/test_firestore_storage.py
@@ -1,4 +1,3 @@
-import json
import os
import unittest
from unittest import mock
@@ -19,154 +18,7 @@
validate_state,
validate_watchlist,
)
-
-
-def make_watchlist():
- return {
- "version": 2,
- "works": [
- {
- "id": "work-1",
- "source": "comic-walker",
- "seed_url": "https://comic-walker.com/detail/work-1",
- "enabled": True,
- "notification_policy": {
- "mode": "all",
- "allowed_update_types": None,
- },
- }
- ],
- }
-
-
-def make_state():
- return {
- "version": 2,
- "works": {
- "work-1": {
- "latest": {
- "series_title": "作品A",
- "episode_title": "第2話",
- "latest_key": "episode-2",
- "url": "https://example.com/episodes/2",
- "update_type": "main_story",
- "default_notify": True,
- },
- "history": [
- {
- "event_id": "event-1",
- "detected_at": "2023-11-14T22:13:20Z",
- "latest": {
- "series_title": "作品A",
- "episode_title": "第2話",
- "latest_key": "episode-2",
- },
- }
- ],
- "unread": {"event_ids": ["event-1"]},
- }
- },
- "last_run_at": 1_700_000_000,
- "notification_outbox": [
- {
- "event": {
- "schema_version": 1,
- "event_id": "event-1",
- "work_id": "work-1",
- "latest_key": "episode-2",
- "series_title": "作品A",
- "update_type": "main_story",
- "detected_at": "2023-11-14T22:13:20Z",
- "from": {"latest_key": "episode-1"},
- "to": {"latest_key": "episode-2"},
- },
- "pending_backends": ["stdout", "webhook"],
- "attempt_count": 1,
- "last_attempted_at": "2023-11-14T22:15:00Z",
- "last_error": "webhook timeout",
- }
- ],
- "discord_delivery": {
- "daily_notification": {
- "delivered_latest_keys": {
- "work-1": {
- "latest_key": "episode-2",
- "delivered_at": None,
- },
- },
- "pending_messages": [
- {
- "channel_id": "main-channel",
- "content": "pending daily message",
- "message_keys": [
- {
- "work_id": "work-1",
- "latest_key": "episode-2",
- }
- ],
- "created_at": "2023-11-14T22:13:20Z",
- "attempt_count": 0,
- "last_attempted_at": None,
- "last_error": None,
- }
- ],
- }
- },
- }
-
-
-class FakeSnapshot:
- def __init__(self, doc_id, payload):
- self.id = doc_id
- self._payload = None if payload is None else json.loads(json.dumps(payload, ensure_ascii=False))
-
- @property
- def exists(self):
- return self._payload is not None
-
- def to_dict(self):
- if self._payload is None:
- return None
- return json.loads(json.dumps(self._payload, ensure_ascii=False))
-
-
-class FakeDocument:
- def __init__(self, store, collection_name, doc_id):
- self.store = store
- self.collection_name = collection_name
- self.doc_id = doc_id
-
- def get(self):
- return FakeSnapshot(self.doc_id, self.store.get(self.collection_name, {}).get(self.doc_id))
-
- def set(self, payload):
- self.store.setdefault(self.collection_name, {})[self.doc_id] = json.loads(
- json.dumps(payload, ensure_ascii=False)
- )
-
- def delete(self):
- self.store.setdefault(self.collection_name, {}).pop(self.doc_id, None)
-
-
-class FakeCollection:
- def __init__(self, store, collection_name):
- self.store = store
- self.collection_name = collection_name
-
- def document(self, doc_id):
- return FakeDocument(self.store, self.collection_name, doc_id)
-
- def stream(self):
- docs = self.store.setdefault(self.collection_name, {})
- return [FakeSnapshot(doc_id, payload) for doc_id, payload in sorted(docs.items())]
-
-
-class FakeFirestoreClient:
- def __init__(self):
- self.store = {}
-
- def collection(self, collection_name):
- return FakeCollection(self.store, collection_name)
+from tests._firestore_fakes import FakeFirestoreClient, make_state, make_watchlist
class FirestoreStorageTests(unittest.TestCase):
@@ -250,48 +102,39 @@ def test_env_selected_firestore_backend_is_used_without_explicit_backend_arg(sel
self.assertEqual("work-1", loaded["works"][0]["id"])
- def test_firestore_backend_round_trips_supertwins_search_sessions_by_token(self):
- repository = self.make_repository()
-
- with mock.patch("manga_watch.storage.get_firestore_repository", return_value=repository):
- save_supertwins_search_session(
- "session-a",
- {"root_work_id": "root-1", "selected_urls_by_value": {"u:a": "https://example.com/a"}},
- backend="firestore",
- )
- save_supertwins_search_session(
- "session-b",
- {"root_work_id": "root-2", "selected_urls_by_value": {"u:b": "https://example.com/b"}},
- backend="firestore",
- )
- session_a = load_supertwins_search_session("session-a", backend="firestore")
- session_b = load_supertwins_search_session("session-b", backend="firestore")
- delete_supertwins_search_session("session-a", backend="firestore")
-
- self.assertEqual("root-1", session_a["root_work_id"])
- self.assertEqual("root-2", session_b["root_work_id"])
- self.assertIn("runtime:session-b", repository.client.store["supertwins_search_sessions"])
- self.assertNotIn("runtime:session-a", repository.client.store["supertwins_search_sessions"])
-
- def test_firestore_backend_round_trips_where_sessions_by_token(self):
- repository = self.make_repository()
-
- with mock.patch("manga_watch.storage.get_firestore_repository", return_value=repository):
- save_where_session(
- "session-a",
- {"query": "作品A", "episode": "1話", "results": [{"source": "comic-walker"}]},
- backend="firestore",
- )
- save_where_session(
- "session-b",
- {"query": "作品B", "episode": "2話", "results": [{"source": "nicovideo-manga"}]},
- backend="firestore",
- )
- session_a = load_where_session("session-a", backend="firestore")
- session_b = load_where_session("session-b", backend="firestore")
- delete_where_session("session-a", backend="firestore")
-
- self.assertEqual("作品A", session_a["query"])
- self.assertEqual("作品B", session_b["query"])
- self.assertIn("runtime:session-b", repository.client.store["where_sessions"])
- self.assertNotIn("runtime:session-a", repository.client.store["where_sessions"])
+ def test_firestore_backend_round_trips_search_sessions_by_token(self):
+ session_cases = {
+ "supertwins": {
+ "save": save_supertwins_search_session,
+ "load": load_supertwins_search_session,
+ "delete": delete_supertwins_search_session,
+ "payload_a": {"root_work_id": "root-1", "selected_urls_by_value": {"u:a": "https://example.com/a"}},
+ "payload_b": {"root_work_id": "root-2", "selected_urls_by_value": {"u:b": "https://example.com/b"}},
+ "collection": "supertwins_search_sessions",
+ "field": "root_work_id",
+ },
+ "where": {
+ "save": save_where_session,
+ "load": load_where_session,
+ "delete": delete_where_session,
+ "payload_a": {"query": "作品A", "episode": "1話", "results": [{"source": "comic-walker"}]},
+ "payload_b": {"query": "作品B", "episode": "2話", "results": [{"source": "nicovideo-manga"}]},
+ "collection": "where_sessions",
+ "field": "query",
+ },
+ }
+ for session_kind, funcs in session_cases.items():
+ with self.subTest(session_kind=session_kind):
+ repository = self.make_repository()
+
+ with mock.patch("manga_watch.storage.get_firestore_repository", return_value=repository):
+ funcs["save"]("session-a", funcs["payload_a"], backend="firestore")
+ funcs["save"]("session-b", funcs["payload_b"], backend="firestore")
+ session_a = funcs["load"]("session-a", backend="firestore")
+ session_b = funcs["load"]("session-b", backend="firestore")
+ funcs["delete"]("session-a", backend="firestore")
+
+ self.assertEqual(funcs["payload_a"][funcs["field"]], session_a[funcs["field"]])
+ self.assertEqual(funcs["payload_b"][funcs["field"]], session_b[funcs["field"]])
+ self.assertIn("runtime:session-b", repository.client.store[funcs["collection"]])
+ self.assertNotIn("runtime:session-a", repository.client.store[funcs["collection"]])
diff --git a/tests/test_github_issue_reporting.py b/tests/test_github_issue_reporting.py
index a1a33b3..1d971f7 100644
--- a/tests/test_github_issue_reporting.py
+++ b/tests/test_github_issue_reporting.py
@@ -36,41 +36,20 @@ def post(self, url, **kwargs):
class GitHubIssueReportingTests(unittest.TestCase):
- def test_build_reporter_from_env_returns_none_when_unconfigured(self):
+ def test_build_reporter_from_env_returns_none_when_incomplete(self):
from manga_watch.github_issue_reporting import build_unsupported_source_issue_reporter_from_env
- with mock.patch.dict(os.environ, {}, clear=True):
- reporter = build_unsupported_source_issue_reporter_from_env()
-
- self.assertIsNone(reporter)
-
- def test_build_reporter_from_env_returns_none_when_only_token_is_present(self):
- from manga_watch.github_issue_reporting import build_unsupported_source_issue_reporter_from_env
-
- with mock.patch.dict(
- os.environ,
- {
- "MANGA_WATCH_GITHUB_TOKEN": "github-token",
- },
- clear=True,
- ):
- reporter = build_unsupported_source_issue_reporter_from_env()
+ cases = {
+ "neither_present": {},
+ "only_token": {"MANGA_WATCH_GITHUB_TOKEN": "github-token"},
+ "only_repository": {"MANGA_WATCH_GITHUB_REPOSITORY": "kentoku24/comic_crawler"},
+ }
+ for case, env in cases.items():
+ with self.subTest(case=case):
+ with mock.patch.dict(os.environ, env, clear=True):
+ reporter = build_unsupported_source_issue_reporter_from_env()
- self.assertIsNone(reporter)
-
- def test_build_reporter_from_env_returns_none_when_only_repository_is_present(self):
- from manga_watch.github_issue_reporting import build_unsupported_source_issue_reporter_from_env
-
- with mock.patch.dict(
- os.environ,
- {
- "MANGA_WATCH_GITHUB_REPOSITORY": "kentoku24/comic_crawler",
- },
- clear=True,
- ):
- reporter = build_unsupported_source_issue_reporter_from_env()
-
- self.assertIsNone(reporter)
+ self.assertIsNone(reporter)
def test_report_unsupported_source_creates_issue(self):
from manga_watch.github_issue_reporting import GitHubIssueReporter, GitHubIssueReporterConfig
@@ -156,89 +135,52 @@ def test_report_unsupported_source_redacts_query_and_userinfo_from_issue_body(se
def test_report_unsupported_source_reuses_existing_open_issue(self):
from manga_watch.github_issue_reporting import GitHubIssueReporter, GitHubIssueReporterConfig
- session = FakeRequestsSession(
- get_responses=[
- FakeResponse(
- status_code=200,
- json_data=[
- {
- "number": 175,
- "html_url": "https://github.com/kentoku24/comic_crawler/issues/175",
- "title": "Unsupported source request from Discord /add: example.com",
- "body": (
- "\n"
- "- Input URL: `https://example.com/work/1`\n"
- "- Host: `example.com`\n"
- ),
- }
+ cases = {
+ "exact_url": "https://example.com/work/1",
+ "host_with_port_and_query": "https://example.com:443/work/1?token=secret",
+ }
+ for case, url in cases.items():
+ with self.subTest(case=case):
+ session = FakeRequestsSession(
+ get_responses=[
+ FakeResponse(
+ status_code=200,
+ json_data=[
+ {
+ "number": 175,
+ "html_url": "https://github.com/kentoku24/comic_crawler/issues/175",
+ "title": "Unsupported source request from Discord /add: example.com",
+ "body": (
+ "\n"
+ "- Input URL: `https://example.com/work/1`\n"
+ "- Host: `example.com`\n"
+ ),
+ }
+ ],
+ )
],
)
- ],
- )
- reporter = GitHubIssueReporter(
- GitHubIssueReporterConfig(
- token="github-token",
- repository="kentoku24/comic_crawler",
- ),
- session=session,
- )
-
- outcome = reporter.report_unsupported_source(
- url="https://example.com/work/1",
- error=WatchlistAddError(
- "unsupported_source",
- "Unsupported source host: example.com",
- "Use one of the supported sources.",
- ),
- )
-
- self.assertEqual("duplicate", outcome["action"])
- self.assertEqual(175, outcome["issue_number"])
- self.assertEqual(1, len(session.get_calls))
- self.assertEqual(0, len(session.post_calls))
-
- def test_report_unsupported_source_deduplicates_by_hostname_without_port(self):
- from manga_watch.github_issue_reporting import GitHubIssueReporter, GitHubIssueReporterConfig
-
- session = FakeRequestsSession(
- get_responses=[
- FakeResponse(
- status_code=200,
- json_data=[
- {
- "number": 175,
- "html_url": "https://github.com/kentoku24/comic_crawler/issues/175",
- "title": "Unsupported source request from Discord /add: example.com",
- "body": (
- "\n"
- "- Input URL: `https://example.com/work/1`\n"
- "- Host: `example.com`\n"
- ),
- }
- ],
+ reporter = GitHubIssueReporter(
+ GitHubIssueReporterConfig(
+ token="github-token",
+ repository="kentoku24/comic_crawler",
+ ),
+ session=session,
)
- ],
- )
- reporter = GitHubIssueReporter(
- GitHubIssueReporterConfig(
- token="github-token",
- repository="kentoku24/comic_crawler",
- ),
- session=session,
- )
- outcome = reporter.report_unsupported_source(
- url="https://example.com:443/work/1?token=secret",
- error=WatchlistAddError(
- "unsupported_source",
- "Unsupported source host: example.com",
- "Use one of the supported sources.",
- ),
- )
+ outcome = reporter.report_unsupported_source(
+ url=url,
+ error=WatchlistAddError(
+ "unsupported_source",
+ "Unsupported source host: example.com",
+ "Use one of the supported sources.",
+ ),
+ )
- self.assertEqual("duplicate", outcome["action"])
- self.assertEqual(175, outcome["issue_number"])
- self.assertEqual(0, len(session.post_calls))
+ self.assertEqual("duplicate", outcome["action"])
+ self.assertEqual(175, outcome["issue_number"])
+ self.assertEqual(1, len(session.get_calls))
+ self.assertEqual(0, len(session.post_calls))
def test_build_reporter_from_env_uses_namespaced_token_and_repository(self):
from manga_watch.github_issue_reporting import build_unsupported_source_issue_reporter_from_env
diff --git a/tests/test_github_workflows.py b/tests/test_github_workflows.py
index 7d746dd..10a333b 100644
--- a/tests/test_github_workflows.py
+++ b/tests/test_github_workflows.py
@@ -1,6 +1,14 @@
import unittest
from pathlib import Path
+try:
+ import yaml
+except ImportError: # pragma: no cover - CI has no PyYAML
+ yaml = None
+ _HAS_YAML = False
+else:
+ _HAS_YAML = True
+
def repo_root() -> Path:
return Path(__file__).resolve().parents[1]
@@ -13,87 +21,330 @@ def read_workflow(name: str) -> str:
return workflow_path.read_text(encoding="utf-8")
+def _strip_quotes(value: str) -> str:
+ if len(value) >= 2 and value[0] == value[-1] and value[0] in ("'", '"'):
+ return value[1:-1]
+ return value
+
+
+def _is_blank_or_comment(line: str) -> bool:
+ stripped = line.strip()
+ return stripped == "" or stripped == "---" or stripped.startswith("#")
+
+
+def _indent_of(line: str) -> int:
+ return len(line) - len(line.lstrip(" "))
+
+
+def _split_key_value(text: str):
+ """Split 'key: value' on the first colon that ends the key.
+
+ Returns (key, value) or None when the colon is not a mapping colon
+ (e.g. 'https://...' inside a scalar).
+ """
+ head, sep, tail = text.partition(":")
+ if not sep:
+ return None
+ if tail != "" and not tail.startswith(" "):
+ return None
+ return head.strip(), tail.strip()
+
+
+def _mini_yaml_load(text: str):
+ """Minimal stdlib YAML-subset parser for the production workflows.
+
+ Handles nested mappings, '- ' sequences (incl. '- key: value' maps),
+ 'key: |' literal blocks and quoted scalars. Anything else (anchors,
+ flow collections, multi-line quotes) is out of scope on purpose:
+ the workflows under test do not use them.
+ """
+
+ lines = text.splitlines()
+ total = len(lines)
+
+ def parse_node(i: int, indent: int):
+ while i < total and _is_blank_or_comment(lines[i]):
+ i += 1
+ if i >= total or _indent_of(lines[i]) < indent:
+ return None, i
+ stripped = lines[i].lstrip(" ")
+ if stripped.startswith("- ") or stripped == "-":
+ return parse_list(i, indent)
+ return parse_map(i, indent)
+
+ def parse_map(i: int, indent: int):
+ mapping = {}
+ while True:
+ while i < total and _is_blank_or_comment(lines[i]):
+ i += 1
+ if i >= total or _indent_of(lines[i]) != indent:
+ break
+ stripped = lines[i].lstrip(" ")
+ if stripped.startswith("- ") or stripped == "-":
+ break
+ split = _split_key_value(stripped)
+ if split is None:
+ i += 1
+ continue
+ key, rest = split
+ key = _strip_quotes(key)
+ i += 1
+ if rest == "|":
+ block = []
+ while i < total and (
+ lines[i].strip() == "" or _indent_of(lines[i]) > indent
+ ):
+ if lines[i].strip() != "":
+ block.append(lines[i])
+ i += 1
+ if block:
+ dedent = min(_indent_of(line) for line in block)
+ block = [line[dedent:] for line in block]
+ mapping[key] = "\n".join(block) + ("\n" if block else "")
+ elif rest == "":
+ probe = i
+ while probe < total and _is_blank_or_comment(lines[probe]):
+ probe += 1
+ if probe < total and _indent_of(lines[probe]) > indent:
+ child, i = parse_node(probe, _indent_of(lines[probe]))
+ mapping[key] = child
+ else:
+ mapping[key] = None
+ else:
+ mapping[key] = _strip_quotes(rest)
+ return mapping, i
+
+ def parse_list(i: int, indent: int):
+ items = []
+ while True:
+ while i < total and _is_blank_or_comment(lines[i]):
+ i += 1
+ if i >= total or _indent_of(lines[i]) != indent:
+ break
+ stripped = lines[i].lstrip(" ")
+ if not (stripped.startswith("- ") or stripped == "-"):
+ break
+ after = stripped[1:].strip()
+ i += 1
+ if after == "":
+ probe = i
+ while probe < total and _is_blank_or_comment(lines[probe]):
+ probe += 1
+ if probe < total and _indent_of(lines[probe]) > indent:
+ child, i = parse_node(probe, _indent_of(lines[probe]))
+ items.append(child)
+ else:
+ items.append(None)
+ continue
+ split = _split_key_value(after)
+ if split is None:
+ items.append(_strip_quotes(after))
+ continue
+ key, rest = split
+ item = {_strip_quotes(key): _strip_quotes(rest)}
+ probe = i
+ while probe < total and _is_blank_or_comment(lines[probe]):
+ probe += 1
+ if probe < total and _indent_of(lines[probe]) > indent:
+ extra, i = parse_map(probe, _indent_of(lines[probe]))
+ item.update(extra)
+ items.append(item)
+ return items, i
+
+ doc, _ = parse_node(0, 0)
+ return doc
+
+
+def load_workflow(name: str):
+ """Parse a workflow file, preferring yaml.safe_load.
+
+ PyYAML is not in requirements.txt (and therefore not in CI), so fall
+ back to the stdlib subset parser instead of adding a dependency.
+ """
+ text = read_workflow(name)
+ try:
+ import yaml
+ except ImportError:
+ doc = _mini_yaml_load(text)
+ else:
+ doc = yaml.safe_load(text)
+ if isinstance(doc, dict) and "on" not in doc and True in doc:
+ # YAML 1.1 parses the 'on:' key as boolean True.
+ doc["on"] = doc.pop(True)
+ return doc
+
+
+def job_steps(job: dict) -> list:
+ steps = job.get("steps") or []
+ if not isinstance(steps, list):
+ raise AssertionError(f"job steps must be a list, got: {steps!r}")
+ return steps
+
+
+def step_run_texts(steps: list) -> list:
+ return [step.get("run") or "" for step in steps if isinstance(step, dict)]
+
+
+def _normalize_for_parity(value):
+ """Normalize scalar typing so stdlib strings compare with typed YAML.
+
+ _mini_yaml_load keeps every scalar a string while yaml.safe_load
+ coerces ints/bools; structural drift (not scalar typing) is what
+ this parity guard watches, so bools/numbers stringify here.
+ """
+ if isinstance(value, bool):
+ return str(value).lower()
+ if value is None:
+ return None
+ if isinstance(value, (int, float)):
+ return str(value)
+ if isinstance(value, dict):
+ normalized = {}
+ for key, item in value.items():
+ if key is True:
+ key = "on"
+ normalized[str(key)] = _normalize_for_parity(item)
+ return normalized
+ if isinstance(value, list):
+ return [_normalize_for_parity(item) for item in value]
+ return value
+
+
class GitHubWorkflowContractTests(unittest.TestCase):
def test_deploy_workflow_exists_and_targets_main_push(self):
- content = read_workflow("deploy-production.yml")
+ doc = load_workflow("deploy-production.yml")
- self.assertIn("on:", content)
- self.assertIn("push:", content)
- self.assertIn("branches:", content)
- self.assertIn("- main", content)
+ branches = doc["on"]["push"]["branches"]
+ self.assertIn("main", branches)
def test_deploy_workflow_defines_test_build_and_deploy_jobs(self):
- content = read_workflow("deploy-production.yml")
+ doc = load_workflow("deploy-production.yml")
- self.assertIn("jobs:", content)
- self.assertIn("test:", content)
- self.assertIn("build:", content)
- self.assertIn("deploy:", content)
- self.assertIn("environment: production", content)
+ jobs = doc["jobs"]
+ for expected in ("test", "build", "deploy"):
+ self.assertIn(expected, jobs)
+ self.assertEqual("production", jobs["deploy"].get("environment"))
def test_deploy_workflow_emits_and_consumes_an_image_digest(self):
- content = read_workflow("deploy-production.yml")
- service_section = content.split(" - name: Deploy Cloud Run Service", maxsplit=1)[1]
+ doc = load_workflow("deploy-production.yml")
- self.assertIn("image_digest", content)
- self.assertIn("gcloud run jobs update comic-crawler-job", content)
- self.assertIn("gcloud run deploy comic-crawler-service", content)
- self.assertIn("DISCORD_BOT_TOKEN_SECRET_VERSION=${DISCORD_BOT_TOKEN_SECRET_VERSION}", service_section)
- self.assertIn('if [[ "${service_image_ref}" != "${IMAGE_REF}" ]]', content)
- self.assertIn('if [[ "${job_image_ref}" != "${IMAGE_REF}" ]]', content)
+ build_outputs = doc["jobs"]["build"].get("outputs") or {}
+ self.assertIn("image_digest", build_outputs)
+ self.assertIn("image_ref", build_outputs)
+
+ deploy_env = doc["jobs"]["deploy"].get("env") or {}
+ self.assertIn("IMAGE_REF", deploy_env)
+ self.assertIn("IMAGE_DIGEST", deploy_env)
+ self.assertIn("needs.build.outputs.image_ref", str(deploy_env["IMAGE_REF"]))
+ self.assertIn(
+ "needs.build.outputs.image_digest", str(deploy_env["IMAGE_DIGEST"])
+ )
def test_deploy_workflow_verifies_service_and_job_image_refs_from_resource_configs(self):
- content = read_workflow("deploy-production.yml")
+ doc = load_workflow("deploy-production.yml")
- self.assertIn("--format='value(spec.template.spec.containers[0].image)'", content)
- self.assertIn("--format='value(spec.template.spec.template.spec.containers[0].image)'", content)
- self.assertIn('if [[ "${service_image_ref}" != "${IMAGE_REF}" ]]', content)
- self.assertIn('if [[ "${job_image_ref}" != "${IMAGE_REF}" ]]', content)
+ runs = step_run_texts(job_steps(doc["jobs"]["deploy"]))
+ service_runs = [
+ run
+ for run in runs
+ if "value(spec.template.spec.containers[0].image)" in run
+ ]
+ job_runs = [
+ run
+ for run in runs
+ if "value(spec.template.spec.template.spec.containers[0].image)"
+ in run
+ ]
+ self.assertTrue(
+ service_runs, "no deploy step verifies the service image-ref"
+ )
+ self.assertTrue(job_runs, "no deploy step verifies the job image-ref")
+ self.assertTrue(
+ any('if [[ "${service_image_ref}" != "${IMAGE_REF}" ]]' in run for run in runs),
+ "no deploy step guards on the service image-ref mismatch",
+ )
+ self.assertTrue(
+ any('if [[ "${job_image_ref}" != "${IMAGE_REF}" ]]' in run for run in runs),
+ "no deploy step guards on the job image-ref mismatch",
+ )
def test_deploy_workflow_sets_up_buildx_before_using_gha_cache(self):
- content = read_workflow("deploy-production.yml")
-
- build_section = content.split(" deploy:", maxsplit=1)[0]
+ doc = load_workflow("deploy-production.yml")
- self.assertIn("uses: docker/setup-buildx-action@v3", build_section)
- self.assertIn("driver: docker-container", build_section)
- self.assertIn("cache-from: type=gha", build_section)
- self.assertIn("cache-to: type=gha,mode=max", build_section)
- self.assertLess(
- build_section.index("uses: docker/setup-buildx-action@v3"),
- build_section.index("uses: docker/build-push-action@v6"),
+ steps = job_steps(doc["jobs"]["build"])
+ uses = [step.get("uses") or "" for step in steps if isinstance(step, dict)]
+ buildx_index = next(
+ i for i, entry in enumerate(uses) if "docker/setup-buildx-action" in entry
+ )
+ build_index = next(
+ i for i, entry in enumerate(uses) if "docker/build-push-action" in entry
)
+ self.assertLess(buildx_index, build_index)
+
+ buildx_step = steps[buildx_index]
+ self.assertIn("docker-container", str((buildx_step.get("with") or {}).get("driver")))
+
+ build_with = steps[build_index].get("with") or {}
+ self.assertIn("cache-from", build_with)
+ self.assertIn("cache-to", build_with)
+ self.assertIn("type=gha", str(build_with["cache-from"]))
+ self.assertIn("type=gha,mode=max", str(build_with["cache-to"]))
def test_rollback_workflow_exists_and_uses_workflow_dispatch(self):
- content = read_workflow("rollback-production.yml")
+ doc = load_workflow("rollback-production.yml")
- self.assertIn("on:", content)
- self.assertIn("workflow_dispatch:", content)
- self.assertIn("image_ref:", content)
+ inputs = doc["on"]["workflow_dispatch"]["inputs"]
+ self.assertIn("image_ref", inputs)
+ self.assertEqual("string", inputs["image_ref"].get("type"))
def test_rollback_workflow_defines_validate_and_rollback_jobs(self):
- content = read_workflow("rollback-production.yml")
+ doc = load_workflow("rollback-production.yml")
- self.assertIn("jobs:", content)
- self.assertIn("validate:", content)
- self.assertIn("rollback:", content)
- self.assertIn("environment: production", content)
+ jobs = doc["jobs"]
+ for expected in ("validate", "rollback"):
+ self.assertIn(expected, jobs)
+ self.assertEqual("production", jobs["rollback"].get("environment"))
def test_rollback_workflow_validates_input_without_gcp_auth_and_updates_both_resources_after_gate(self):
- content = read_workflow("rollback-production.yml")
- rollback_service_section = content.split(" - name: Deploy Cloud Run Service", maxsplit=1)[1]
+ doc = load_workflow("rollback-production.yml")
- self.assertIn("asia-northeast1-docker.pkg.dev/star-light-breaker/comic-crawler/comic-crawler@sha256:", content)
- self.assertIn("gcloud run jobs update comic-crawler-job", content)
- self.assertIn("gcloud run deploy comic-crawler-service", content)
- self.assertIn(
- "DISCORD_BOT_TOKEN_SECRET_VERSION=${DISCORD_BOT_TOKEN_SECRET_VERSION}",
- rollback_service_section,
+ validate_steps = job_steps(doc["jobs"]["validate"])
+ for step in validate_steps:
+ uses = step.get("uses") or ""
+ name = step.get("name") or ""
+ run = step.get("run") or ""
+ self.assertNotIn("google-github-actions/auth", uses)
+ self.assertNotIn("Authenticate to Google Cloud", name)
+ self.assertNotIn("gcloud artifacts docker images describe", run)
+
+ runs = step_run_texts(job_steps(doc["jobs"]["rollback"]))
+ self.assertTrue(
+ any("gcloud artifacts docker images describe" in run for run in runs),
+ "no rollback step describes the rollback target in Artifact Registry",
+ )
+ self.assertTrue(
+ any("gcloud run jobs update comic-crawler-job" in run for run in runs),
+ "no rollback step updates the Cloud Run Job resource",
)
- validate_section = content.split(" rollback:", maxsplit=1)[0]
- self.assertNotIn("Authenticate to Google Cloud", validate_section)
- self.assertNotIn("gcloud artifacts docker images describe", validate_section)
- self.assertIn("gcloud artifacts docker images describe", content.split(" rollback:", maxsplit=1)[1])
+ self.assertTrue(
+ any("gcloud run deploy comic-crawler-service" in run for run in runs),
+ "no rollback step updates the Cloud Run Service resource",
+ )
+
+ @unittest.skipUnless(_HAS_YAML, "PyYAML not installed; parity needs yaml.safe_load")
+ def test_mini_yaml_load_matches_pyyaml_on_target_keys(self):
+ for name in ("deploy-production.yml", "rollback-production.yml"):
+ text = read_workflow(name)
+ expected = yaml.safe_load(text)
+ if isinstance(expected, dict) and "on" not in expected and True in expected:
+ expected["on"] = expected.pop(True)
+ actual = _mini_yaml_load(text)
+ for key in ("on", "jobs"):
+ self.assertEqual(
+ _normalize_for_parity(actual.get(key)),
+ _normalize_for_parity(expected.get(key)),
+ f"{name}:{key} drift between _mini_yaml_load and yaml.safe_load",
+ )
if __name__ == "__main__":
diff --git a/tests/test_migrate_storage.py b/tests/test_migrate_storage.py
index 9940b97..43be272 100644
--- a/tests/test_migrate_storage.py
+++ b/tests/test_migrate_storage.py
@@ -6,7 +6,7 @@
from manga_watch.firestore_storage import FirestoreStorageConfig, FirestoreStorageRepository
from manga_watch.migrate_storage import migrate_storage
-from tests.test_firestore_storage import FakeFirestoreClient, make_state, make_watchlist
+from tests._firestore_fakes import FakeFirestoreClient, make_state, make_watchlist
class MigrateStorageTests(unittest.TestCase):
diff --git a/tests/test_print_cloud_scheduler_job.py b/tests/test_print_cloud_scheduler_job.py
index 29806b0..75f7386 100644
--- a/tests/test_print_cloud_scheduler_job.py
+++ b/tests/test_print_cloud_scheduler_job.py
@@ -55,17 +55,17 @@ def test_build_run_request_body_sets_scheduled_trigger_override(self):
module.build_run_request_body(),
)
- def test_build_gcloud_command_for_create_requires_schedule(self):
+ def test_build_gcloud_command_for_create_requires_non_empty_schedule(self):
module = load_scheduler_helper_module()
- with self.assertRaisesRegex(ValueError, "schedule"):
- module.build_gcloud_scheduler_http_command(action="create")
-
- def test_build_gcloud_command_for_create_rejects_whitespace_only_schedule(self):
- module = load_scheduler_helper_module()
-
- with self.assertRaisesRegex(ValueError, "schedule"):
- module.build_gcloud_scheduler_http_command(action="create", schedule=" ")
+ cases = {
+ "missing": {},
+ "whitespace_only": {"schedule": " "},
+ }
+ for case, kwargs in cases.items():
+ with self.subTest(case=case):
+ with self.assertRaisesRegex(ValueError, "schedule"):
+ module.build_gcloud_scheduler_http_command(action="create", **kwargs)
def test_build_gcloud_command_for_create_includes_oauth_and_json_body(self):
module = load_scheduler_helper_module()
@@ -127,20 +127,26 @@ def test_script_prints_update_command_with_canonical_defaults(self):
result.stdout,
)
- def test_script_create_requires_schedule_without_traceback(self):
+ def test_script_create_requires_non_empty_schedule_without_traceback(self):
repo_root = Path(__file__).resolve().parents[1]
- result = subprocess.run(
- [sys.executable, "scripts/print_cloud_scheduler_job.py", "create"],
- cwd=repo_root,
- capture_output=True,
- text=True,
- check=False,
- )
-
- self.assertNotEqual(0, result.returncode)
- self.assertIn("--schedule must be a non-empty cron expression", result.stderr)
- self.assertNotIn("Traceback", result.stderr)
+ cases = {
+ "missing": ["create"],
+ "whitespace_only": ["create", "--schedule", " "],
+ }
+ for case, args in cases.items():
+ with self.subTest(case=case):
+ result = subprocess.run(
+ [sys.executable, "scripts/print_cloud_scheduler_job.py", *args],
+ cwd=repo_root,
+ capture_output=True,
+ text=True,
+ check=False,
+ )
+
+ self.assertNotEqual(0, result.returncode)
+ self.assertIn("--schedule must be a non-empty cron expression", result.stderr)
+ self.assertNotIn("Traceback", result.stderr)
def test_build_gcloud_command_for_create_uses_create_headers_flag(self):
module = load_scheduler_helper_module()
@@ -161,21 +167,6 @@ def test_build_gcloud_command_for_update_uses_update_headers_flag(self):
self.assertIn("--update-headers=Content-Type=application/json", command)
self.assertNotIn("--headers=Content-Type=application/json", command)
- def test_script_create_requires_non_empty_schedule_without_traceback(self):
- repo_root = Path(__file__).resolve().parents[1]
-
- result = subprocess.run(
- [sys.executable, "scripts/print_cloud_scheduler_job.py", "create", "--schedule", " "],
- cwd=repo_root,
- capture_output=True,
- text=True,
- check=False,
- )
-
- self.assertNotEqual(0, result.returncode)
- self.assertIn("--schedule must be a non-empty cron expression", result.stderr)
- self.assertNotIn("Traceback", result.stderr)
-
if __name__ == "__main__":
unittest.main()
diff --git a/tests/test_repair_mojibake.py b/tests/test_repair_mojibake.py
index f06547e..46f57e7 100644
--- a/tests/test_repair_mojibake.py
+++ b/tests/test_repair_mojibake.py
@@ -25,23 +25,22 @@ class RepairMojibakeHeuristicTests(unittest.TestCase):
def test_garbled_title_round_trips_to_clean_title(self):
self.assertEqual(repair_mojibake(garbled(CLEAN_TITLE)), CLEAN_TITLE)
- def test_clean_japanese_unchanged(self):
- self.assertEqual(repair_mojibake(CLEAN_TITLE), CLEAN_TITLE)
-
- def test_ascii_url_unchanged(self):
- url = "https://championcross.jp/episodes/939a4960ab2ce"
- self.assertEqual(repair_mojibake(url), url)
-
- def test_accented_european_unchanged(self):
- self.assertEqual(repair_mojibake("café"), "café")
-
- def test_mixed_string_with_clean_japanese_unchanged(self):
- mixed = "新着エピソードを検知しました(2026-08-11)" + garbled(CLEAN_TITLE)
- self.assertEqual(repair_mojibake(mixed), mixed)
-
- def test_non_str_input_unchanged(self):
- self.assertIs(repair_mojibake(None), None)
- self.assertEqual(repair_mojibake(123), 123)
+ def test_noop_inputs_unchanged(self):
+ cases = {
+ "clean_japanese": CLEAN_TITLE,
+ "ascii_url": "https://championcross.jp/episodes/939a4960ab2ce",
+ "accented_european": "café",
+ "mixed_clean_japanese": (
+ "新着エピソードを検知しました(2026-08-11)" + garbled(CLEAN_TITLE)
+ ),
+ }
+ for case, value in cases.items():
+ with self.subTest(case=case):
+ self.assertEqual(repair_mojibake(value), value)
+ with self.subTest(case="non_str_none"):
+ self.assertIs(repair_mojibake(None), None)
+ with self.subTest(case="non_str_int"):
+ self.assertEqual(repair_mojibake(123), 123)
def build_state():
@@ -281,6 +280,86 @@ def test_nonexistent_state_path_returns_exit_1_with_error(self):
self.assertEqual(exit_code, 1)
self.assertIn("[repair_mojibake] error", stderr.getvalue())
+ def test_apply_with_backup_path_creates_backup_matching_original(self):
+ with tempfile.TemporaryDirectory() as tmpdir:
+ state_path = write_state_file(Path(tmpdir), build_state())
+ before = state_path.read_bytes()
+ backup_path = Path(tmpdir) / "state.backup.json"
+
+ stdout = io.StringIO()
+ stderr = io.StringIO()
+ exit_code = main(
+ argv=["--state", str(state_path), "--backup-path", str(backup_path)],
+ stdout=stdout,
+ stderr=stderr,
+ )
+
+ self.assertEqual(exit_code, 0)
+ self.assertEqual(stderr.getvalue(), "")
+ self.assertTrue(backup_path.exists())
+ self.assertEqual(backup_path.read_bytes(), before)
+ saved = json.loads(state_path.read_text(encoding="utf-8"))
+ work_id = "champion-cross:6504eab816435"
+ self.assertEqual(saved["works"][work_id]["latest"]["series_title"], CLEAN_TITLE)
+
+ def test_apply_without_backup_path_creates_no_backup(self):
+ with tempfile.TemporaryDirectory() as tmpdir:
+ state_path = write_state_file(Path(tmpdir), build_state())
+ backup_path = Path(tmpdir) / "state.backup.json"
+
+ stdout = io.StringIO()
+ stderr = io.StringIO()
+ exit_code = main(
+ argv=["--state", str(state_path)],
+ stdout=stdout,
+ stderr=stderr,
+ )
+
+ self.assertEqual(exit_code, 0)
+ self.assertEqual(stderr.getvalue(), "")
+ self.assertFalse(backup_path.exists())
+
+ def test_dry_run_with_backup_path_does_not_copy(self):
+ with tempfile.TemporaryDirectory() as tmpdir:
+ state_path = write_state_file(Path(tmpdir), build_state())
+ before = state_path.read_bytes()
+ backup_path = Path(tmpdir) / "state.backup.json"
+
+ stdout = io.StringIO()
+ stderr = io.StringIO()
+ exit_code = main(
+ argv=[
+ "--state",
+ str(state_path),
+ "--dry-run",
+ "--backup-path",
+ str(backup_path),
+ ],
+ stdout=stdout,
+ stderr=stderr,
+ )
+
+ self.assertEqual(exit_code, 0)
+ self.assertEqual(stderr.getvalue(), "")
+ self.assertFalse(backup_path.exists())
+ self.assertEqual(state_path.read_bytes(), before)
+
+ def test_apply_with_unwritable_backup_path_returns_exit_1(self):
+ with tempfile.TemporaryDirectory() as tmpdir:
+ state_path = write_state_file(Path(tmpdir), build_state())
+ backup_path = Path(tmpdir) / "missing-dir" / "state.backup.json"
+
+ stdout = io.StringIO()
+ stderr = io.StringIO()
+ exit_code = main(
+ argv=["--state", str(state_path), "--backup-path", str(backup_path)],
+ stdout=stdout,
+ stderr=stderr,
+ )
+
+ self.assertEqual(exit_code, 1)
+ self.assertIn("[repair_mojibake] error", stderr.getvalue())
+
if __name__ == "__main__":
unittest.main()
diff --git a/tests/test_run_service.py b/tests/test_run_service.py
index e3b783e..d311730 100644
--- a/tests/test_run_service.py
+++ b/tests/test_run_service.py
@@ -65,56 +65,49 @@ def test_build_http_response_returns_ok_for_healthz_get(self):
self.assertEqual("text/plain; charset=utf-8", response.content_type)
self.assertEqual([], service.calls)
- def test_build_http_response_delegates_non_health_requests_to_interaction_service(self):
- service = FakeInteractionService()
-
- response = run_service.build_http_response(
- service,
- method="POST",
- path="/",
- headers={"X-Test": "1"},
- body=b'{"type":1}',
- )
-
- self.assertEqual(200, response.status_code)
- self.assertEqual(b"delegated", response.body)
- self.assertEqual(
- [
- {
- "method": "POST",
- "path": "/",
- "headers": {"X-Test": "1"},
- "body": b'{"type":1}',
- }
- ],
- service.calls,
- )
-
- def test_build_http_response_delegates_when_health_path_matches_interaction_path(self):
- service = FakeInteractionService()
- service.interaction_path = "/healthz"
-
- response = run_service.build_http_response(
- service,
- method="GET",
- path="/healthz",
- headers={"X-Test": "1"},
- body=b"",
- )
-
- self.assertEqual(200, response.status_code)
- self.assertEqual(b"delegated", response.body)
- self.assertEqual(
- [
- {
- "method": "GET",
- "path": "/healthz",
- "headers": {"X-Test": "1"},
- "body": b"",
- }
- ],
- service.calls,
- )
+ def test_build_http_response_delegates_non_health_requests(self):
+ cases = {
+ "post_to_root": {
+ "interaction_path": "/",
+ "method": "POST",
+ "path": "/",
+ "headers": {"X-Test": "1"},
+ "body": b'{"type":1}',
+ },
+ "health_path_matches_interaction_path": {
+ "interaction_path": "/healthz",
+ "method": "GET",
+ "path": "/healthz",
+ "headers": {"X-Test": "1"},
+ "body": b"",
+ },
+ }
+ for case, request in cases.items():
+ with self.subTest(case=case):
+ service = FakeInteractionService()
+ service.interaction_path = request["interaction_path"]
+
+ response = run_service.build_http_response(
+ service,
+ method=request["method"],
+ path=request["path"],
+ headers=request["headers"],
+ body=request["body"],
+ )
+
+ self.assertEqual(200, response.status_code)
+ self.assertEqual(b"delegated", response.body)
+ self.assertEqual(
+ [
+ {
+ "method": request["method"],
+ "path": request["path"],
+ "headers": request["headers"],
+ "body": request["body"],
+ }
+ ],
+ service.calls,
+ )
def test_main_boots_with_cloud_run_job_backend_minimal_env(self):
public_key = SigningKey.generate().verify_key.encode().hex()
diff --git a/tests/test_runner.py b/tests/test_runner.py
index b5b0453..5119860 100644
--- a/tests/test_runner.py
+++ b/tests/test_runner.py
@@ -146,26 +146,6 @@ def test_runner_module_runs_until_config_validation(self):
self.assertIn("[runner] configuration error:", result.stderr)
self.assertIn("MANGA_WATCH_NOTIFIER_BACKENDS", result.stderr)
- def test_run_once_records_run_summary_when_recorder_is_injected(self):
- recorded = []
-
- def checker(_watchlist_path):
- return {"updates": [], "errors": {"sources": [], "run": []}}
-
- outcome = run_once(
- self.make_config(),
- checker=checker,
- state_loader=lambda: {"version": 2, "works": {}, "last_run_at": None, "notification_outbox": [], "discord_delivery": {"daily_notification": {"delivered_latest_keys": {}, "pending_messages": []}}},
- state_saver=lambda _state: None,
- run_recorder=lambda summary: recorded.append(dict(summary)) or "run-1",
- report_logger=lambda _message: None,
- error_logger=lambda _message: None,
- )
-
- self.assertEqual("run-1", outcome["runId"])
- self.assertEqual(1, len(recorded))
- self.assertTrue(recorded[0]["ok"])
-
def make_config(self, *, with_discord=False):
return RunnerConfig(
timezone_name="Asia/Tokyo",
@@ -352,27 +332,30 @@ def test_build_notifier_fans_out_to_all_configured_backends(self):
self.assertEqual(1, len(session.calls))
self.assertEqual(event.as_payload(), session.calls[0]["json"])
- def test_webhook_notifier_raises_on_non_2xx(self):
- event = build_update_event(
- self.make_update(latest_key="episode-2"),
- detected_at="2023-11-14T22:13:20Z",
- )
- session = FakeSession(responses=[FakeResponse(500, "server exploded")])
- notifier = WebhookNotifier("https://example.com/hook", session=session)
-
- with self.assertRaisesRegex(RuntimeError, "Webhook returned HTTP 500"):
- notifier.send(event)
+ def test_webhook_notifier_raises_on_delivery_failure(self):
+ cases = [
+ (
+ "non_2xx",
+ FakeSession(responses=[FakeResponse(500, "server exploded")]),
+ "Webhook returned HTTP 500",
+ ),
+ (
+ "transport_error",
+ FakeSession(error=requests.Timeout("timed out")),
+ "Webhook delivery failed",
+ ),
+ ]
- def test_webhook_notifier_raises_on_transport_error(self):
- event = build_update_event(
- self.make_update(latest_key="episode-2"),
- detected_at="2023-11-14T22:13:20Z",
- )
- session = FakeSession(error=requests.Timeout("timed out"))
- notifier = WebhookNotifier("https://example.com/hook", session=session)
+ for case, session, expected_message in cases:
+ with self.subTest(case=case):
+ event = build_update_event(
+ self.make_update(latest_key="episode-2"),
+ detected_at="2023-11-14T22:13:20Z",
+ )
+ notifier = WebhookNotifier("https://example.com/hook", session=session)
- with self.assertRaisesRegex(RuntimeError, "Webhook delivery failed"):
- notifier.send(event)
+ with self.assertRaisesRegex(RuntimeError, expected_message):
+ notifier.send(event)
def test_webhook_notifier_masks_webhook_url_in_transport_error(self):
webhook_url = "https://discord.com/api/webhooks/123/secret"
@@ -458,15 +441,19 @@ def test_run_once_records_run_summary(self):
checker=lambda _: {"updates": [self.make_update()]},
state_loader=self.make_state,
state_saver=lambda _: None,
- run_recorder=recorded.append,
+ run_recorder=lambda summary: recorded.append(dict(summary)) or "run-1",
now_fn=lambda: 1_700_000_000,
report_logger=lambda _: None,
error_logger=lambda _: self.fail("unexpected error log"),
)
self.assertTrue(outcome["ok"])
+ self.assertEqual("run-1", outcome["runId"])
self.assertEqual(1, len(recorded))
- self.assertEqual(outcome, recorded[0])
+ self.assertTrue(recorded[0]["ok"])
+ expected_recorded = dict(outcome)
+ expected_recorded.pop("runId")
+ self.assertEqual(expected_recorded, recorded[0])
def test_run_once_reports_failure_when_run_recorder_raises(self):
reports = []
@@ -666,35 +653,6 @@ def test_run_once_logs_failure_report_when_notifier_fails(self):
self.assertIn("巡回実行に失敗しました", errors[0])
self.assertIn("notifier backend failed", errors[0])
- def test_run_once_continues_delivering_valid_updates_when_one_payload_is_invalid(self):
- notifier = FakeNotifier()
- errors = []
- invalid_update = self.make_update()
- invalid_update["to"] = {
- "series_title": "作品A",
- "episode_title": "第2話",
- "update_type": "main_story",
- "default_notify": True,
- }
-
- outcome = run_once(
- self.make_config(),
- notifier=notifier,
- checker=lambda _: {"updates": [invalid_update, self.make_update(latest_key="episode-3")]},
- state_loader=self.make_state,
- state_saver=lambda _: None,
- now_fn=lambda: 1_700_000_000,
- report_logger=lambda _: self.fail("unexpected report log"),
- error_logger=errors.append,
- )
-
- self.assertFalse(outcome["ok"])
- self.assertEqual(2, outcome["notifiedUpdateCount"])
- self.assertEqual(1, len(notifier.events))
- self.assertEqual("episode-3", notifier.events[0].latest_key)
- self.assertEqual(1, len(errors))
- self.assertIn("work-1: update event work-1 is missing latest_key", errors[0])
-
def test_generic_notification_phase_continues_delivering_valid_updates_when_one_payload_is_invalid(self):
notifier = FakeNotifier()
errors = {"sources": [], "run": []}
@@ -1131,49 +1089,15 @@ def checker(_):
self.assertEqual(1, len(reports))
self.assertEqual([], notifier.events)
- def test_run_coordinator_queues_startup_while_fetch_is_in_progress(self):
- checker_started = threading.Event()
- allow_finish = threading.Event()
- reports = []
- call_count = {"value": 0}
-
- def checker(_):
- call_count["value"] += 1
- if call_count["value"] == 1:
- checker_started.set()
- allow_finish.wait(1.0)
- return {"updates": []}
-
- coordinator = RunCoordinator(
- self.make_config(),
- notifier=FakeNotifier(),
- checker=checker,
- state_loader=self.make_state,
- state_saver=lambda _: None,
- now_fn=lambda: 1_700_000_000,
- report_logger=reports.append,
- error_logger=lambda _: self.fail("unexpected error log"),
- )
-
- fetch_outcome = start_fetch_run(coordinator)
- self.assertTrue(fetch_outcome["accepted"])
- self.assertTrue(checker_started.wait(0.5))
-
- startup_outcome = coordinator.run(TRIGGER_SOURCE_STARTUP)
-
- self.assertTrue(startup_outcome["ok"])
- self.assertTrue(startup_outcome["accepted"])
- self.assertTrue(startup_outcome["queued"])
- self.assertTrue(startup_outcome["serialized"])
- self.assertEqual(TRIGGER_SOURCE_STARTUP, startup_outcome["triggerSource"])
-
- allow_finish.set()
- self.wait_until(lambda: not coordinator.is_running())
- self.assertEqual(2, call_count["value"])
- self.assertEqual(2, len(reports))
- self.assertIn("トリガー: startup", reports[1])
+ def test_run_coordinator_queues_second_trigger_while_fetch_is_in_progress(self):
+ for trigger_source, trigger_label in (
+ (TRIGGER_SOURCE_STARTUP, "startup"),
+ (TRIGGER_SOURCE_SCHEDULED, "scheduled"),
+ ):
+ with self.subTest(trigger_source=trigger_source):
+ self._assert_run_coordinator_queues_second_trigger(trigger_source, trigger_label)
- def test_run_coordinator_queues_scheduled_while_fetch_is_in_progress(self):
+ def _assert_run_coordinator_queues_second_trigger(self, trigger_source, trigger_label):
checker_started = threading.Event()
allow_finish = threading.Event()
reports = []
@@ -1201,19 +1125,19 @@ def checker(_):
self.assertTrue(fetch_outcome["accepted"])
self.assertTrue(checker_started.wait(0.5))
- scheduled_outcome = coordinator.run(TRIGGER_SOURCE_SCHEDULED)
+ second_outcome = coordinator.run(trigger_source)
- self.assertTrue(scheduled_outcome["ok"])
- self.assertTrue(scheduled_outcome["accepted"])
- self.assertTrue(scheduled_outcome["queued"])
- self.assertTrue(scheduled_outcome["serialized"])
- self.assertEqual(TRIGGER_SOURCE_SCHEDULED, scheduled_outcome["triggerSource"])
+ self.assertTrue(second_outcome["ok"])
+ self.assertTrue(second_outcome["accepted"])
+ self.assertTrue(second_outcome["queued"])
+ self.assertTrue(second_outcome["serialized"])
+ self.assertEqual(trigger_source, second_outcome["triggerSource"])
allow_finish.set()
self.wait_until(lambda: not coordinator.is_running())
self.assertEqual(2, call_count["value"])
self.assertEqual(2, len(reports))
- self.assertIn("トリガー: scheduled", reports[1])
+ self.assertIn(f"トリガー: {trigger_label}", reports[1])
def test_handle_fetch_trigger_accepts_again_after_failure(self):
call_count = {"value": 0}
diff --git a/tests/test_source_drift.py b/tests/test_source_drift.py
index 566b0b4..8838e04 100644
--- a/tests/test_source_drift.py
+++ b/tests/test_source_drift.py
@@ -98,87 +98,64 @@ def test_broken_fixture_reports_drift_with_refresh_hint(self):
result.next_action,
)
- def test_piccoma_canary_requires_ld_json_product_name(self):
- manifest, client = load_fixture_http_client("piccoma", "normal")
- product_without_ld_json = (FIXTURES_ROOT / "piccoma" / "normal" / "01-product.html").read_text(
+ def test_piccoma_canary_rejects_mutated_product_signals(self):
+ product_html = (FIXTURES_ROOT / "piccoma" / "normal" / "01-product.html").read_text(
encoding="utf-8"
)
- product_without_ld_json = product_without_ld_json.replace(
- """
+ mutations = (
+ (
+ "missing_ld_json",
+ 0,
+ product_html.replace(
+ """
""",
- "",
- )
- client.steps[0] = {
- "url": manifest["steps"][0]["url"],
- "body": product_without_ld_json,
- }
- contract = DEFAULT_SOURCE_CANARY_CONTRACTS["piccoma"]
- contract = contract.__class__(
- source=contract.source,
- seed_url=manifest["seedUrl"],
- fixture_bundle=contract.fixture_bundle,
- monitored_signals=contract.monitored_signals,
- )
-
- result = run_source_canary(contract, http_client=client)
-
- self.assertEqual("drift", result.status)
- self.assertEqual("SourceParseError", result.error_type)
- self.assertIn("LD JSON Product.name not found", result.message)
-
- def test_piccoma_canary_rejects_non_product_ld_json_name(self):
- manifest, client = load_fixture_http_client("piccoma", "normal")
- product_with_non_product_ld_json = (FIXTURES_ROOT / "piccoma" / "normal" / "01-product.html").read_text(
- encoding="utf-8"
- )
- product_with_non_product_ld_json = product_with_non_product_ld_json.replace(
- '{"@type":"Product","name":"九条の大罪"}',
- '{"@type":"BreadcrumbList","name":"九条の大罪"}',
- )
- client.steps[0] = {
- "url": manifest["steps"][0]["url"],
- "body": product_with_non_product_ld_json,
- }
- contract = DEFAULT_SOURCE_CANARY_CONTRACTS["piccoma"]
- contract = contract.__class__(
- source=contract.source,
- seed_url=manifest["seedUrl"],
- fixture_bundle=contract.fixture_bundle,
- monitored_signals=contract.monitored_signals,
- )
-
- result = run_source_canary(contract, http_client=client)
-
- self.assertEqual("drift", result.status)
- self.assertEqual("SourceParseError", result.error_type)
- self.assertIn("LD JSON Product.name not found", result.message)
-
- def test_piccoma_canary_requires_episode_list_latest_identifier(self):
- manifest, client = load_fixture_http_client("piccoma", "normal")
- episodes_without_list = """
+ "",
+ ),
+ "LD JSON Product.name not found",
+ ),
+ (
+ "non_product_ld_json",
+ 0,
+ product_html.replace(
+ '{"@type":"Product","name":"九条の大罪"}',
+ '{"@type":"BreadcrumbList","name":"九条の大罪"}',
+ ),
+ "LD JSON Product.name not found",
+ ),
+ (
+ "missing_episode_list",
+ 1,
+ """
- """
- client.steps[1] = {
- "url": manifest["steps"][1]["url"],
- "body": episodes_without_list,
- }
- contract = DEFAULT_SOURCE_CANARY_CONTRACTS["piccoma"]
- contract = contract.__class__(
- source=contract.source,
- seed_url=manifest["seedUrl"],
- fixture_bundle=contract.fixture_bundle,
- monitored_signals=contract.monitored_signals,
+ """,
+ "#js_episodeList latest episode identifier not found",
+ ),
)
- result = run_source_canary(contract, http_client=client)
+ for mutation, step_index, body, expected_message in mutations:
+ with self.subTest(mutation=mutation):
+ manifest, client = load_fixture_http_client("piccoma", "normal")
+ client.steps[step_index] = {
+ "url": manifest["steps"][step_index]["url"],
+ "body": body,
+ }
+ contract = DEFAULT_SOURCE_CANARY_CONTRACTS["piccoma"]
+ contract = contract.__class__(
+ source=contract.source,
+ seed_url=manifest["seedUrl"],
+ fixture_bundle=contract.fixture_bundle,
+ monitored_signals=contract.monitored_signals,
+ )
- self.assertEqual("drift", result.status)
- self.assertEqual("SourceParseError", result.error_type)
- self.assertIn("#js_episodeList latest episode identifier not found", result.message)
+ result = run_source_canary(contract, http_client=client)
+
+ self.assertEqual("drift", result.status)
+ self.assertEqual("SourceParseError", result.error_type)
+ self.assertIn(expected_message, result.message)
def test_main_returns_non_zero_when_a_canary_fails(self):
failing_result = SourceCanaryResult(
diff --git a/tests/test_source_search.py b/tests/test_source_search.py
index c619d4b..c7cd500 100644
--- a/tests/test_source_search.py
+++ b/tests/test_source_search.py
@@ -21,64 +21,61 @@ def get_text(self, url: str) -> str:
class SourceSearchTests(unittest.TestCase):
- def test_search_source_parses_takecomic_results_and_strips_update_label(self):
- query = "異世界の常識は難しい"
- request_url = f"https://takecomic.jp/search?keyword={quote_plus(query)}"
- html = (
- Path(__file__).parent
- / "fixtures"
- / "source_search"
- / "takecomic_search_update_label.html"
- ).read_text(encoding="utf-8")
-
- results = search_source(
- "takecomic",
- query,
- http_client=StaticHttpClient({request_url: html}),
- )
+ def test_search_source_parses_takecomic_results(self):
+ cases = (
+ {
+ "branch": "strips_update_label",
+ "query": "異世界の常識は難しい",
+ "html": (
+ Path(__file__).parent
+ / "fixtures"
+ / "source_search"
+ / "takecomic_search_update_label.html"
+ ).read_text(encoding="utf-8"),
+ "expected": [
+ SearchResult(
+ source="takecomic",
+ title="異世界の常識は難しい~希少で最弱な人族に転生したけど物理以外で最強になりそうです~",
+ seed_url="https://takecomic.jp/series/bb237f85f48a3",
+ subtitle="takecomic",
+ )
+ ],
+ },
+ {
+ "branch": "badge_only_fallback",
+ "query": "takecomic badge only",
+ "html": """
+
+
+
+
+
+ """,
+ "expected": [
+ SearchResult(
+ source="takecomic",
+ title="https://takecomic.jp/series/bb237f85f48a3",
+ seed_url="https://takecomic.jp/series/bb237f85f48a3",
+ subtitle="takecomic",
+ )
+ ],
+ },
+ )
+
+ for entry in cases:
+ with self.subTest(branch=entry["branch"]):
+ query = entry["query"]
+ request_url = f"https://takecomic.jp/search?keyword={quote_plus(query)}"
- self.assertEqual(
- [
- SearchResult(
- source="takecomic",
- title="異世界の常識は難しい~希少で最弱な人族に転生したけど物理以外で最強になりそうです~",
- seed_url="https://takecomic.jp/series/bb237f85f48a3",
- subtitle="takecomic",
+ results = search_source(
+ "takecomic",
+ query,
+ http_client=StaticHttpClient({request_url: entry["html"]}),
)
- ],
- results,
- )
- def test_search_source_falls_back_to_canonical_url_when_takecomic_title_is_only_badge(self):
- query = "takecomic badge only"
- request_url = f"https://takecomic.jp/search?keyword={quote_plus(query)}"
- html = """
-
-
-
-
-
- """
-
- results = search_source(
- "takecomic",
- query,
- http_client=StaticHttpClient({request_url: html}),
- )
-
- self.assertEqual(
- [
- SearchResult(
- source="takecomic",
- title="https://takecomic.jp/series/bb237f85f48a3",
- seed_url="https://takecomic.jp/series/bb237f85f48a3",
- subtitle="takecomic",
- )
- ],
- results,
- )
+ self.assertEqual(entry["expected"], results)
def test_supported_search_sources_match_registered_sources(self):
self.assertEqual(
@@ -226,103 +223,97 @@ def test_search_source_prefers_champion_cross_series_card_over_noise_links(self)
results,
)
- def test_search_source_parses_comic_action_results_via_q_parameter(self):
- html = """
-
-
-
-
-
- """
-
- results = search_source(
- "comic-action",
- "ダンジョンの中のひと",
- http_client=StaticHttpClient(
- {
- "https://comic-action.com/search?q=%E3%83%80%E3%83%B3%E3%82%B8%E3%83%A7%E3%83%B3%E3%81%AE%E4%B8%AD%E3%81%AE%E3%81%B2%E3%81%A8": html
- }
- ),
- )
-
- self.assertEqual(
- [
- SearchResult(
- source="comic-action",
- title="ダンジョンの中のひと",
- seed_url="https://comic-action.com/rss/series/13933686331663374228",
- subtitle="comic-action",
+ def test_search_source_parses_comic_action_results(self):
+ cases = (
+ {
+ "branch": "series_thumbnail_seed",
+ "html": """
+
+
+
+
+
+ """,
+ "expected": [
+ SearchResult(
+ source="comic-action",
+ title="ダンジョンの中のひと",
+ seed_url="https://comic-action.com/rss/series/13933686331663374228",
+ subtitle="comic-action",
+ )
+ ],
+ },
+ {
+ "branch": "reordered_attributes_latest_link",
+ "html": """
+
+
+
+
+
+ """,
+ "expected": [
+ SearchResult(
+ source="comic-action",
+ title="ダンジョンの中のひと",
+ seed_url="https://comic-action.com/episode/13933686331677886179",
+ subtitle="comic-action",
+ )
+ ],
+ },
+ )
+
+ for entry in cases:
+ with self.subTest(branch=entry["branch"]):
+ results = search_source(
+ "comic-action",
+ "ダンジョンの中のひと",
+ http_client=StaticHttpClient(
+ {
+ "https://comic-action.com/search?q=%E3%83%80%E3%83%B3%E3%82%B8%E3%83%A7%E3%83%B3%E3%81%AE%E4%B8%AD%E3%81%AE%E3%81%B2%E3%81%A8": entry[
+ "html"
+ ]
+ }
+ ),
)
- ],
- results,
- )
- def test_search_source_parses_comic_action_latest_link_when_attributes_are_reordered(self):
- html = """
-
-
-
-
-
- """
-
- results = search_source(
- "comic-action",
- "ダンジョンの中のひと",
- http_client=StaticHttpClient(
- {
- "https://comic-action.com/search?q=%E3%83%80%E3%83%B3%E3%82%B8%E3%83%A7%E3%83%B3%E3%81%AE%E4%B8%AD%E3%81%AE%E3%81%B2%E3%81%A8": html
- }
- ),
- )
-
- self.assertEqual(
- [
- SearchResult(
- source="comic-action",
- title="ダンジョンの中のひと",
- seed_url="https://comic-action.com/episode/13933686331677886179",
- subtitle="comic-action",
- )
- ],
- results,
- )
+ self.assertEqual(entry["expected"], results)
def test_search_source_parses_comic_earthstar_results_via_q_parameter(self):
html = """
@@ -496,216 +487,162 @@ def test_search_source_percent_encodes_magapoke_queries_with_spaces(self):
)
def test_search_source_parses_gaugau_results(self):
- html = """
-
-
-
-
-
- """
-
- results = search_source(
- "gaugau",
- "ダンジョンの中のひと",
- http_client=StaticHttpClient(
- {
- "https://gaugau.futabanet.jp/list/search-result?word=%E3%83%80%E3%83%B3%E3%82%B8%E3%83%A7%E3%83%B3%E3%81%AE%E4%B8%AD%E3%81%AE%E3%81%B2%E3%81%A8": html
- }
- ),
- )
-
- self.assertEqual(
- [
- SearchResult(
- source="gaugau",
- title="ダンジョンの中のひと",
- seed_url="https://gaugau.futabanet.jp/list/work/600a5fd37765610d30010000",
- subtitle="gaugau",
+ cases = (
+ {
+ "branch": "heading_over_empty_thumbnail",
+ "html": """
+
+
+
+
+
+ """,
+ },
+ {
+ "branch": "heading_over_badge_inside_thumbnail_anchor",
+ "html": """
+
+
+
+
+
+ """,
+ },
+ )
+
+ for entry in cases:
+ with self.subTest(branch=entry["branch"]):
+ results = search_source(
+ "gaugau",
+ "ダンジョンの中のひと",
+ http_client=StaticHttpClient(
+ {
+ "https://gaugau.futabanet.jp/list/search-result?word=%E3%83%80%E3%83%B3%E3%82%B8%E3%83%A7%E3%83%B3%E3%81%AE%E4%B8%AD%E3%81%AE%E3%81%B2%E3%81%A8": entry[
+ "html"
+ ]
+ }
+ ),
)
- ],
- results,
- )
- def test_search_source_parses_bookwalker_series_results(self):
- html = """
-
-
-
-
-
- """
-
- results = search_source(
- "bookwalker",
- "くまぐらし",
- http_client=StaticHttpClient(
- {"https://bookwalker.jp/search/?word=%E3%81%8F%E3%81%BE%E3%81%90%E3%82%89%E3%81%97&order=score": html}
- ),
- )
-
- self.assertEqual(
- [
- SearchResult(
- source="bookwalker",
- title="くまぐらし(MANGAバル コミックス)",
- seed_url="https://bookwalker.jp/series/519222/list/",
- subtitle="bookwalker",
+ self.assertEqual(
+ [
+ SearchResult(
+ source="gaugau",
+ title="ダンジョンの中のひと",
+ seed_url="https://gaugau.futabanet.jp/list/work/600a5fd37765610d30010000",
+ subtitle="gaugau",
+ )
+ ],
+ results,
)
- ],
- results,
- )
-
- def test_search_source_uses_bookwalker_image_alt_when_title_anchor_is_absent(self):
- html = """
-
-
-
- -
-
-
![くまぐらし(MANGAバル コミックス)]()
-
-
-
-
-
-
- """
-
- results = search_source(
- "bookwalker",
- "くまぐらし",
- http_client=StaticHttpClient(
- {"https://bookwalker.jp/search/?word=%E3%81%8F%E3%81%BE%E3%81%90%E3%82%89%E3%81%97&order=score": html}
- ),
- )
- self.assertEqual(
- [
- SearchResult(
- source="bookwalker",
- title="くまぐらし(MANGAバル コミックス)",
- seed_url="https://bookwalker.jp/series/519222/list/",
- subtitle="bookwalker",
+ def test_search_source_parses_bookwalker_series_results(self):
+ cases = (
+ {
+ "branch": "title_anchor",
+ "html": """
+
+
+
+
+
+ """,
+ },
+ {
+ "branch": "image_alt_fallback",
+ "html": """
+
+
+
+ -
+
+
![くまぐらし(MANGAバル コミックス)]()
+
+
+
+
+
+
+ """,
+ },
+ )
+
+ for entry in cases:
+ with self.subTest(branch=entry["branch"]):
+ results = search_source(
+ "bookwalker",
+ "くまぐらし",
+ http_client=StaticHttpClient(
+ {"https://bookwalker.jp/search/?word=%E3%81%8F%E3%81%BE%E3%81%90%E3%82%89%E3%81%97&order=score": entry["html"]}
+ ),
)
- ],
- results,
- )
-
- def test_search_source_prefers_gaugau_series_heading_over_badge_inside_thumbnail_anchor(self):
- html = """
-
-
-
-
-
- """
-
- results = search_source(
- "gaugau",
- "ダンジョンの中のひと",
- http_client=StaticHttpClient(
- {
- "https://gaugau.futabanet.jp/list/search-result?word=%E3%83%80%E3%83%B3%E3%82%B8%E3%83%A7%E3%83%B3%E3%81%AE%E4%B8%AD%E3%81%AE%E3%81%B2%E3%81%A8": html
- }
- ),
- )
- self.assertEqual(
- [
- SearchResult(
- source="gaugau",
- title="ダンジョンの中のひと",
- seed_url="https://gaugau.futabanet.jp/list/work/600a5fd37765610d30010000",
- subtitle="gaugau",
+ self.assertEqual(
+ [
+ SearchResult(
+ source="bookwalker",
+ title="くまぐらし(MANGAバル コミックス)",
+ seed_url="https://bookwalker.jp/series/519222/list/",
+ subtitle="bookwalker",
+ )
+ ],
+ results,
)
- ],
- results,
- )
- def test_search_source_parses_piccoma_json_results(self):
+ def test_search_source_parses_piccoma_results(self):
query = "九条の大罪"
request_url = (
"https://piccoma.com/web/search/result_ajax/list"
f"?tab_type=T&word={quote_plus(query)}&page=1"
)
- json_text = (FIXTURES_ROOT / "piccoma" / "01-search.json").read_text(encoding="utf-8")
-
- results = search_source(
- "piccoma",
- query,
- http_client=StaticHttpClient({request_url: json_text}),
- )
- self.assertEqual(
- [
- SearchResult(
- source="piccoma",
- title="九条の大罪",
- seed_url="https://piccoma.com/web/product/58170?etype=episode",
- subtitle="piccoma",
- ),
- SearchResult(
- source="piccoma",
- title="闇金ウシジマくん",
- seed_url="https://piccoma.com/web/product/12345?etype=episode",
- subtitle="piccoma",
- ),
- ],
- results,
- )
-
- def test_search_source_piccoma_ignores_malformed_rows(self):
- query = "九条の大罪"
- request_url = (
- "https://piccoma.com/web/search/result_ajax/list"
- f"?tab_type=T&word={quote_plus(query)}&page=1"
- )
- json_text = """
+ valid_json = (FIXTURES_ROOT / "piccoma" / "01-search.json").read_text(encoding="utf-8")
+ malformed_rows_json = """
{
"status": 0,
"products": [
@@ -719,47 +656,68 @@ def test_search_source_piccoma_ignores_malformed_rows(self):
]
}
"""
-
- results = search_source(
- "piccoma",
- query,
- http_client=StaticHttpClient({request_url: json_text}),
- )
-
- self.assertEqual(
- [
- SearchResult(
- source="piccoma",
- title="九条の大罪",
- seed_url="https://piccoma.com/web/product/58170?etype=episode",
- subtitle="piccoma",
- )
- ],
- results,
- )
-
- def test_search_source_piccoma_returns_empty_for_empty_or_malformed_products(self):
- query = "九条の大罪"
- request_url = (
- "https://piccoma.com/web/search/result_ajax/list"
- f"?tab_type=T&word={quote_plus(query)}&page=1"
- )
-
- for json_text in (
+ empty_variants = (
'{"status": 0, "products": []}',
'{"status": 0, "products": {}}',
'{"status": 1, "products": [{"id": 58170, "title": "九条の大罪"}]}',
'{"status": 0}',
'not json',
- ):
- with self.subTest(json_text=json_text):
- results = search_source(
- "piccoma",
- query,
- http_client=StaticHttpClient({request_url: json_text}),
- )
+ )
- self.assertEqual([], results)
+ with self.subTest(branch="valid_results"):
+ results = search_source(
+ "piccoma",
+ query,
+ http_client=StaticHttpClient({request_url: valid_json}),
+ )
+
+ self.assertEqual(
+ [
+ SearchResult(
+ source="piccoma",
+ title="九条の大罪",
+ seed_url="https://piccoma.com/web/product/58170?etype=episode",
+ subtitle="piccoma",
+ ),
+ SearchResult(
+ source="piccoma",
+ title="闇金ウシジマくん",
+ seed_url="https://piccoma.com/web/product/12345?etype=episode",
+ subtitle="piccoma",
+ ),
+ ],
+ results,
+ )
+
+ with self.subTest(branch="malformed_rows"):
+ results = search_source(
+ "piccoma",
+ query,
+ http_client=StaticHttpClient({request_url: malformed_rows_json}),
+ )
+
+ self.assertEqual(
+ [
+ SearchResult(
+ source="piccoma",
+ title="九条の大罪",
+ seed_url="https://piccoma.com/web/product/58170?etype=episode",
+ subtitle="piccoma",
+ )
+ ],
+ results,
+ )
+
+ with self.subTest(branch="empty_or_malformed"):
+ for json_text in empty_variants:
+ with self.subTest(json_text=json_text):
+ results = search_source(
+ "piccoma",
+ query,
+ http_client=StaticHttpClient({request_url: json_text}),
+ )
+
+ self.assertEqual([], results)
def test_search_source_falls_back_to_comicborder_homepage_results_when_search_errors(self):
homepage_html = """
diff --git a/tests/test_sources.py b/tests/test_sources.py
index 197b9c7..30f8050 100644
--- a/tests/test_sources.py
+++ b/tests/test_sources.py
@@ -11,14 +11,12 @@
REGISTERED_ADAPTERS,
REGISTERED_SOURCES,
SourceAdapter,
- fetch_latest_for_work,
normalize_seed_url,
)
from manga_watch.sources.bookwalker import BookwalkerAdapter
from manga_watch.sources.base import SourceParseError, WorkDescriptor
from manga_watch.sources.champion_cross import ChampionCrossAdapter
from manga_watch.sources.comic_action import ComicActionAdapter
-from manga_watch.sources.comic_earthstar import ComicEarthstarAdapter
from manga_watch.sources.comic_trail import parse_comic_trail_title
from manga_watch.sources.comic_walker import ComicWalkerAdapter
from manga_watch.sources.gaugau import GaugauAdapter
@@ -27,7 +25,6 @@
from manga_watch.sources.nicovideo_manga import NicovideoMangaAdapter
from manga_watch.sources.piccoma import PiccomaAdapter
from manga_watch.sources.shonenjumpplus import ShonenJumpPlusAdapter
-from manga_watch.sources.sunday_webry import SundayWebryAdapter
from manga_watch.sources.util import html_title
from manga_watch.sources.takecomic import TakecomicAdapter
@@ -218,7 +215,10 @@ def get_text(self, url: str) -> str:
self.calls.append(url)
if url not in self.responses:
raise AssertionError(f"unexpected request: {url!r}")
- return self.responses[url]
+ body = self.responses[url]
+ if isinstance(body, Exception):
+ raise body
+ return body
def load_fixture_case(source: str, case_name: str):
@@ -396,144 +396,563 @@ def test_magapoke_normalize_accepts_episode_url(self):
work.to_dict(),
)
- def test_feed_family_canonicalize_item_promotes_episode_seed_to_stable_series_descriptor(self):
- cases = (
+ def test_feed_family_normalize_and_canonicalize_matrix(self):
+ family = (
+ {
+ "source": "comic-earthstar",
+ "episode_input": "https://comic-earthstar.com/episode/12207421983526541742?from=share",
+ "episode_url": "https://comic-earthstar.com/episode/12207421983526541742",
+ "rss_input": "https://comic-earthstar.com/rss/series/12207421983526538413?from=share",
+ "atom_input": "https://comic-earthstar.com/atom/series/12207421983526538413",
+ "rss_url": "https://comic-earthstar.com/rss/series/12207421983526538413",
+ "series_id": "12207421983526538413",
+ "canonical_input": "https://comic-earthstar.com/episode/12207421983526541742?from=share",
+ "normalize_cases": ("episode", "rss", "atom"),
+ },
+ {
+ "source": "comicborder",
+ "episode_input": "https://comicborder.com/episode/12207421983437812169?from=share",
+ "episode_url": "https://comicborder.com/episode/12207421983437812169",
+ "rss_input": "https://comicborder.com/rss/series/12207421983437805229?from=share",
+ "atom_input": "https://comicborder.com/atom/series/12207421983437805229",
+ "rss_url": "https://comicborder.com/rss/series/12207421983437805229",
+ "series_id": "12207421983437805229",
+ "canonical_input": "https://comicborder.com/episode/12207421983437812169?from=share",
+ "normalize_cases": ("episode", "rss", "atom"),
+ },
+ {
+ "source": "comic-trail",
+ "episode_input": "https://comic-trail.com/episode/2550689798402927313?from=share",
+ "episode_url": "https://comic-trail.com/episode/2550689798402927313",
+ "rss_input": "https://comic-trail.com/rss/series/14079602755560047206?from=share",
+ "atom_input": "https://comic-trail.com/atom/series/14079602755560047206?from=share",
+ "rss_url": "https://comic-trail.com/rss/series/14079602755560047206",
+ "series_id": "14079602755560047206",
+ "canonical_input": "https://comic-trail.com/episode/2550689798402927313?from=share",
+ "normalize_cases": ("episode", "rss", "atom"),
+ },
+ {
+ "source": "comic-days",
+ "episode_input": "https://comic-days.com/episode/12207421983746014850?from=share",
+ "episode_url": "https://comic-days.com/episode/12207421983746014850",
+ "rss_input": "https://comic-days.com/rss/series/13933686331650127004?from=share",
+ "atom_input": "https://comic-days.com/atom/series/13933686331650127004",
+ "rss_url": "https://comic-days.com/rss/series/13933686331650127004",
+ "series_id": "13933686331650127004",
+ "canonical_input": "https://comic-days.com/episode/12207421983746014850?from=share",
+ "normalize_cases": ("episode", "rss", "atom"),
+ },
+ {
+ "source": "kuragebunch",
+ "episode_input": "https://kuragebunch.com/episode/2550912964856491139?from=share",
+ "episode_url": "https://kuragebunch.com/episode/2550912964856491139",
+ "rss_input": "https://kuragebunch.com/rss/series/2550912964856487532?from=share",
+ "atom_input": "https://kuragebunch.com/atom/series/2550912964856487532",
+ "rss_url": "https://kuragebunch.com/rss/series/2550912964856487532",
+ "series_id": "2550912964856487532",
+ "canonical_input": "https://kuragebunch.com/episode/2550912964856491139?from=share",
+ "normalize_cases": ("episode", "rss", "atom"),
+ },
+ {
+ "source": "shonenjumpplus",
+ "episode_url": "https://shonenjumpplus.com/episode/17107419589191805801",
+ "rss_url": "https://shonenjumpplus.com/rss/series/3269754496881854342",
+ "series_id": "3269754496881854342",
+ "canonical_input": "https://shonenjumpplus.com/episode/17107419589191805801?from=episode",
+ "normalize_cases": (),
+ },
+ {
+ "source": "sunday-webry",
+ "episode_input": "https://www.sunday-webry.com/episode/12207421983581042977?from=share",
+ "episode_url": "https://www.sunday-webry.com/episode/12207421983581042977",
+ "rss_input": "https://www.sunday-webry.com/rss/series/12207421983580960894?from=share",
+ "atom_input": "https://www.sunday-webry.com/atom/series/12207421983580960894",
+ "rss_url": "https://www.sunday-webry.com/rss/series/12207421983580960894",
+ "series_id": "12207421983580960894",
+ "canonical_input": "https://www.sunday-webry.com/episode/12207421983581042977?from=episode",
+ "normalize_cases": ("episode", "rss", "atom"),
+ },
+ )
+
+ for entry in family:
+ source = entry["source"]
+ expected_episode = {
+ "source": source,
+ "kind": source,
+ "workId": entry["episode_url"],
+ "seedUrl": entry["episode_url"],
+ }
+ expected_feed = {
+ "source": source,
+ "kind": source,
+ "workId": f"{source}:{entry['series_id']}",
+ "seedUrl": entry["rss_url"],
+ "series": f"{source}:{entry['series_id']}",
+ "seriesId": entry["series_id"],
+ "feedKind": "rss",
+ }
+ inputs = {
+ "episode": entry.get("episode_input"),
+ "rss": entry.get("rss_input"),
+ "atom": entry.get("atom_input"),
+ }
+ for case in (*entry["normalize_cases"], "canonicalize"):
+ with self.subTest(source=source, case=case):
+ if case == "canonicalize":
+ item = normalize_seed_url(entry["canonical_input"]).to_dict()
+ client = StaticHttpClient(
+ {
+ item["seedUrl"]: (
+ ""
+ f''
+ ""
+ ),
+ }
+ )
+ descriptor = ADAPTERS[source]().canonicalize_item(item, client).to_dict()
+
+ self.assertEqual(
+ normalize_seed_url(entry["rss_url"]).to_dict(),
+ descriptor,
+ )
+ self.assertEqual([item["seedUrl"]], client.calls)
+ elif case == "episode":
+ self.assertEqual(
+ expected_episode,
+ normalize_seed_url(inputs[case]).to_dict(),
+ )
+ else:
+ self.assertEqual(
+ expected_feed,
+ normalize_seed_url(inputs[case]).to_dict(),
+ )
+
+ def _assert_fetch_cases(self, cases):
+ for entry in cases:
+ source = entry["source"]
+ with self.subTest(source=source, case=entry["case"]):
+ adapter = ADAPTERS[source]()
+ if "work" in entry:
+ work_id, seed_url, metadata = entry["work"]
+ work = WorkDescriptor(
+ source=source,
+ work_id=work_id,
+ seed_url=seed_url,
+ metadata=metadata,
+ )
+ else:
+ work = adapter.normalize(entry["seed_url"])
+ client = StaticHttpClient(entry["responses"])
+ expected_error = entry.get("expected_error")
+ if expected_error is not None:
+ with self.assertRaisesRegex(SourceParseError, re.escape(expected_error)):
+ adapter.fetch_latest(work, client)
+ else:
+ latest = adapter.fetch_latest(work, client).to_dict()
+ for key, value in entry.get("expected_latest", {}).items():
+ self.assertEqual(value, latest[key])
+ for key in entry.get("missing_latest_keys", ()):
+ self.assertNotIn(key, latest)
+ if "expected_calls" in entry:
+ self.assertEqual(entry["expected_calls"], client.calls)
+
+ def test_feed_family_fetch_latest_matrix(self):
+ self._assert_fetch_cases(
(
- "comic-earthstar",
- normalize_seed_url(
- "https://comic-earthstar.com/episode/12207421983526541742?from=share"
- ).to_dict(),
{
- "https://comic-earthstar.com/episode/12207421983526541742": """
-
-
-
-
-
- """,
+ "source": "comic-trail",
+ "case": "canonical_feed_seed",
+ "work": (
+ "comic-trail:14079602755560047206",
+ "https://comic-trail.com/rss/series/14079602755560047206",
+ {
+ "series": "comic-trail:14079602755560047206",
+ "seriesId": "14079602755560047206",
+ "feedKind": "rss",
+ },
+ ),
+ "responses": {
+ "https://comic-trail.com/rss/series/14079602755560047206": """
+
+
+ コミックトレイル(作品E)
+ -
+ 第3話 新章
+ https://comic-trail.com/episode/2550912965721039352?from=rss
+ 作品E
+
+
+
+ """,
+ "https://comic-trail.com/episode/2550912965721039352": """
+
+
+ 第3話 新章 / 作品E | コミックトレイル
+
+
+ 次回更新:毎月第2金曜
+
+
+ """,
+ },
+ "expected_latest": {
+ "workId": "comic-trail:14079602755560047206",
+ "series": "comic-trail:14079602755560047206",
+ "latestKey": "https://comic-trail.com/episode/2550912965721039352",
+ "seriesTitle": "作品E",
+ "episodeTitle": "第3話 新章",
+ "nextUpdateLabel": "次回更新:毎月第2金曜",
+ },
+ "expected_calls": [
+ "https://comic-trail.com/rss/series/14079602755560047206",
+ "https://comic-trail.com/episode/2550912965721039352",
+ ],
},
- normalize_seed_url(
- "https://comic-earthstar.com/rss/series/12207421983526538413"
- ).to_dict(),
- ),
- (
- "comicborder",
- normalize_seed_url(
- "https://comicborder.com/episode/12207421983437812169?from=share"
- ).to_dict(),
{
- "https://comicborder.com/episode/12207421983437812169": """
-
-
-
-
-
- """,
+ "source": "shonenjumpplus",
+ "case": "backfills_series_title_from_page_title",
+ "work": (
+ "shonenjumpplus:3269754496881854342",
+ "https://shonenjumpplus.com/rss/series/3269754496881854342",
+ {
+ "series": "shonenjumpplus:3269754496881854342",
+ "seriesId": "3269754496881854342",
+ "feedKind": "rss",
+ },
+ ),
+ "responses": {
+ "https://shonenjumpplus.com/rss/series/3269754496881854342": """
+
+
+ 少年ジャンプ+
+ -
+ [159話]マリッジトキシン
+ https://shonenjumpplus.com/episode/17107419589191805801
+
+
+
+ """,
+ "https://shonenjumpplus.com/episode/17107419589191805801": """
+
+
+ [159話]マリッジトキシン - 静脈/依田瑞稀 | 少年ジャンプ+
+
+
+ """,
+ },
+ "expected_latest": {
+ "seriesTitle": "マリッジトキシン",
+ "episodeTitle": "[159話]マリッジトキシン",
+ },
},
- normalize_seed_url(
- "https://comicborder.com/rss/series/12207421983437805229"
- ).to_dict(),
- ),
- (
- "comic-trail",
- normalize_seed_url(
- "https://comic-trail.com/episode/2550689798402927313?from=share"
- ).to_dict(),
{
- "https://comic-trail.com/episode/2550689798402927313": """
-
-
-
-
-
- """,
+ "source": "shonenjumpplus",
+ "case": "episode_seed",
+ "seed_url": "https://shonenjumpplus.com/episode/17107419589191805801?from=episode",
+ "responses": {
+ "https://shonenjumpplus.com/episode/17107419589191805801": """
+
+
+ [159話]マリッジトキシン - 静脈/依田瑞稀 | 少年ジャンプ+
+
+
+
+
+
+
+ """,
+ "https://shonenjumpplus.com/rss/series/3269754496881854342": """
+
+
+ 少年ジャンプ+(マリッジトキシン)
+ -
+ [159話]マリッジトキシン
+ https://shonenjumpplus.com/episode/17107419589191805801
+ マリッジトキシン
+
+
+
+ """,
+ },
+ "expected_latest": {
+ "latestKey": "https://shonenjumpplus.com/episode/17107419589191805801",
+ "seriesTitle": "マリッジトキシン",
+ "episodeTitle": "[159話]マリッジトキシン",
+ },
+ "expected_calls": [
+ "https://shonenjumpplus.com/episode/17107419589191805801",
+ "https://shonenjumpplus.com/rss/series/3269754496881854342",
+ "https://shonenjumpplus.com/episode/17107419589191805801",
+ ],
},
- normalize_seed_url(
- "https://comic-trail.com/rss/series/14079602755560047206"
- ).to_dict(),
- ),
- (
- "comic-days",
- normalize_seed_url(
- "https://comic-days.com/episode/12207421983746014850?from=share"
- ).to_dict(),
{
- "https://comic-days.com/episode/12207421983746014850": """
-
-
-
-
-
- """,
+ "source": "comic-earthstar",
+ "case": "canonical_feed_seed",
+ "work": (
+ "comic-earthstar:12207421983526538413",
+ "https://comic-earthstar.com/rss/series/12207421983526538413",
+ {
+ "series": "comic-earthstar:12207421983526538413",
+ "seriesId": "12207421983526538413",
+ "feedKind": "rss",
+ },
+ ),
+ "responses": {
+ "https://comic-earthstar.com/rss/series/12207421983526538413": """
+
+
+ コミック アース・スター|毎週木曜・最新話更新!無料で漫画が読めるWEBコミック誌(魔物(マンドラゴラ)ってバレたら討伐ですか? ~花の魔女のほほえみは勘違いの種を蒔く~)
+ -
+ 第2話②
+ https://comic-earthstar.com/episode/12207421983562435589
+ 魔物(マンドラゴラ)ってバレたら討伐ですか? ~花の魔女のほほえみは勘違いの種を蒔く~
+
+
+
+ """,
+ "https://comic-earthstar.com/episode/12207421983562435589": """
+
+
+ 第2話② / 魔物(マンドラゴラ)ってバレたら討伐ですか? ~花の魔女のほほえみは勘違いの種を蒔く~ - 漫画:大林ポチ子/原作:Mikura/キャラクター原案:中西達哉 | コミック アース・スター
+
+
+ """,
+ },
+ "expected_latest": {
+ "workId": "comic-earthstar:12207421983526538413",
+ "series": "comic-earthstar:12207421983526538413",
+ "latestKey": "https://comic-earthstar.com/episode/12207421983562435589",
+ "seriesTitle": "魔物(マンドラゴラ)ってバレたら討伐ですか? ~花の魔女のほほえみは勘違いの種を蒔く~",
+ "episodeTitle": "第2話②",
+ },
+ "expected_calls": [
+ "https://comic-earthstar.com/rss/series/12207421983526538413",
+ "https://comic-earthstar.com/episode/12207421983562435589",
+ ],
},
- normalize_seed_url(
- "https://comic-days.com/rss/series/13933686331650127004"
- ).to_dict(),
- ),
- (
- "kuragebunch",
- normalize_seed_url(
- "https://kuragebunch.com/episode/2550912964856491139?from=share"
- ).to_dict(),
{
- "https://kuragebunch.com/episode/2550912964856491139": """
-
-
-
-
-
- """,
+ "source": "comic-earthstar",
+ "case": "nested_parentheses_in_series_title_fallback",
+ "work": (
+ "comic-earthstar:12207421983526538413",
+ "https://comic-earthstar.com/rss/series/12207421983526538413",
+ {
+ "series": "comic-earthstar:12207421983526538413",
+ "seriesId": "12207421983526538413",
+ "feedKind": "rss",
+ },
+ ),
+ "responses": {
+ "https://comic-earthstar.com/rss/series/12207421983526538413": """
+
+
+ コミック アース・スター|毎週木曜・最新話更新!無料で漫画が読めるWEBコミック誌(魔物(マンドラゴラ)ってバレたら討伐ですか? ~花の魔女のほほえみは勘違いの種を蒔く~)
+ -
+ 第2話②
+ https://comic-earthstar.com/episode/12207421983562435589
+
+
+
+ """,
+ "https://comic-earthstar.com/episode/12207421983562435589": """
+
+
+ 第2話② / 魔物(マンドラゴラ)ってバレたら討伐ですか? ~花の魔女のほほえみは勘違いの種を蒔く~ - 漫画:大林ポチ子/原作:Mikura/キャラクター原案:中西達哉 | コミック アース・スター
+
+
+ """,
+ },
+ "expected_latest": {
+ "latestKey": "https://comic-earthstar.com/episode/12207421983562435589",
+ "episodeTitle": "第2話②",
+ "seriesTitle": "魔物(マンドラゴラ)ってバレたら討伐ですか? ~花の魔女のほほえみは勘違いの種を蒔く~",
+ },
},
- normalize_seed_url(
- "https://kuragebunch.com/rss/series/2550912964856487532"
- ).to_dict(),
- ),
- (
- "shonenjumpplus",
- normalize_seed_url(
- "https://shonenjumpplus.com/episode/17107419589191805801?from=episode"
- ).to_dict(),
{
- "https://shonenjumpplus.com/episode/17107419589191805801": """
-
-
-
-
-
- """,
+ "source": "comicborder",
+ "case": "canonical_feed_seed",
+ "work": (
+ "comicborder:12207421983437805229",
+ "https://comicborder.com/rss/series/12207421983437805229",
+ {
+ "series": "comicborder:12207421983437805229",
+ "seriesId": "12207421983437805229",
+ "feedKind": "rss",
+ },
+ ),
+ "responses": {
+ "https://comicborder.com/rss/series/12207421983437805229": """
+
+
+ コミックボーダー(マヨネーズ王は貧乏になりたい!【男女比1:100】世界で逝く勘違い出世街道)
+ -
+ 第01話 死んでサイタマ ~異世界全方位成り上がりRTA開始(※望んでない)~
+ https://comicborder.com/episode/12207421983437812169
+ マヨネーズ王は貧乏になりたい!【男女比1:100】世界で逝く勘違い出世街道
+
+
+
+ """,
+ "https://comicborder.com/episode/12207421983437812169": """
+
+
+ マヨネーズ王は貧乏になりたい!【男女比1:100】世界で逝く勘違い出世街道 - 神影龍之介/馬路まんじ / 第01話 死んでサイタマ ~異世界全方位成り上がりRTA開始(※望んでない)~ | コミックボーダー
+
+
+ """,
+ },
+ "expected_latest": {
+ "workId": "comicborder:12207421983437805229",
+ "series": "comicborder:12207421983437805229",
+ "latestKey": "https://comicborder.com/episode/12207421983437812169",
+ "seriesTitle": "マヨネーズ王は貧乏になりたい!【男女比1:100】世界で逝く勘違い出世街道",
+ "episodeTitle": "第01話 死んでサイタマ ~異世界全方位成り上がりRTA開始(※望んでない)~",
+ },
+ "expected_calls": [
+ "https://comicborder.com/rss/series/12207421983437805229",
+ "https://comicborder.com/episode/12207421983437812169",
+ ],
+ },
+ {
+ "source": "sunday-webry",
+ "case": "episode_seed",
+ "seed_url": "https://www.sunday-webry.com/episode/12207421983581042977?from=episode",
+ "responses": {
+ "https://www.sunday-webry.com/episode/12207421983581042977": """
+
+
+ diary1 つきこと先生 / しっぽと逆鱗 - 由田果 | サンデーうぇぶり
+
+
+
+
+
+
+
+ """,
+ "https://www.sunday-webry.com/rss/series/12207421983580960894": """
+
+
+ サンデーうぇぶり(しっぽと逆鱗)
+ -
+ diary1 つきこと先生
+ https://www.sunday-webry.com/episode/12207421983581042977
+ しっぽと逆鱗
+
+
+
+ """,
+ },
+ "expected_latest": {
+ "workId": "sunday-webry:12207421983580960894",
+ "series": "sunday-webry:12207421983580960894",
+ "latestKey": "https://www.sunday-webry.com/episode/12207421983581042977",
+ "seriesTitle": "しっぽと逆鱗",
+ "episodeTitle": "diary1 つきこと先生",
+ "pageTitle": "diary1 つきこと先生 / しっぽと逆鱗 - 由田果 | サンデーうぇぶり",
+ },
+ "expected_calls": [
+ "https://www.sunday-webry.com/episode/12207421983581042977",
+ "https://www.sunday-webry.com/rss/series/12207421983580960894",
+ ],
+ },
+ {
+ "source": "sunday-webry",
+ "case": "stale_episode_seed_uses_latest_page_title",
+ "seed_url": "https://www.sunday-webry.com/episode/12207421983581042977?from=episode",
+ "responses": {
+ "https://www.sunday-webry.com/episode/12207421983581042977": """
+
+
+ diary1 つきこと先生 / しっぽと逆鱗 - 由田果 | サンデーうぇぶり
+
+
+
+
+
+
+ """,
+ "https://www.sunday-webry.com/rss/series/12207421983580960894": """
+
+
+ サンデーうぇぶり(しっぽと逆鱗)
+ -
+
+ https://www.sunday-webry.com/episode/12207421983581043001
+
+
+
+
+ """,
+ "https://www.sunday-webry.com/episode/12207421983581043001": """
+
+
+ diary2 新しい話 / しっぽと逆鱗 - 由田果 | サンデーうぇぶり
+
+
+
+ """,
+ },
+ "expected_latest": {
+ "latestKey": "https://www.sunday-webry.com/episode/12207421983581043001",
+ "episodeTitle": "diary2 新しい話",
+ "seriesTitle": "しっぽと逆鱗",
+ "pageTitle": "diary2 新しい話 / しっぽと逆鱗 - 由田果 | サンデーうぇぶり",
+ },
+ "expected_calls": [
+ "https://www.sunday-webry.com/episode/12207421983581042977",
+ "https://www.sunday-webry.com/rss/series/12207421983580960894",
+ "https://www.sunday-webry.com/episode/12207421983581043001",
+ ],
},
- normalize_seed_url(
- "https://shonenjumpplus.com/rss/series/3269754496881854342"
- ).to_dict(),
- ),
- (
- "sunday-webry",
- normalize_seed_url(
- "https://www.sunday-webry.com/episode/12207421983581042977?from=episode"
- ).to_dict(),
{
- "https://www.sunday-webry.com/episode/12207421983581042977": """
-
-
-
-
-
- """,
+ "source": "kuragebunch",
+ "case": "canonical_feed_seed",
+ "work": (
+ "kuragebunch:2550912964856487532",
+ "https://kuragebunch.com/rss/series/2550912964856487532",
+ {
+ "series": "kuragebunch:2550912964856487532",
+ "seriesId": "2550912964856487532",
+ "feedKind": "rss",
+ },
+ ),
+ "responses": {
+ "https://kuragebunch.com/rss/series/2550912964856487532": """
+
+
+ くらげバンチ(赤と青のガウン)
+ -
+ 第15話
+ https://kuragebunch.com/episode/12207421983430264919
+ 赤と青のガウン
+
+
+
+ """,
+ "https://kuragebunch.com/episode/12207421983430264919": """
+
+
+ 赤と青のガウン - 彬子女王/池辺葵 / 第15話 | くらげバンチ
+
+
+ """,
+ },
+ "expected_latest": {
+ "workId": "kuragebunch:2550912964856487532",
+ "series": "kuragebunch:2550912964856487532",
+ "latestKey": "https://kuragebunch.com/episode/12207421983430264919",
+ "seriesTitle": "赤と青のガウン",
+ "episodeTitle": "第15話",
+ },
+ "expected_calls": [
+ "https://kuragebunch.com/rss/series/2550912964856487532",
+ "https://kuragebunch.com/episode/12207421983430264919",
+ ],
},
- normalize_seed_url(
- "https://www.sunday-webry.com/rss/series/12207421983580960894"
- ).to_dict(),
),
)
- for source, item, responses, expected in cases:
- with self.subTest(source=source):
- client = StaticHttpClient(responses)
- descriptor = ADAPTERS[source]().canonicalize_item(item, client).to_dict()
-
- self.assertEqual(expected, descriptor)
- self.assertEqual([item["seedUrl"]], client.calls)
-
def test_magapoke_fetch_latest_reads_series_rss_from_title_page(self):
adapter = MagapokeAdapter()
work = adapter.normalize("https://pocket.shonenmagazine.com/title/03021/episode/427856")
@@ -628,86 +1047,6 @@ def test_champion_cross_normalize_accepts_series_url(self):
work.to_dict(),
)
- def test_comic_trail_normalize_accepts_episode_and_feed_urls(self):
- episode_work = normalize_seed_url("https://comic-trail.com/episode/2550689798402927313?from=share").to_dict()
- rss_work = normalize_seed_url("https://comic-trail.com/rss/series/14079602755560047206?from=share").to_dict()
- atom_work = normalize_seed_url("https://comic-trail.com/atom/series/14079602755560047206?from=share").to_dict()
-
- self.assertEqual(
- {
- "source": "comic-trail",
- "kind": "comic-trail",
- "workId": "https://comic-trail.com/episode/2550689798402927313",
- "seedUrl": "https://comic-trail.com/episode/2550689798402927313",
- },
- episode_work,
- )
- expected_feed = {
- "source": "comic-trail",
- "kind": "comic-trail",
- "workId": "comic-trail:14079602755560047206",
- "seedUrl": "https://comic-trail.com/rss/series/14079602755560047206",
- "series": "comic-trail:14079602755560047206",
- "seriesId": "14079602755560047206",
- "feedKind": "rss",
- }
- self.assertEqual(expected_feed, rss_work)
- self.assertEqual(expected_feed, atom_work)
-
- def test_comic_trail_fetch_latest_accepts_canonical_feed_seed(self):
- work = WorkDescriptor(
- source="comic-trail",
- work_id="comic-trail:14079602755560047206",
- seed_url="https://comic-trail.com/rss/series/14079602755560047206",
- metadata={
- "series": "comic-trail:14079602755560047206",
- "seriesId": "14079602755560047206",
- "feedKind": "rss",
- },
- )
- client = StaticHttpClient(
- {
- "https://comic-trail.com/rss/series/14079602755560047206": """
-
-
- コミックトレイル(作品E)
- -
- 第3話 新章
- https://comic-trail.com/episode/2550912965721039352?from=rss
- 作品E
-
-
-
- """,
- "https://comic-trail.com/episode/2550912965721039352": """
-
-
- 第3話 新章 / 作品E | コミックトレイル
-
-
- 次回更新:毎月第2金曜
-
-
- """,
- }
- )
-
- latest = fetch_latest_for_work(work, http_client=client).to_dict()
-
- self.assertEqual("comic-trail:14079602755560047206", latest["workId"])
- self.assertEqual("comic-trail:14079602755560047206", latest["series"])
- self.assertEqual("https://comic-trail.com/episode/2550912965721039352", latest["latestKey"])
- self.assertEqual("作品E", latest["seriesTitle"])
- self.assertEqual("第3話 新章", latest["episodeTitle"])
- self.assertEqual("次回更新:毎月第2金曜", latest["nextUpdateLabel"])
- self.assertEqual(
- [
- "https://comic-trail.com/rss/series/14079602755560047206",
- "https://comic-trail.com/episode/2550912965721039352",
- ],
- client.calls,
- )
-
def test_parse_comic_trail_title_strips_author_suffix_from_live_title_shape(self):
page_title = (
"Pain.1 僕の好きな人 / 僕の彼女は春を売る - 後藤ねぎ | "
@@ -719,45 +1058,6 @@ def test_parse_comic_trail_title_strips_author_suffix_from_live_title_shape(self
parse_comic_trail_title(page_title),
)
- def test_shonenjumpplus_fetch_latest_backfills_series_title_from_page_title(self):
- work = WorkDescriptor(
- source="shonenjumpplus",
- work_id="shonenjumpplus:3269754496881854342",
- seed_url="https://shonenjumpplus.com/rss/series/3269754496881854342",
- metadata={
- "series": "shonenjumpplus:3269754496881854342",
- "seriesId": "3269754496881854342",
- "feedKind": "rss",
- },
- )
- client = StaticHttpClient(
- {
- "https://shonenjumpplus.com/rss/series/3269754496881854342": """
-
-
- 少年ジャンプ+
- -
- [159話]マリッジトキシン
- https://shonenjumpplus.com/episode/17107419589191805801
-
-
-
- """,
- "https://shonenjumpplus.com/episode/17107419589191805801": """
-
-
- [159話]マリッジトキシン - 静脈/依田瑞稀 | 少年ジャンプ+
-
-
- """,
- }
- )
-
- latest = ShonenJumpPlusAdapter().fetch_latest(work, client).to_dict()
-
- self.assertEqual("マリッジトキシン", latest["seriesTitle"])
- self.assertEqual("[159話]マリッジトキシン", latest["episodeTitle"])
-
def test_takecomic_normalize_accepts_series_url(self):
work = TakecomicAdapter().normalize("https://takecomic.jp/series/3f846451aff2d/?ref=top")
@@ -820,918 +1120,409 @@ def test_nicovideo_manga_normalize_accepts_sp_comic_url(self):
)
def test_champion_cross_normalize_accepts_series_rss_url(self):
- work = ChampionCrossAdapter().normalize("https://championcross.jp/series/abc123/rss?ref=top")
-
- self.assertEqual(
- {
- "source": "champion-cross",
- "kind": "champion-cross",
- "workId": "champion-cross:abc123",
- "seedUrl": "https://championcross.jp/series/abc123/rss",
- "series": "champion-cross:abc123",
- "seriesHash": "abc123",
- "feedKind": "rss",
- },
- work.to_dict(),
- )
-
- def test_champion_cross_normalize_accepts_episode_url(self):
- work = ChampionCrossAdapter().normalize("https://championcross.jp/episodes/ep12345/?page=1")
-
- self.assertEqual(
- {
- "source": "champion-cross",
- "kind": "champion-cross",
- "workId": "https://championcross.jp/episodes/ep12345",
- "seedUrl": "https://championcross.jp/episodes/ep12345",
- },
- work.to_dict(),
- )
-
- def test_comic_action_normalize_accepts_series_feed_url(self):
- work = ComicActionAdapter().normalize("https://comic-action.com/rss/series/13933686331606207128?free_only=1")
-
- self.assertEqual(
- {
- "source": "comic-action",
- "kind": "comic-action",
- "workId": "comic-action:13933686331606207128",
- "seedUrl": "https://comic-action.com/rss/series/13933686331606207128",
- "series": "comic-action:13933686331606207128",
- "seriesId": "13933686331606207128",
- "feedKind": "rss",
- },
- work.to_dict(),
- )
-
- def test_comic_action_fetch_latest_accepts_series_feed_url(self):
- adapter = ComicActionAdapter()
- work = adapter.normalize("https://comic-action.com/atom/series/13933686331606207128")
- client = StaticHttpClient(
- {
- "https://comic-action.com/atom/series/13933686331606207128": """
-
-
-
-
-
- """,
- "https://comic-action.com/episode/11341664176570134078": """
-
-
- 第1話 母さんの形見 / つぐもも - 浜田よしかづ | webアクション
-
-
-
- """,
- }
- )
-
- latest = adapter.fetch_latest(work, client).to_dict()
-
- self.assertEqual("comic-action:13933686331606207128", latest["workId"])
- self.assertEqual(
- "https://comic-action.com/episode/11341664176570134078",
- latest["latestKey"],
- )
- self.assertEqual("つぐもも", latest["seriesTitle"])
- self.assertEqual("第1話 母さんの形見", latest["episodeTitle"])
- self.assertEqual(
- [
- "https://comic-action.com/atom/series/13933686331606207128",
- "https://comic-action.com/episode/11341664176570134078",
- ],
- client.calls,
- )
-
- def test_comic_action_fetch_latest_extracts_next_update_label_from_latest_page(self):
- adapter = ComicActionAdapter()
- work = adapter.normalize("https://comic-action.com/episode/111")
- client = StaticHttpClient(
- {
- "https://comic-action.com/episode/111": """
-
- 第1話 / 作品B - webアクション | comic-action
-
-
-
-
- """,
- "https://comic-action.com/episode/222": """
-
- 第2話 / 作品B - webアクション | comic-action
-
-
-
-
- """,
- }
- )
-
- latest = adapter.fetch_latest(work, client).to_dict()
-
- self.assertEqual("4月3日", latest["nextUpdateLabel"])
-
- def test_comic_action_fetch_latest_uses_series_rss_for_episode_seed_when_readable_chain_stops(self):
- adapter = ComicActionAdapter()
- work = adapter.normalize("https://comic-action.com/episode/2550689798784879524")
- client = StaticHttpClient(
- {
- "https://comic-action.com/episode/2550689798784879524": """
-
- 第39話 / ダンジョンの中のひと - 双見酔 | webアクション
-
-
-
-
-
- """,
- "https://comic-action.com/rss/series/13933686331663374228": """
-
-
- -
- 第51話
- https://comic-action.com/episode/2551460910007760899
-
- -
- 第50話
- https://comic-action.com/episode/2551460909780695609
-
-
-
- """,
- "https://comic-action.com/episode/2551460910007760899": """
-
- 第51話 / ダンジョンの中のひと - 双見酔 | webアクション
-
-
-
-
-
- """,
- }
- )
-
- latest = adapter.fetch_latest(work, client).to_dict()
-
- self.assertEqual(
- "https://comic-action.com/episode/2551460910007760899",
- latest["latestKey"],
- )
- self.assertEqual("ダンジョンの中のひと", latest["seriesTitle"])
- self.assertEqual("第51話", latest["episodeTitle"])
- self.assertEqual("4月4日", latest["nextUpdateLabel"])
- self.assertEqual(
- [
- "https://comic-action.com/episode/2550689798784879524",
- "https://comic-action.com/rss/series/13933686331663374228",
- "https://comic-action.com/episode/2551460910007760899",
- ],
- client.calls,
- )
-
- def test_comic_action_fetch_latest_falls_back_to_entry_episode_when_series_rss_fetch_fails(self):
- adapter = ComicActionAdapter()
- work = adapter.normalize("https://comic-action.com/episode/2550689798784879524")
-
- class Client:
- def __init__(self):
- self.calls = []
-
- def get_text(self, url: str) -> str:
- self.calls.append(url)
- if url == "https://comic-action.com/episode/2550689798784879524":
- return """
-
- 第39話 / ダンジョンの中のひと - 双見酔 | webアクション
-
-
-
-
-
- """
- if url == "https://comic-action.com/rss/series/13933686331663374228":
- raise RuntimeError("temporary feed outage")
- raise AssertionError(f"unexpected request: {url!r}")
-
- client = Client()
-
- latest = adapter.fetch_latest(work, client).to_dict()
-
- self.assertEqual(
- "https://comic-action.com/episode/2550689798784879524",
- latest["latestKey"],
- )
- self.assertEqual("ダンジョンの中のひと", latest["seriesTitle"])
- self.assertEqual("第39話", latest["episodeTitle"])
- self.assertEqual(
- [
- "https://comic-action.com/episode/2550689798784879524",
- "https://comic-action.com/rss/series/13933686331663374228",
- ],
- client.calls,
- )
-
- def test_comic_action_fetch_latest_keeps_entry_episode_when_series_rss_is_stale(self):
- adapter = ComicActionAdapter()
- work = adapter.normalize("https://comic-action.com/episode/2550689798784879524")
- client = StaticHttpClient(
- {
- "https://comic-action.com/episode/2550689798784879524": """
-
- 第39話 / ダンジョンの中のひと - 双見酔 | webアクション
-
-
-
-
-
- """,
- "https://comic-action.com/rss/series/13933686331663374228": """
-
-
- -
- 第50話
- https://comic-action.com/episode/2551460909780695609
-
-
-
- """,
- "https://comic-action.com/episode/2551460909780695609": """
-
- 第50話 / ダンジョンの中のひと - 双見酔 | webアクション
-
-
-
-
- """,
- }
- )
-
- latest = adapter.fetch_latest(work, client).to_dict()
-
- self.assertEqual(
- "https://comic-action.com/episode/2550689798784879524",
- latest["latestKey"],
- )
- self.assertEqual("ダンジョンの中のひと", latest["seriesTitle"])
- self.assertEqual("第39話", latest["episodeTitle"])
- self.assertEqual(
- [
- "https://comic-action.com/episode/2550689798784879524",
- "https://comic-action.com/rss/series/13933686331663374228",
- "https://comic-action.com/episode/2551460909780695609",
- ],
- client.calls,
- )
-
- def test_comic_walker_fetch_latest_extracts_next_update_label(self):
- adapter = ComicWalkerAdapter()
- work = adapter.normalize("https://comic-walker.com/detail/KC_123456_S")
- client = StaticHttpClient(
- {
- "https://comic-walker.com/detail/KC_123456_S": """
-
-
-
-
-
- """,
- "https://comic-walker.com/detail/KC_123456_S/episodes/KC_123456003_E?episodeType=latest": """
-
- 【作品A】第3話|カドコミ (コミックウォーカー)
-
- 次回更新予定日:未定
-
-
- """,
- }
- )
-
- latest = adapter.fetch_latest(work, client).to_dict()
-
- self.assertEqual("未定", latest["nextUpdateLabel"])
-
- def test_kakuyomu_fetch_latest_extracts_next_update_label_from_schedule(self):
- adapter = KakuyomuAdapter()
- work = adapter.normalize("https://kakuyomu.jp/works/123")
- client = StaticHttpClient(
- {
- "https://kakuyomu.jp/works/123": """
-
-
-
-
-
- """,
- "https://kakuyomu.jp/works/123/episodes/789": """
-
- 第2話 - 作品C - カクヨム
-
- """,
- }
- )
-
- latest = adapter.fetch_latest(work, client).to_dict()
-
- self.assertEqual("毎日 12:08", latest["nextUpdateLabel"])
-
- def test_kakuyomu_fetch_latest_allows_null_schedule(self):
- adapter = KakuyomuAdapter()
- work = adapter.normalize("https://kakuyomu.jp/works/123")
- client = StaticHttpClient(
- {
- "https://kakuyomu.jp/works/123": """
-
-
-
-
-
- """,
- "https://kakuyomu.jp/works/123/episodes/789": """
-
- 第2話 - 作品C - カクヨム
-
- """,
- }
- )
-
- latest = adapter.fetch_latest(work, client).to_dict()
-
- self.assertNotIn("nextUpdateLabel", latest)
-
- def test_comic_action_fetch_latest_accepts_www_episode_links_from_feed(self):
- adapter = ComicActionAdapter()
- work = adapter.normalize("https://comic-action.com/rss/series/13933686331606207128")
- client = StaticHttpClient(
- {
- "https://comic-action.com/rss/series/13933686331606207128": """
-
-
- -
- https://www.comic-action.com/episode/11341664176570134078
-
-
-
- """,
- "https://comic-action.com/episode/11341664176570134078": """
-
-
- 第1話 母さんの形見 / つぐもも - 浜田よしかづ | webアクション
-
-
-
- """,
- }
- )
-
- latest = adapter.fetch_latest(work, client).to_dict()
-
- self.assertEqual("comic-action:13933686331606207128", latest["workId"])
- self.assertEqual(
- "https://comic-action.com/episode/11341664176570134078",
- latest["latestKey"],
- )
- self.assertEqual(
- [
- "https://comic-action.com/rss/series/13933686331606207128",
- "https://comic-action.com/episode/11341664176570134078",
- ],
- client.calls,
- )
-
- def test_shonenjumpplus_normalize_accepts_series_feed_urls(self):
- adapter = ShonenJumpPlusAdapter()
-
- rss_work = adapter.normalize("https://shonenjumpplus.com/rss/series/3269754496881854342?from=share")
- atom_work = adapter.normalize("https://shonenjumpplus.com/atom/series/3269754496881854342")
-
- expected = {
- "source": "shonenjumpplus",
- "kind": "shonenjumpplus",
- "workId": "shonenjumpplus:3269754496881854342",
- "seedUrl": "https://shonenjumpplus.com/rss/series/3269754496881854342",
- "series": "shonenjumpplus:3269754496881854342",
- "seriesId": "3269754496881854342",
- "feedKind": "rss",
- }
- self.assertEqual(expected, rss_work.to_dict())
- self.assertEqual(expected, atom_work.to_dict())
-
- def test_shonenjumpplus_fetch_latest_accepts_episode_seed(self):
- adapter = ShonenJumpPlusAdapter()
- work = adapter.normalize("https://shonenjumpplus.com/episode/17107419589191805801?from=episode")
- client = StaticHttpClient(
- {
- "https://shonenjumpplus.com/episode/17107419589191805801": """
-
-
- [159話]マリッジトキシン - 静脈/依田瑞稀 | 少年ジャンプ+
-
-
-
-
-
-
- """,
- "https://shonenjumpplus.com/rss/series/3269754496881854342": """
-
-
- 少年ジャンプ+(マリッジトキシン)
- -
- [159話]マリッジトキシン
- https://shonenjumpplus.com/episode/17107419589191805801
- マリッジトキシン
-
-
-
- """,
- }
- )
-
- latest = adapter.fetch_latest(work, client).to_dict()
-
- self.assertEqual("https://shonenjumpplus.com/episode/17107419589191805801", latest["latestKey"])
- self.assertEqual("マリッジトキシン", latest["seriesTitle"])
- self.assertEqual("[159話]マリッジトキシン", latest["episodeTitle"])
- self.assertEqual(
- [
- "https://shonenjumpplus.com/episode/17107419589191805801",
- "https://shonenjumpplus.com/rss/series/3269754496881854342",
- "https://shonenjumpplus.com/episode/17107419589191805801",
- ],
- client.calls,
- )
-
- def test_comicborder_normalize_accepts_episode_and_feed_urls(self):
- episode_work = normalize_seed_url("https://comicborder.com/episode/12207421983437812169?from=share").to_dict()
- rss_work = normalize_seed_url("https://comicborder.com/rss/series/12207421983437805229?from=share").to_dict()
- atom_work = normalize_seed_url("https://comicborder.com/atom/series/12207421983437805229").to_dict()
-
- self.assertEqual(
- {
- "source": "comicborder",
- "kind": "comicborder",
- "workId": "https://comicborder.com/episode/12207421983437812169",
- "seedUrl": "https://comicborder.com/episode/12207421983437812169",
- },
- episode_work,
- )
-
- expected_feed = {
- "source": "comicborder",
- "kind": "comicborder",
- "workId": "comicborder:12207421983437805229",
- "seedUrl": "https://comicborder.com/rss/series/12207421983437805229",
- "series": "comicborder:12207421983437805229",
- "seriesId": "12207421983437805229",
- "feedKind": "rss",
- }
- self.assertEqual(expected_feed, rss_work)
- self.assertEqual(expected_feed, atom_work)
-
- def test_comic_earthstar_normalize_accepts_episode_and_feed_urls(self):
- episode_work = normalize_seed_url(
- "https://comic-earthstar.com/episode/12207421983526541742?from=share"
- ).to_dict()
- rss_work = normalize_seed_url(
- "https://comic-earthstar.com/rss/series/12207421983526538413?from=share"
- ).to_dict()
- atom_work = normalize_seed_url(
- "https://comic-earthstar.com/atom/series/12207421983526538413"
- ).to_dict()
-
- self.assertEqual(
- {
- "source": "comic-earthstar",
- "kind": "comic-earthstar",
- "workId": "https://comic-earthstar.com/episode/12207421983526541742",
- "seedUrl": "https://comic-earthstar.com/episode/12207421983526541742",
- },
- episode_work,
- )
-
- expected_feed = {
- "source": "comic-earthstar",
- "kind": "comic-earthstar",
- "workId": "comic-earthstar:12207421983526538413",
- "seedUrl": "https://comic-earthstar.com/rss/series/12207421983526538413",
- "series": "comic-earthstar:12207421983526538413",
- "seriesId": "12207421983526538413",
- "feedKind": "rss",
- }
- self.assertEqual(expected_feed, rss_work)
- self.assertEqual(expected_feed, atom_work)
-
- def test_comic_earthstar_fetch_latest_accepts_canonical_feed_seed(self):
- work = WorkDescriptor(
- source="comic-earthstar",
- work_id="comic-earthstar:12207421983526538413",
- seed_url="https://comic-earthstar.com/rss/series/12207421983526538413",
- metadata={
- "series": "comic-earthstar:12207421983526538413",
- "seriesId": "12207421983526538413",
- "feedKind": "rss",
- },
- )
- client = StaticHttpClient(
- {
- "https://comic-earthstar.com/rss/series/12207421983526538413": """
-
-
- コミック アース・スター|毎週木曜・最新話更新!無料で漫画が読めるWEBコミック誌(魔物(マンドラゴラ)ってバレたら討伐ですか? ~花の魔女のほほえみは勘違いの種を蒔く~)
- -
- 第2話②
- https://comic-earthstar.com/episode/12207421983562435589
- 魔物(マンドラゴラ)ってバレたら討伐ですか? ~花の魔女のほほえみは勘違いの種を蒔く~
-
-
-
- """,
- "https://comic-earthstar.com/episode/12207421983562435589": """
-
-
- 第2話② / 魔物(マンドラゴラ)ってバレたら討伐ですか? ~花の魔女のほほえみは勘違いの種を蒔く~ - 漫画:大林ポチ子/原作:Mikura/キャラクター原案:中西達哉 | コミック アース・スター
-
-
- """,
- }
- )
-
- latest = fetch_latest_for_work(work, http_client=client).to_dict()
-
- self.assertEqual("comic-earthstar:12207421983526538413", latest["workId"])
- self.assertEqual("comic-earthstar:12207421983526538413", latest["series"])
- self.assertEqual("https://comic-earthstar.com/episode/12207421983562435589", latest["latestKey"])
- self.assertEqual(
- "魔物(マンドラゴラ)ってバレたら討伐ですか? ~花の魔女のほほえみは勘違いの種を蒔く~",
- latest["seriesTitle"],
- )
- self.assertEqual("第2話②", latest["episodeTitle"])
- self.assertEqual(
- [
- "https://comic-earthstar.com/rss/series/12207421983526538413",
- "https://comic-earthstar.com/episode/12207421983562435589",
- ],
- client.calls,
- )
-
- def test_comic_earthstar_feed_parser_keeps_nested_parentheses_in_series_title_fallback(self):
- latest = ComicEarthstarAdapter().fetch_latest(
- WorkDescriptor(
- source="comic-earthstar",
- work_id="comic-earthstar:12207421983526538413",
- seed_url="https://comic-earthstar.com/rss/series/12207421983526538413",
- metadata={
- "series": "comic-earthstar:12207421983526538413",
- "seriesId": "12207421983526538413",
+ cases = (
+ (
+ "https://championcross.jp/series/abc123/rss?ref=top",
+ {
+ "source": "champion-cross",
+ "kind": "champion-cross",
+ "workId": "champion-cross:abc123",
+ "seedUrl": "https://championcross.jp/series/abc123/rss",
+ "series": "champion-cross:abc123",
+ "seriesHash": "abc123",
"feedKind": "rss",
},
),
- StaticHttpClient(
+ (
+ "https://championcross.jp/series/4756324e1c1b1/rss?from=share",
{
- "https://comic-earthstar.com/rss/series/12207421983526538413": """
-
-
- コミック アース・スター|毎週木曜・最新話更新!無料で漫画が読めるWEBコミック誌(魔物(マンドラゴラ)ってバレたら討伐ですか? ~花の魔女のほほえみは勘違いの種を蒔く~)
- -
- 第2話②
- https://comic-earthstar.com/episode/12207421983562435589
-
-
-
- """,
- "https://comic-earthstar.com/episode/12207421983562435589": """
-
-
- 第2話② / 魔物(マンドラゴラ)ってバレたら討伐ですか? ~花の魔女のほほえみは勘違いの種を蒔く~ - 漫画:大林ポチ子/原作:Mikura/キャラクター原案:中西達哉 | コミック アース・スター
-
-
- """,
- }
+ "source": "champion-cross",
+ "kind": "champion-cross",
+ "workId": "champion-cross:4756324e1c1b1",
+ "seedUrl": "https://championcross.jp/series/4756324e1c1b1/rss",
+ "series": "champion-cross:4756324e1c1b1",
+ "seriesHash": "4756324e1c1b1",
+ "feedKind": "rss",
+ },
),
- ).to_dict()
-
- self.assertEqual("https://comic-earthstar.com/episode/12207421983562435589", latest["latestKey"])
- self.assertEqual("第2話②", latest["episodeTitle"])
- self.assertEqual(
- "魔物(マンドラゴラ)ってバレたら討伐ですか? ~花の魔女のほほえみは勘違いの種を蒔く~",
- latest["seriesTitle"],
)
- def test_comicborder_fetch_latest_accepts_canonical_feed_seed(self):
- work = WorkDescriptor(
- source="comicborder",
- work_id="comicborder:12207421983437805229",
- seed_url="https://comicborder.com/rss/series/12207421983437805229",
- metadata={
- "series": "comicborder:12207421983437805229",
- "seriesId": "12207421983437805229",
- "feedKind": "rss",
- },
- )
- client = StaticHttpClient(
- {
- "https://comicborder.com/rss/series/12207421983437805229": """
-
-
- コミックボーダー(マヨネーズ王は貧乏になりたい!【男女比1:100】世界で逝く勘違い出世街道)
- -
- 第01話 死んでサイタマ ~異世界全方位成り上がりRTA開始(※望んでない)~
- https://comicborder.com/episode/12207421983437812169
- マヨネーズ王は貧乏になりたい!【男女比1:100】世界で逝く勘違い出世街道
-
-
-
- """,
- "https://comicborder.com/episode/12207421983437812169": """
-
-
- マヨネーズ王は貧乏になりたい!【男女比1:100】世界で逝く勘違い出世街道 - 神影龍之介/馬路まんじ / 第01話 死んでサイタマ ~異世界全方位成り上がりRTA開始(※望んでない)~ | コミックボーダー
-
-
- """,
- }
- )
-
- latest = fetch_latest_for_work(work, http_client=client).to_dict()
+ for query, expected in cases:
+ with self.subTest(query=query):
+ work = ChampionCrossAdapter().normalize(query)
- self.assertEqual("comicborder:12207421983437805229", latest["workId"])
- self.assertEqual("comicborder:12207421983437805229", latest["series"])
- self.assertEqual("https://comicborder.com/episode/12207421983437812169", latest["latestKey"])
- self.assertEqual("マヨネーズ王は貧乏になりたい!【男女比1:100】世界で逝く勘違い出世街道", latest["seriesTitle"])
- self.assertEqual("第01話 死んでサイタマ ~異世界全方位成り上がりRTA開始(※望んでない)~", latest["episodeTitle"])
- self.assertEqual(
- [
- "https://comicborder.com/rss/series/12207421983437805229",
- "https://comicborder.com/episode/12207421983437812169",
- ],
- client.calls,
- )
+ self.assertEqual(expected, work.to_dict())
- def test_sunday_webry_normalize_accepts_episode_and_feed_urls(self):
- episode_work = normalize_seed_url(
- "https://www.sunday-webry.com/episode/12207421983581042977?from=share"
- ).to_dict()
- rss_work = normalize_seed_url(
- "https://www.sunday-webry.com/rss/series/12207421983580960894?from=share"
- ).to_dict()
- atom_work = normalize_seed_url(
- "https://www.sunday-webry.com/atom/series/12207421983580960894"
- ).to_dict()
+ def test_champion_cross_normalize_accepts_episode_url(self):
+ work = ChampionCrossAdapter().normalize("https://championcross.jp/episodes/ep12345/?page=1")
self.assertEqual(
{
- "source": "sunday-webry",
- "kind": "sunday-webry",
- "workId": "https://www.sunday-webry.com/episode/12207421983581042977",
- "seedUrl": "https://www.sunday-webry.com/episode/12207421983581042977",
+ "source": "champion-cross",
+ "kind": "champion-cross",
+ "workId": "https://championcross.jp/episodes/ep12345",
+ "seedUrl": "https://championcross.jp/episodes/ep12345",
},
- episode_work,
+ work.to_dict(),
)
- expected_feed = {
- "source": "sunday-webry",
- "kind": "sunday-webry",
- "workId": "sunday-webry:12207421983580960894",
- "seedUrl": "https://www.sunday-webry.com/rss/series/12207421983580960894",
- "series": "sunday-webry:12207421983580960894",
- "seriesId": "12207421983580960894",
- "feedKind": "rss",
- }
- self.assertEqual(expected_feed, rss_work)
- self.assertEqual(expected_feed, atom_work)
-
- def test_kuragebunch_normalize_accepts_episode_and_feed_urls(self):
- episode_work = normalize_seed_url("https://kuragebunch.com/episode/2550912964856491139?from=share").to_dict()
- rss_work = normalize_seed_url("https://kuragebunch.com/rss/series/2550912964856487532?from=share").to_dict()
- atom_work = normalize_seed_url("https://kuragebunch.com/atom/series/2550912964856487532").to_dict()
+ def test_comic_action_normalize_accepts_series_feed_url(self):
+ work = ComicActionAdapter().normalize("https://comic-action.com/rss/series/13933686331606207128?free_only=1")
self.assertEqual(
{
- "source": "kuragebunch",
- "kind": "kuragebunch",
- "workId": "https://kuragebunch.com/episode/2550912964856491139",
- "seedUrl": "https://kuragebunch.com/episode/2550912964856491139",
+ "source": "comic-action",
+ "kind": "comic-action",
+ "workId": "comic-action:13933686331606207128",
+ "seedUrl": "https://comic-action.com/rss/series/13933686331606207128",
+ "series": "comic-action:13933686331606207128",
+ "seriesId": "13933686331606207128",
+ "feedKind": "rss",
},
- episode_work,
+ work.to_dict(),
)
- expected_feed = {
- "source": "kuragebunch",
- "kind": "kuragebunch",
- "workId": "kuragebunch:2550912964856487532",
- "seedUrl": "https://kuragebunch.com/rss/series/2550912964856487532",
- "series": "kuragebunch:2550912964856487532",
- "seriesId": "2550912964856487532",
- "feedKind": "rss",
- }
- self.assertEqual(expected_feed, rss_work)
- self.assertEqual(expected_feed, atom_work)
+ def test_comic_action_fetch_latest_matrix(self):
+ self._assert_fetch_cases(
+ (
+ {
+ "source": "comic-action",
+ "case": "series_feed_seed",
+ "seed_url": "https://comic-action.com/atom/series/13933686331606207128",
+ "responses": {
+ "https://comic-action.com/atom/series/13933686331606207128": """
+
+
+
+
+
+ """,
+ "https://comic-action.com/episode/11341664176570134078": """
+
+
+ 第1話 母さんの形見 / つぐもも - 浜田よしかづ | webアクション
+
+
+
+ """,
+ },
+ "expected_latest": {
+ "workId": "comic-action:13933686331606207128",
+ "latestKey": "https://comic-action.com/episode/11341664176570134078",
+ "seriesTitle": "つぐもも",
+ "episodeTitle": "第1話 母さんの形見",
+ },
+ "expected_calls": [
+ "https://comic-action.com/atom/series/13933686331606207128",
+ "https://comic-action.com/episode/11341664176570134078",
+ ],
+ },
+ {
+ "source": "comic-action",
+ "case": "next_update_label_from_latest_page",
+ "seed_url": "https://comic-action.com/episode/111",
+ "responses": {
+ "https://comic-action.com/episode/111": """
+
+ 第1話 / 作品B - webアクション | comic-action
+
+
+
+
+ """,
+ "https://comic-action.com/episode/222": """
+
+ 第2話 / 作品B - webアクション | comic-action
+
+
+
+
+ """,
+ },
+ "expected_latest": {
+ "nextUpdateLabel": "4月3日",
+ },
+ },
+ {
+ "source": "comic-action",
+ "case": "series_rss_fallback_when_readable_chain_stops",
+ "seed_url": "https://comic-action.com/episode/2550689798784879524",
+ "responses": {
+ "https://comic-action.com/episode/2550689798784879524": """
+
+ 第39話 / ダンジョンの中のひと - 双見酔 | webアクション
+
+
+
+
+
+ """,
+ "https://comic-action.com/rss/series/13933686331663374228": """
+
+
+ -
+ 第51話
+ https://comic-action.com/episode/2551460910007760899
+
+ -
+ 第50話
+ https://comic-action.com/episode/2551460909780695609
+
+
+
+ """,
+ "https://comic-action.com/episode/2551460910007760899": """
+
+ 第51話 / ダンジョンの中のひと - 双見酔 | webアクション
+
+
+
+
+
+ """,
+ },
+ "expected_latest": {
+ "latestKey": "https://comic-action.com/episode/2551460910007760899",
+ "seriesTitle": "ダンジョンの中のひと",
+ "episodeTitle": "第51話",
+ "nextUpdateLabel": "4月4日",
+ },
+ "expected_calls": [
+ "https://comic-action.com/episode/2550689798784879524",
+ "https://comic-action.com/rss/series/13933686331663374228",
+ "https://comic-action.com/episode/2551460910007760899",
+ ],
+ },
+ {
+ "source": "comic-action",
+ "case": "entry_episode_when_series_rss_fetch_fails",
+ "seed_url": "https://comic-action.com/episode/2550689798784879524",
+ "responses": {
+ "https://comic-action.com/episode/2550689798784879524": """
+
+ 第39話 / ダンジョンの中のひと - 双見酔 | webアクション
+
+
+
+
+
+ """,
+ "https://comic-action.com/rss/series/13933686331663374228": RuntimeError(
+ "temporary feed outage"
+ ),
+ },
+ "expected_latest": {
+ "latestKey": "https://comic-action.com/episode/2550689798784879524",
+ "seriesTitle": "ダンジョンの中のひと",
+ "episodeTitle": "第39話",
+ },
+ "expected_calls": [
+ "https://comic-action.com/episode/2550689798784879524",
+ "https://comic-action.com/rss/series/13933686331663374228",
+ ],
+ },
+ {
+ "source": "comic-action",
+ "case": "entry_episode_when_series_rss_is_stale",
+ "seed_url": "https://comic-action.com/episode/2550689798784879524",
+ "responses": {
+ "https://comic-action.com/episode/2550689798784879524": """
+
+ 第39話 / ダンジョンの中のひと - 双見酔 | webアクション
+
+
+
+
+
+ """,
+ "https://comic-action.com/rss/series/13933686331663374228": """
+
+
+ -
+ 第50話
+ https://comic-action.com/episode/2551460909780695609
+
+
+
+ """,
+ "https://comic-action.com/episode/2551460909780695609": """
+
+ 第50話 / ダンジョンの中のひと - 双見酔 | webアクション
+
+
+
+
+ """,
+ },
+ "expected_latest": {
+ "latestKey": "https://comic-action.com/episode/2550689798784879524",
+ "seriesTitle": "ダンジョンの中のひと",
+ "episodeTitle": "第39話",
+ },
+ "expected_calls": [
+ "https://comic-action.com/episode/2550689798784879524",
+ "https://comic-action.com/rss/series/13933686331663374228",
+ "https://comic-action.com/episode/2551460909780695609",
+ ],
+ },
+ {
+ "source": "comic-action",
+ "case": "www_episode_links_from_feed",
+ "seed_url": "https://comic-action.com/rss/series/13933686331606207128",
+ "responses": {
+ "https://comic-action.com/rss/series/13933686331606207128": """
+
+
+ -
+ https://www.comic-action.com/episode/11341664176570134078
+
+
+
+ """,
+ "https://comic-action.com/episode/11341664176570134078": """
+
+
+ 第1話 母さんの形見 / つぐもも - 浜田よしかづ | webアクション
+
+
+
+ """,
+ },
+ "expected_latest": {
+ "workId": "comic-action:13933686331606207128",
+ "latestKey": "https://comic-action.com/episode/11341664176570134078",
+ },
+ "expected_calls": [
+ "https://comic-action.com/rss/series/13933686331606207128",
+ "https://comic-action.com/episode/11341664176570134078",
+ ],
+ },
+ ),
+ )
- def test_sunday_webry_fetch_latest_accepts_episode_seed(self):
- adapter = SundayWebryAdapter()
- work = adapter.normalize("https://www.sunday-webry.com/episode/12207421983581042977?from=episode")
+ def test_comic_walker_fetch_latest_extracts_next_update_label(self):
+ adapter = ComicWalkerAdapter()
+ work = adapter.normalize("https://comic-walker.com/detail/KC_123456_S")
client = StaticHttpClient(
{
- "https://www.sunday-webry.com/episode/12207421983581042977": """
+ "https://comic-walker.com/detail/KC_123456_S": """
-
- diary1 つきこと先生 / しっぽと逆鱗 - 由田果 | サンデーうぇぶり
-
-
-
-
+
""",
- "https://www.sunday-webry.com/rss/series/12207421983580960894": """
-
-
- サンデーうぇぶり(しっぽと逆鱗)
- -
- diary1 つきこと先生
- https://www.sunday-webry.com/episode/12207421983581042977
- しっぽと逆鱗
-
-
-
- """,
- }
- )
-
- latest = adapter.fetch_latest(work, client).to_dict()
-
- self.assertEqual("sunday-webry:12207421983580960894", latest["workId"])
- self.assertEqual("sunday-webry:12207421983580960894", latest["series"])
- self.assertEqual("https://www.sunday-webry.com/episode/12207421983581042977", latest["latestKey"])
- self.assertEqual("しっぽと逆鱗", latest["seriesTitle"])
- self.assertEqual("diary1 つきこと先生", latest["episodeTitle"])
- self.assertEqual(
- "diary1 つきこと先生 / しっぽと逆鱗 - 由田果 | サンデーうぇぶり",
- latest["pageTitle"],
- )
- self.assertEqual(
- [
- "https://www.sunday-webry.com/episode/12207421983581042977",
- "https://www.sunday-webry.com/rss/series/12207421983580960894",
- ],
- client.calls,
- )
-
- def test_sunday_webry_fetch_latest_uses_latest_page_title_when_episode_seed_is_stale(self):
- adapter = SundayWebryAdapter()
- work = adapter.normalize("https://www.sunday-webry.com/episode/12207421983581042977?from=episode")
- client = StaticHttpClient(
- {
- "https://www.sunday-webry.com/episode/12207421983581042977": """
+ "https://comic-walker.com/detail/KC_123456_S/episodes/KC_123456003_E?episodeType=latest": """
-
- diary1 つきこと先生 / しっぽと逆鱗 - 由田果 | サンデーうぇぶり
-
-
+ 【作品A】第3話|カドコミ (コミックウォーカー)
-
+ 次回更新予定日:未定
""",
- "https://www.sunday-webry.com/rss/series/12207421983580960894": """
-
-
- サンデーうぇぶり(しっぽと逆鱗)
- -
-
- https://www.sunday-webry.com/episode/12207421983581043001
-
-
-
-
- """,
- "https://www.sunday-webry.com/episode/12207421983581043001": """
-
-
- diary2 新しい話 / しっぽと逆鱗 - 由田果 | サンデーうぇぶり
-
-
-
- """,
}
)
latest = adapter.fetch_latest(work, client).to_dict()
- self.assertEqual("https://www.sunday-webry.com/episode/12207421983581043001", latest["latestKey"])
- self.assertEqual("diary2 新しい話", latest["episodeTitle"])
- self.assertEqual("しっぽと逆鱗", latest["seriesTitle"])
- self.assertEqual(
- "diary2 新しい話 / しっぽと逆鱗 - 由田果 | サンデーうぇぶり",
- latest["pageTitle"],
- )
- self.assertEqual(
- [
- "https://www.sunday-webry.com/episode/12207421983581042977",
- "https://www.sunday-webry.com/rss/series/12207421983580960894",
- "https://www.sunday-webry.com/episode/12207421983581043001",
- ],
- client.calls,
- )
+ self.assertEqual("未定", latest["nextUpdateLabel"])
- def test_kuragebunch_fetch_latest_accepts_canonical_feed_seed(self):
- work = WorkDescriptor(
- source="kuragebunch",
- work_id="kuragebunch:2550912964856487532",
- seed_url="https://kuragebunch.com/rss/series/2550912964856487532",
- metadata={
- "series": "kuragebunch:2550912964856487532",
- "seriesId": "2550912964856487532",
- "feedKind": "rss",
- },
- )
- client = StaticHttpClient(
- {
- "https://kuragebunch.com/rss/series/2550912964856487532": """
-
-
- くらげバンチ(赤と青のガウン)
- -
- 第15話
- https://kuragebunch.com/episode/12207421983430264919
- 赤と青のガウン
-
-
-
- """,
- "https://kuragebunch.com/episode/12207421983430264919": """
-
-
- 赤と青のガウン - 彬子女王/池辺葵 / 第15話 | くらげバンチ
-
-
- """,
- }
+ def test_kakuyomu_fetch_latest_matrix(self):
+ self._assert_fetch_cases(
+ (
+ {
+ "source": "kakuyomu",
+ "case": "next_update_label_from_schedule",
+ "seed_url": "https://kakuyomu.jp/works/123",
+ "responses": {
+ "https://kakuyomu.jp/works/123": """
+
+
+
+
+
+ """,
+ "https://kakuyomu.jp/works/123/episodes/789": """
+
+ 第2話 - 作品C - カクヨム
+
+ """,
+ },
+ "expected_latest": {
+ "nextUpdateLabel": "毎日 12:08",
+ },
+ },
+ {
+ "source": "kakuyomu",
+ "case": "null_schedule",
+ "seed_url": "https://kakuyomu.jp/works/123",
+ "responses": {
+ "https://kakuyomu.jp/works/123": """
+
+
+
+
+
+ """,
+ "https://kakuyomu.jp/works/123/episodes/789": """
+
+ 第2話 - 作品C - カクヨム
+
+ """,
+ },
+ "missing_latest_keys": ("nextUpdateLabel",),
+ },
+ ),
)
- latest = fetch_latest_for_work(work, http_client=client).to_dict()
-
- self.assertEqual("kuragebunch:2550912964856487532", latest["workId"])
- self.assertEqual("kuragebunch:2550912964856487532", latest["series"])
- self.assertEqual("https://kuragebunch.com/episode/12207421983430264919", latest["latestKey"])
- self.assertEqual("赤と青のガウン", latest["seriesTitle"])
- self.assertEqual("第15話", latest["episodeTitle"])
- self.assertEqual(
- [
- "https://kuragebunch.com/rss/series/2550912964856487532",
- "https://kuragebunch.com/episode/12207421983430264919",
- ],
- client.calls,
- )
+ def test_shonenjumpplus_normalize_accepts_series_feed_urls(self):
+ adapter = ShonenJumpPlusAdapter()
- def test_champion_cross_normalize_accepts_series_rss_url(self):
- work = ChampionCrossAdapter().normalize("https://championcross.jp/series/4756324e1c1b1/rss?from=share")
+ rss_work = adapter.normalize("https://shonenjumpplus.com/rss/series/3269754496881854342?from=share")
+ atom_work = adapter.normalize("https://shonenjumpplus.com/atom/series/3269754496881854342")
- self.assertEqual(
- {
- "source": "champion-cross",
- "kind": "champion-cross",
- "workId": "champion-cross:4756324e1c1b1",
- "seedUrl": "https://championcross.jp/series/4756324e1c1b1/rss",
- "series": "champion-cross:4756324e1c1b1",
- "seriesHash": "4756324e1c1b1",
- "feedKind": "rss",
- },
- work.to_dict(),
- )
+ expected = {
+ "source": "shonenjumpplus",
+ "kind": "shonenjumpplus",
+ "workId": "shonenjumpplus:3269754496881854342",
+ "seedUrl": "https://shonenjumpplus.com/rss/series/3269754496881854342",
+ "series": "shonenjumpplus:3269754496881854342",
+ "seriesId": "3269754496881854342",
+ "feedKind": "rss",
+ }
+ self.assertEqual(expected, rss_work.to_dict())
+ self.assertEqual(expected, atom_work.to_dict())
def test_champion_cross_fetch_latest_accepts_series_url(self):
adapter = ChampionCrossAdapter()
@@ -1820,116 +1611,109 @@ def test_takecomic_fetch_latest_accepts_series_url(self):
client.calls,
)
- def test_firecross_fetch_latest_accepts_reader_url(self):
- adapter = ADAPTERS["firecross"]()
- work = adapter.normalize("https://firecross.jp/reader/19386?trial=0&token=temp")
- client = StaticHttpClient(
- {
- "https://firecross.jp/reader/19386": """
-
- 第12話 / 作品E | ファイアCROSS
-
- 作品詳細
-
-
- """,
- "https://firecross.jp/series/series-abc": """
-
- 作品E | ファイアCROSS
-
- 最新話
-
-
- """,
- "https://firecross.jp/reader/19420": """
-
- 第13話 / 作品E | ファイアCROSS
-
-
- """,
- }
- )
-
- latest = adapter.fetch_latest(work, client).to_dict()
-
- self.assertEqual("https://firecross.jp/reader/19420", latest["latestKey"])
- self.assertEqual("https://firecross.jp/reader/19420", latest["url"])
- self.assertEqual("firecross:series-abc", latest["series"])
- self.assertEqual("作品E", latest["seriesTitle"])
- self.assertEqual("第13話", latest["episodeTitle"])
- self.assertEqual(
- [
- "https://firecross.jp/reader/19386",
- "https://firecross.jp/series/series-abc",
- "https://firecross.jp/reader/19420",
- ],
- client.calls,
- )
-
- def test_firecross_fetch_latest_requires_explicit_latest_signal(self):
- adapter = ADAPTERS["firecross"]()
- work = adapter.normalize("https://firecross.jp/reader/19386?trial=0&token=temp")
- client = StaticHttpClient(
- {
- "https://firecross.jp/reader/19386": """
-
- 第12話 / 作品E | ファイアCROSS
-
- 作品詳細
-
-
- """,
- "https://firecross.jp/series/series-abc": """
-
- 作品E | ファイアCROSS
-
- 第10話
- 第13話
-
-
- """,
- }
- )
-
- with self.assertRaisesRegex(SourceParseError, "firecross: latest reader URL not found"):
- adapter.fetch_latest(work, client)
-
- def test_firecross_fetch_latest_accepts_ebook_series_url(self):
- adapter = ADAPTERS["firecross"]()
- work = adapter.normalize("https://firecross.jp/ebook/series/358?sort=latest")
- client = StaticHttpClient(
- {
- "https://firecross.jp/ebook/series/358?sort=latest": """
-
- 作品E | ファイアCROSS
-
- 第13話
- 第10話
-
-
- """,
- "https://firecross.jp/reader/19420": """
-
- 第13話 / 作品E | ファイアCROSS
-
-
- """,
- }
- )
-
- latest = adapter.fetch_latest(work, client).to_dict()
-
- self.assertEqual("https://firecross.jp/reader/19420", latest["latestKey"])
- self.assertEqual("https://firecross.jp/reader/19420", latest["url"])
- self.assertEqual("firecross:358", latest["series"])
- self.assertEqual("作品E", latest["seriesTitle"])
- self.assertEqual("第13話", latest["episodeTitle"])
- self.assertEqual(
- [
- "https://firecross.jp/ebook/series/358?sort=latest",
- "https://firecross.jp/reader/19420",
- ],
- client.calls,
+ def test_firecross_fetch_latest_matrix(self):
+ self._assert_fetch_cases(
+ (
+ {
+ "source": "firecross",
+ "case": "reader_seed",
+ "seed_url": "https://firecross.jp/reader/19386?trial=0&token=temp",
+ "responses": {
+ "https://firecross.jp/reader/19386": """
+
+ 第12話 / 作品E | ファイアCROSS
+
+ 作品詳細
+
+
+ """,
+ "https://firecross.jp/series/series-abc": """
+
+ 作品E | ファイアCROSS
+
+ 最新話
+
+
+ """,
+ "https://firecross.jp/reader/19420": """
+
+ 第13話 / 作品E | ファイアCROSS
+
+
+ """,
+ },
+ "expected_latest": {
+ "latestKey": "https://firecross.jp/reader/19420",
+ "url": "https://firecross.jp/reader/19420",
+ "series": "firecross:series-abc",
+ "seriesTitle": "作品E",
+ "episodeTitle": "第13話",
+ },
+ "expected_calls": [
+ "https://firecross.jp/reader/19386",
+ "https://firecross.jp/series/series-abc",
+ "https://firecross.jp/reader/19420",
+ ],
+ },
+ {
+ "source": "firecross",
+ "case": "requires_explicit_latest_signal",
+ "seed_url": "https://firecross.jp/reader/19386?trial=0&token=temp",
+ "responses": {
+ "https://firecross.jp/reader/19386": """
+
+ 第12話 / 作品E | ファイアCROSS
+
+ 作品詳細
+
+
+ """,
+ "https://firecross.jp/series/series-abc": """
+
+ 作品E | ファイアCROSS
+
+ 第10話
+ 第13話
+
+
+ """,
+ },
+ "expected_error": "firecross: latest reader URL not found",
+ },
+ {
+ "source": "firecross",
+ "case": "ebook_series_seed",
+ "seed_url": "https://firecross.jp/ebook/series/358?sort=latest",
+ "responses": {
+ "https://firecross.jp/ebook/series/358?sort=latest": """
+
+ 作品E | ファイアCROSS
+
+ 第13話
+ 第10話
+
+
+ """,
+ "https://firecross.jp/reader/19420": """
+
+ 第13話 / 作品E | ファイアCROSS
+
+
+ """,
+ },
+ "expected_latest": {
+ "latestKey": "https://firecross.jp/reader/19420",
+ "url": "https://firecross.jp/reader/19420",
+ "series": "firecross:358",
+ "seriesTitle": "作品E",
+ "episodeTitle": "第13話",
+ },
+ "expected_calls": [
+ "https://firecross.jp/ebook/series/358?sort=latest",
+ "https://firecross.jp/reader/19420",
+ ],
+ },
+ ),
)
def test_nicovideo_manga_fetch_latest_accepts_comic_url(self):
@@ -2069,79 +1853,78 @@ def test_bookwalker_normalize_accepts_series_and_product_urls(self):
adapter.normalize("https://bookwalker.jp/de2b0ecd13-4ad0-405e-9fe9-a625ad80c74c/?sample=1").to_dict(),
)
- def test_bookwalker_fetch_latest_prefers_latest_series_card(self):
- adapter = BookwalkerAdapter()
- work = adapter.normalize("https://bookwalker.jp/series/519222/list/")
- client = StaticHttpClient(
- {
- "https://bookwalker.jp/series/519222/list/": """
-
- くまぐらし(MANGAバル コミックス)一覧 - BOOK☆WALKER
-
-
- くまぐらし (06)
- 配信予定
-
-
- くまぐらし (05)
-
-
-
- """
- }
- )
-
- latest = adapter.fetch_latest(work, client).to_dict()
-
- self.assertEqual("bookwalker:series:519222", latest["workId"])
- self.assertEqual("bookwalker:series:519222", latest["series"])
- self.assertEqual("bookwalker:book:893cb2ba-dc87-4c1b-90cb-a1cd13d33a0f", latest["latestKey"])
- self.assertEqual("くまぐらし(MANGAバル コミックス)", latest["seriesTitle"])
- self.assertEqual("くまぐらし (06)", latest["episodeTitle"])
- self.assertEqual("2026/5/14(木) 配信予定", latest["nextUpdateLabel"])
- self.assertEqual(["https://bookwalker.jp/series/519222/list/"], client.calls)
-
- def test_bookwalker_fetch_latest_reads_episode_titles_from_wauri_series(self):
- adapter = BookwalkerAdapter()
- work = adapter.normalize("https://bookwalker.jp/series/559081/")
- client = StaticHttpClient(
- {
- "https://bookwalker.jp/series/559081/": """
-
- 【話・連載】救世のアルル【分冊版】(ノヴァコミックス) - BOOK☆WALKER
-
- 2026/3/25(水) 更新
-
- 1
-
-
- 8
-
-
-
- """
- }
- )
-
- latest = adapter.fetch_latest(work, client).to_dict()
-
- self.assertEqual("bookwalker:series:559081", latest["workId"])
- self.assertEqual("bookwalker:book:b5c239d6-7f48-4d79-b2ee-c0fb0e481a35", latest["latestKey"])
- self.assertEqual("救世のアルル【分冊版】(ノヴァコミックス)", latest["seriesTitle"])
- self.assertEqual("救世のアルル【分冊版】8", latest["episodeTitle"])
- self.assertEqual(
- "https://bookwalker.jp/deb5c239d6-7f48-4d79-b2ee-c0fb0e481a35/",
- latest["url"],
+ def test_bookwalker_fetch_latest_matrix(self):
+ self._assert_fetch_cases(
+ (
+ {
+ "source": "bookwalker",
+ "case": "latest_series_card",
+ "seed_url": "https://bookwalker.jp/series/519222/list/",
+ "responses": {
+ "https://bookwalker.jp/series/519222/list/": """
+
+ くまぐらし(MANGAバル コミックス)一覧 - BOOK☆WALKER
+
+
+ くまぐらし (06)
+ 配信予定
+
+
+ くまぐらし (05)
+
+
+
+ """
+ },
+ "expected_latest": {
+ "workId": "bookwalker:series:519222",
+ "series": "bookwalker:series:519222",
+ "latestKey": "bookwalker:book:893cb2ba-dc87-4c1b-90cb-a1cd13d33a0f",
+ "seriesTitle": "くまぐらし(MANGAバル コミックス)",
+ "episodeTitle": "くまぐらし (06)",
+ "nextUpdateLabel": "2026/5/14(木) 配信予定",
+ },
+ "expected_calls": ["https://bookwalker.jp/series/519222/list/"],
+ },
+ {
+ "source": "bookwalker",
+ "case": "wauri_series_episode_titles",
+ "seed_url": "https://bookwalker.jp/series/559081/",
+ "responses": {
+ "https://bookwalker.jp/series/559081/": """
+
+ 【話・連載】救世のアルル【分冊版】(ノヴァコミックス) - BOOK☆WALKER
+
+ 2026/3/25(水) 更新
+
+ 1
+
+
+ 8
+
+
+
+ """
+ },
+ "expected_latest": {
+ "workId": "bookwalker:series:559081",
+ "latestKey": "bookwalker:book:b5c239d6-7f48-4d79-b2ee-c0fb0e481a35",
+ "seriesTitle": "救世のアルル【分冊版】(ノヴァコミックス)",
+ "episodeTitle": "救世のアルル【分冊版】8",
+ "url": "https://bookwalker.jp/deb5c239d6-7f48-4d79-b2ee-c0fb0e481a35/",
+ "nextUpdateLabel": "2026/3/25(水) 更新",
+ },
+ },
+ ),
)
- self.assertEqual("2026/3/25(水) 更新", latest["nextUpdateLabel"])
def _assert_fixture_matrix(self, source: str):
source_dir = FIXTURES_ROOT / source
diff --git a/tests/test_storage.py b/tests/test_storage.py
index ed46ed0..872326b 100644
--- a/tests/test_storage.py
+++ b/tests/test_storage.py
@@ -226,66 +226,42 @@ def writer(worker_id: int):
self.assertIn(final_latest_key, written_keys)
self.assertEqual(final_latest_key, json.loads(state_path.read_text(encoding="utf-8"))["works"]["work-1"]["latest"]["latest_key"])
- def test_supertwins_search_sessions_round_trip_without_overwrite(self):
- with tempfile.TemporaryDirectory() as tmpdir:
- state_path = Path(tmpdir) / "state.json"
- save_supertwins_search_session(
- "session-a",
- {"root_work_id": "root-1", "selected_urls_by_value": {"u:a": "https://example.com/a"}},
- path=str(state_path),
- )
- save_supertwins_search_session(
- "session-b",
- {"root_work_id": "root-2", "selected_urls_by_value": {"u:b": "https://example.com/b"}},
- path=str(state_path),
- )
-
- session_a = load_supertwins_search_session("session-a", str(state_path))
- session_b = load_supertwins_search_session("session-b", str(state_path))
- delete_supertwins_search_session("session-a", str(state_path))
-
- self.assertEqual(
- {"root_work_id": "root-1", "selected_urls_by_value": {"u:a": "https://example.com/a"}},
- session_a,
- )
- self.assertEqual(
- {"root_work_id": "root-2", "selected_urls_by_value": {"u:b": "https://example.com/b"}},
- session_b,
- )
- with self.assertRaises(FileNotFoundError):
- load_supertwins_search_session("session-a", str(state_path))
-
- def test_where_sessions_round_trip_without_overwrite(self):
- with tempfile.TemporaryDirectory() as tmpdir:
- state_path = Path(tmpdir) / "state.json"
- save_where_session(
- "session-a",
- {"query": "作品A", "episode": "1話", "results": [{"source": "comic-walker"}]},
- path=str(state_path),
- )
- save_where_session(
- "session-b",
- {"query": "作品B", "episode": "2話", "results": [{"source": "nicovideo-manga"}]},
- path=str(state_path),
- )
-
- session_a = load_where_session("session-a", str(state_path))
- session_b = load_where_session("session-b", str(state_path))
- delete_where_session("session-a", str(state_path))
-
- self.assertEqual(
- {"query": "作品A", "episode": "1話", "results": [{"source": "comic-walker"}]},
- session_a,
- )
- self.assertEqual(
- {"query": "作品B", "episode": "2話", "results": [{"source": "nicovideo-manga"}]},
- session_b,
- )
- with self.assertRaises(FileNotFoundError):
- load_where_session("session-a", str(state_path))
-
- def test_validate_watchlist_normalizes_hidden_to_boolean(self):
- normalized = validate_watchlist(
+ def test_search_sessions_round_trip_without_overwrite(self):
+ session_cases = {
+ "supertwins": {
+ "save": save_supertwins_search_session,
+ "load": load_supertwins_search_session,
+ "delete": delete_supertwins_search_session,
+ "payload_a": {"root_work_id": "root-1", "selected_urls_by_value": {"u:a": "https://example.com/a"}},
+ "payload_b": {"root_work_id": "root-2", "selected_urls_by_value": {"u:b": "https://example.com/b"}},
+ },
+ "where": {
+ "save": save_where_session,
+ "load": load_where_session,
+ "delete": delete_where_session,
+ "payload_a": {"query": "作品A", "episode": "1話", "results": [{"source": "comic-walker"}]},
+ "payload_b": {"query": "作品B", "episode": "2話", "results": [{"source": "nicovideo-manga"}]},
+ },
+ }
+ for session_kind, funcs in session_cases.items():
+ with self.subTest(session_kind=session_kind):
+ with tempfile.TemporaryDirectory() as tmpdir:
+ state_path = Path(tmpdir) / "state.json"
+ funcs["save"]("session-a", funcs["payload_a"], path=str(state_path))
+ funcs["save"]("session-b", funcs["payload_b"], path=str(state_path))
+
+ session_a = funcs["load"]("session-a", str(state_path))
+ session_b = funcs["load"]("session-b", str(state_path))
+ funcs["delete"]("session-a", str(state_path))
+
+ self.assertEqual(funcs["payload_a"], session_a)
+ self.assertEqual(funcs["payload_b"], session_b)
+ with self.assertRaises(FileNotFoundError):
+ funcs["load"]("session-a", str(state_path))
+
+ def test_validate_watchlist_hidden_boolean_matrix(self):
+ with self.subTest(case="missing_and_true_normalize_to_boolean"):
+ normalized = validate_watchlist(
{
"version": 2,
"works": [
@@ -311,23 +287,23 @@ def test_validate_watchlist_normalizes_hidden_to_boolean(self):
self.assertFalse(normalized["works"][0]["hidden"])
self.assertTrue(normalized["works"][1]["hidden"])
- def test_validate_watchlist_rejects_non_boolean_hidden(self):
- with self.assertRaisesRegex(ValueError, "watchlist entry work-1 hidden must be boolean"):
- validate_watchlist(
- {
- "version": 2,
- "works": [
- {
- "id": "work-1",
- "source": "comic-walker",
- "seed_url": "https://example.com/work-1",
- "enabled": True,
- "hidden": "yes",
- "notification_policy": {"mode": "all", "allowed_update_types": None},
- }
- ],
- }
- )
+ with self.subTest(case="non_boolean_hidden_rejected"):
+ with self.assertRaisesRegex(ValueError, "watchlist entry work-1 hidden must be boolean"):
+ validate_watchlist(
+ {
+ "version": 2,
+ "works": [
+ {
+ "id": "work-1",
+ "source": "comic-walker",
+ "seed_url": "https://example.com/work-1",
+ "enabled": True,
+ "hidden": "yes",
+ "notification_policy": {"mode": "all", "allowed_update_types": None},
+ }
+ ],
+ }
+ )
if __name__ == "__main__":
diff --git a/tests/test_watchlist.py b/tests/test_watchlist.py
index 73382ec..b46ace7 100644
--- a/tests/test_watchlist.py
+++ b/tests/test_watchlist.py
@@ -198,409 +198,6 @@ def test_add_watchlist_url_accepts_comic_trail_episode_url_and_canonicalizes_to_
self.assertEqual(1, payload["work_count"])
self.assertEqual(1, len(saved["works"]))
- def test_add_watchlist_url_accepts_comic_days_episode_url_and_canonicalizes_to_rss(self):
- with tempfile.TemporaryDirectory() as tmpdir:
- watchlist_path = Path(tmpdir) / "watchlist.json"
- write_watchlist(watchlist_path, [])
-
- payload = add_watchlist_url(
- "https://comic-days.com/episode/12207421983746014850?from=share",
- watchlist_path=str(watchlist_path),
- http_client=StaticHttpClient(
- {
- "https://comic-days.com/episode/12207421983746014850": """
-
-
- メダリスト - つるまいかだ / score60 牧師と子羊① | コミックDAYS
-
-
-
-
-
-
- """
- }
- ),
- )
- saved = json.loads(watchlist_path.read_text(encoding="utf-8"))
-
- self.assertEqual("added", payload["action"])
- self.assertEqual("comic-days:13933686331650127004", payload["entry"]["id"])
- self.assertEqual(
- "https://comic-days.com/rss/series/13933686331650127004",
- payload["entry"]["seed_url"],
- )
- self.assertEqual("comic-days", payload["entry"]["source"])
- self.assertEqual(1, payload["work_count"])
- self.assertEqual(1, len(saved["works"]))
-
- def test_add_watchlist_url_accepts_magapoke_episode_url(self):
- with tempfile.TemporaryDirectory() as tmpdir:
- watchlist_path = Path(tmpdir) / "watchlist.json"
- write_watchlist(watchlist_path, [])
-
- payload = add_watchlist_url(
- "https://pocket.shonenmagazine.com/title/03021/episode/427856?utm_source=share",
- watchlist_path=str(watchlist_path),
- http_client=StaticHttpClient(
- {
- "https://pocket.shonenmagazine.com/title/03021": """
-
-
-
-
-
-
- """
- }
- ),
- )
- saved = json.loads(watchlist_path.read_text(encoding="utf-8"))
-
- self.assertEqual("added", payload["action"])
- self.assertEqual("magapoke:3021", payload["entry"]["id"])
- self.assertEqual(
- "https://pocket.shonenmagazine.com/title/03021",
- payload["entry"]["seed_url"],
- )
- self.assertEqual("magapoke", payload["entry"]["source"])
- self.assertEqual(1, payload["work_count"])
- self.assertEqual(1, len(saved["works"]))
-
- def test_add_watchlist_url_accepts_takecomic_series_url(self):
- with tempfile.TemporaryDirectory() as tmpdir:
- watchlist_path = Path(tmpdir) / "watchlist.json"
- write_watchlist(watchlist_path, [])
-
- payload = add_watchlist_url(
- "https://takecomic.jp/series/3f846451aff2d/?utm_source=share",
- watchlist_path=str(watchlist_path),
- http_client=StaticHttpClient({}),
- )
- saved = json.loads(watchlist_path.read_text(encoding="utf-8"))
-
- self.assertEqual("added", payload["action"])
- self.assertEqual("takecomic:3f846451aff2d", payload["entry"]["id"])
- self.assertEqual(
- "https://takecomic.jp/series/3f846451aff2d",
- payload["entry"]["seed_url"],
- )
- self.assertEqual("takecomic", payload["entry"]["source"])
- self.assertEqual(1, payload["work_count"])
- self.assertEqual(1, len(saved["works"]))
-
- def test_add_watchlist_url_accepts_shonenjumpplus_episode_url_and_canonicalizes_to_rss(self):
- with tempfile.TemporaryDirectory() as tmpdir:
- watchlist_path = Path(tmpdir) / "watchlist.json"
- write_watchlist(watchlist_path, [])
-
- payload = add_watchlist_url(
- "https://shonenjumpplus.com/episode/17107419589191805801?from=share",
- watchlist_path=str(watchlist_path),
- http_client=StaticHttpClient(
- {
- "https://shonenjumpplus.com/episode/17107419589191805801": """
-
-
- [159話]マリッジトキシン - 静脈/依田瑞稀 | 少年ジャンプ+
-
-
-
-
- """
- }
- ),
- )
- saved = json.loads(watchlist_path.read_text(encoding="utf-8"))
-
- self.assertEqual("added", payload["action"])
- self.assertEqual("shonenjumpplus:3269754496881854342", payload["entry"]["id"])
- self.assertEqual(
- "https://shonenjumpplus.com/rss/series/3269754496881854342",
- payload["entry"]["seed_url"],
- )
- self.assertEqual("shonenjumpplus", payload["entry"]["source"])
- self.assertEqual(1, payload["work_count"])
- self.assertEqual(1, len(saved["works"]))
-
- def test_add_watchlist_url_accepts_comicborder_episode_url_and_canonicalizes_to_rss(self):
- with tempfile.TemporaryDirectory() as tmpdir:
- watchlist_path = Path(tmpdir) / "watchlist.json"
- write_watchlist(watchlist_path, [])
-
- payload = add_watchlist_url(
- "https://comicborder.com/episode/12207421983437812169?from=share",
- watchlist_path=str(watchlist_path),
- http_client=StaticHttpClient(
- {
- "https://comicborder.com/episode/12207421983437812169": """
-
-
- マヨネーズ王は貧乏になりたい!【男女比1:100】世界で逝く勘違い出世街道 - 神影龍之介/馬路まんじ / 第01話 死んでサイタマ ~異世界全方位成り上がりRTA開始(※望んでない)~ | コミックボーダー
-
-
-
-
- """
- }
- ),
- )
- saved = json.loads(watchlist_path.read_text(encoding="utf-8"))
-
- self.assertEqual("added", payload["action"])
- self.assertEqual("comicborder:12207421983437805229", payload["entry"]["id"])
- self.assertEqual(
- "https://comicborder.com/rss/series/12207421983437805229",
- payload["entry"]["seed_url"],
- )
- self.assertEqual("comicborder", payload["entry"]["source"])
- self.assertEqual(1, payload["work_count"])
- self.assertEqual(1, len(saved["works"]))
-
- def test_add_watchlist_url_accepts_comic_earthstar_episode_url_and_canonicalizes_to_rss(self):
- with tempfile.TemporaryDirectory() as tmpdir:
- watchlist_path = Path(tmpdir) / "watchlist.json"
- write_watchlist(watchlist_path, [])
-
- payload = add_watchlist_url(
- "https://comic-earthstar.com/episode/12207421983526541742?from=share",
- watchlist_path=str(watchlist_path),
- http_client=StaticHttpClient(
- {
- "https://comic-earthstar.com/episode/12207421983526541742": """
-
-
- 第1話 / 魔物(マンドラゴラ)ってバレたら討伐ですか? ~花の魔女のほほえみは勘違いの種を蒔く~ - 漫画:大林ポチ子/原作:Mikura/キャラクター原案:中西達哉 | コミック アース・スター
-
-
-
-
- """
- }
- ),
- )
- saved = json.loads(watchlist_path.read_text(encoding="utf-8"))
-
- self.assertEqual("added", payload["action"])
- self.assertEqual("comic-earthstar:12207421983526538413", payload["entry"]["id"])
- self.assertEqual(
- "https://comic-earthstar.com/rss/series/12207421983526538413",
- payload["entry"]["seed_url"],
- )
- self.assertEqual("comic-earthstar", payload["entry"]["source"])
- self.assertEqual(1, payload["work_count"])
- self.assertEqual(1, len(saved["works"]))
-
- def test_add_watchlist_url_accepts_sunday_webry_episode_url_and_canonicalizes_to_rss(self):
- with tempfile.TemporaryDirectory() as tmpdir:
- watchlist_path = Path(tmpdir) / "watchlist.json"
- write_watchlist(watchlist_path, [])
-
- payload = add_watchlist_url(
- "https://www.sunday-webry.com/episode/12207421983581042977?from=share",
- watchlist_path=str(watchlist_path),
- http_client=StaticHttpClient(
- {
- "https://www.sunday-webry.com/episode/12207421983581042977": """
-
-
- diary1 つきこと先生 / しっぽと逆鱗 - 由田果 | サンデーうぇぶり
-
-
-
-
-
-
-
- """
- }
- ),
- )
- saved = json.loads(watchlist_path.read_text(encoding="utf-8"))
-
- self.assertEqual("added", payload["action"])
- self.assertEqual("sunday-webry:12207421983580960894", payload["entry"]["id"])
- self.assertEqual(
- "https://www.sunday-webry.com/rss/series/12207421983580960894",
- payload["entry"]["seed_url"],
- )
- self.assertEqual("sunday-webry", payload["entry"]["source"])
- self.assertEqual(1, payload["work_count"])
- self.assertEqual(1, len(saved["works"]))
-
- def test_add_watchlist_url_accepts_kuragebunch_episode_url_and_canonicalizes_to_rss(self):
- with tempfile.TemporaryDirectory() as tmpdir:
- watchlist_path = Path(tmpdir) / "watchlist.json"
- write_watchlist(watchlist_path, [])
-
- payload = add_watchlist_url(
- "https://kuragebunch.com/episode/2550912964856491139?from=share",
- watchlist_path=str(watchlist_path),
- http_client=StaticHttpClient(
- {
- "https://kuragebunch.com/episode/2550912964856491139": """
-
-
- 赤と青のガウン - 彬子女王/池辺葵 / 第1話 | くらげバンチ
-
-
-
-
-
-
- """
- }
- ),
- )
- saved = json.loads(watchlist_path.read_text(encoding="utf-8"))
-
- self.assertEqual("added", payload["action"])
- self.assertEqual("kuragebunch:2550912964856487532", payload["entry"]["id"])
- self.assertEqual(
- "https://kuragebunch.com/rss/series/2550912964856487532",
- payload["entry"]["seed_url"],
- )
- self.assertEqual("kuragebunch", payload["entry"]["source"])
- self.assertEqual(1, payload["work_count"])
- self.assertEqual(1, len(saved["works"]))
-
- def test_add_watchlist_url_accepts_sunday_webry_apex_episode_url_and_canonicalizes_to_rss(self):
- with tempfile.TemporaryDirectory() as tmpdir:
- watchlist_path = Path(tmpdir) / "watchlist.json"
- write_watchlist(watchlist_path, [])
-
- payload = add_watchlist_url(
- "https://sunday-webry.com/episode/12207421983581042977?from=share",
- watchlist_path=str(watchlist_path),
- http_client=StaticHttpClient(
- {
- "https://www.sunday-webry.com/episode/12207421983581042977": """
-
-
- diary1 つきこと先生 / しっぽと逆鱗 - 由田果 | サンデーうぇぶり
-
-
-
-
-
-
-
- """
- }
- ),
- )
- saved = json.loads(watchlist_path.read_text(encoding="utf-8"))
-
- self.assertEqual("added", payload["action"])
- self.assertEqual("sunday-webry:12207421983580960894", payload["entry"]["id"])
- self.assertEqual(
- "https://www.sunday-webry.com/rss/series/12207421983580960894",
- payload["entry"]["seed_url"],
- )
- self.assertEqual("sunday-webry", payload["entry"]["source"])
- self.assertEqual(1, payload["work_count"])
- self.assertEqual(1, len(saved["works"]))
-
- def test_add_watchlist_url_accepts_firecross_reader_url(self):
- with tempfile.TemporaryDirectory() as tmpdir:
- watchlist_path = Path(tmpdir) / "watchlist.json"
- write_watchlist(watchlist_path, [])
-
- payload = add_watchlist_url(
- "https://firecross.jp/reader/19386?trial=0&token=temp&vertical=0",
- watchlist_path=str(watchlist_path),
- http_client=StaticHttpClient(
- {
- "https://firecross.jp/reader/19386": """
-
-
- 作品詳細
-
-
- """
- }
- ),
- )
- saved = json.loads(watchlist_path.read_text(encoding="utf-8"))
-
- self.assertEqual("added", payload["action"])
- self.assertEqual("firecross:series-abc", payload["entry"]["id"])
- self.assertEqual(
- "https://firecross.jp/reader/19386",
- payload["entry"]["seed_url"],
- )
- self.assertEqual("firecross", payload["entry"]["source"])
- self.assertEqual(1, payload["work_count"])
- self.assertEqual(1, len(saved["works"]))
-
- def test_add_watchlist_url_accepts_firecross_ebook_series_url(self):
- with tempfile.TemporaryDirectory() as tmpdir:
- watchlist_path = Path(tmpdir) / "watchlist.json"
- write_watchlist(watchlist_path, [])
-
- payload = add_watchlist_url(
- "https://firecross.jp/ebook/series/358?sort=latest",
- watchlist_path=str(watchlist_path),
- http_client=StaticHttpClient({}),
- )
- saved = json.loads(watchlist_path.read_text(encoding="utf-8"))
-
- self.assertEqual("added", payload["action"])
- self.assertEqual("firecross:358", payload["entry"]["id"])
- self.assertEqual(
- "https://firecross.jp/ebook/series/358",
- payload["entry"]["seed_url"],
- )
- self.assertEqual("firecross", payload["entry"]["source"])
- self.assertEqual(1, payload["work_count"])
- self.assertEqual(1, len(saved["works"]))
-
- def test_watchlist_add_accepts_nicovideo_sp_comic_url(self):
- with tempfile.TemporaryDirectory() as tmpdir:
- watchlist_path = Path(tmpdir) / "watchlist.json"
- write_watchlist(watchlist_path, [])
-
- payload = add_watchlist_url(
- "https://sp.manga.nicovideo.jp/comic/53764?track=share",
- watchlist_path=str(watchlist_path),
- http_client=StaticHttpClient({}),
- )
- saved = json.loads(watchlist_path.read_text(encoding="utf-8"))
-
- self.assertEqual("added", payload["action"])
- self.assertEqual("nicovideo-manga:53764", payload["entry"]["id"])
- self.assertEqual(
- "https://manga.nicovideo.jp/comic/53764",
- payload["entry"]["seed_url"],
- )
- self.assertEqual("nicovideo-manga", payload["entry"]["source"])
- self.assertEqual(1, payload["work_count"])
- self.assertEqual(1, len(saved["works"]))
-
- def test_watchlist_add_accepts_gaugau_work_url(self):
- with tempfile.TemporaryDirectory() as tmpdir:
- watchlist_path = Path(tmpdir) / "watchlist.json"
- write_watchlist(watchlist_path, [])
-
- payload = add_watchlist_url(
- "https://gaugau.futabanet.jp/list/work/600a5fd37765610d30010000?from=search",
- watchlist_path=str(watchlist_path),
- http_client=StaticHttpClient({}),
- )
- saved = json.loads(watchlist_path.read_text(encoding="utf-8"))
-
- self.assertEqual("added", payload["action"])
- self.assertEqual("gaugau:600a5fd37765610d30010000", payload["entry"]["id"])
- self.assertEqual(
- "https://gaugau.futabanet.jp/list/work/600a5fd37765610d30010000",
- payload["entry"]["seed_url"],
- )
- self.assertEqual("gaugau", payload["entry"]["source"])
- self.assertEqual(1, payload["work_count"])
- self.assertEqual(1, len(saved["works"]))
-
def test_watchlist_add_accepts_piccoma_product_url(self):
with tempfile.TemporaryDirectory() as tmpdir:
watchlist_path = Path(tmpdir) / "watchlist.json"
@@ -687,36 +284,6 @@ def test_watchlist_add_accepts_bookwalker_series_url(self):
self.assertEqual(1, payload["work_count"])
self.assertEqual(1, len(saved["works"]))
- def test_watchlist_add_accepts_bookwalker_book_detail_url(self):
- with tempfile.TemporaryDirectory() as tmpdir:
- watchlist_path = Path(tmpdir) / "watchlist.json"
- write_watchlist(watchlist_path, [])
-
- payload = add_watchlist_url(
- "https://bookwalker.jp/de2b0ecd13-4ad0-405e-9fe9-a625ad80c74c/?sample=1",
- watchlist_path=str(watchlist_path),
- http_client=StaticHttpClient({}),
- )
- saved = json.loads(watchlist_path.read_text(encoding="utf-8"))
-
- self.assertEqual("added", payload["action"])
- self.assertEqual(
- "bookwalker:book:2b0ecd13-4ad0-405e-9fe9-a625ad80c74c",
- payload["entry"]["id"],
- )
- self.assertEqual(
- "https://bookwalker.jp/de2b0ecd13-4ad0-405e-9fe9-a625ad80c74c/",
- payload["entry"]["seed_url"],
- )
- self.assertEqual("bookwalker", payload["entry"]["source"])
- self.assertEqual(1, payload["work_count"])
- self.assertEqual(1, len(saved["works"]))
-
- def test_watchlist_add_reports_unsupported_source(self):
- with tempfile.TemporaryDirectory() as tmpdir:
- watchlist_path = Path(tmpdir) / "watchlist.json"
- write_watchlist(watchlist_path, [])
-
class WatchlistCliRetirementTests(unittest.TestCase):
maxDiff = None
diff --git a/tests/test_web_admin_settings.py b/tests/test_web_admin_settings.py
deleted file mode 100644
index 619467d..0000000
--- a/tests/test_web_admin_settings.py
+++ /dev/null
@@ -1 +0,0 @@
-"""Reserved for future web admin settings tests."""