Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 9 additions & 1 deletion manga_watch/repair_mojibake.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
import argparse
import json
import os
import shutil
import sys
from typing import Dict, List, Optional, TextIO, Tuple

Expand All @@ -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:
Expand Down Expand Up @@ -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)

Expand All @@ -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)
Expand Down
23 changes: 18 additions & 5 deletions manga_watch/sources/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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:
Expand All @@ -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):
Expand Down
156 changes: 156 additions & 0 deletions tests/_firestore_fakes.py
Original file line number Diff line number Diff line change
@@ -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)
31 changes: 14 additions & 17 deletions tests/acceptance_traceability.py
Original file line number Diff line number Diff line change
Expand Up @@ -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": {
Expand All @@ -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": {
Expand Down Expand Up @@ -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": {
Expand Down Expand Up @@ -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",
],
},
Expand Down
37 changes: 37 additions & 0 deletions tests/test_acceptance_manifest.py
Original file line number Diff line number Diff line change
@@ -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()
Loading