Add Linkki broadcast image
Build custom container images / build (map[base_image:php:8-fpm-alpine build_args:PHP_VERSION=8
context:php8-pgsql fingerprint_command:{ apk info -v | LC_ALL=C sort; find /usr/local/lib/php/extensions /usr/local/etc/php/conf.d -type f -exec sha256sum {} + | LC_ALL=C sort; }
name:ph… (push) Successful in 48s
Build custom container images / build (map[base_image:postgres:18 build_args:PG_VERSION=18
POSTGIS_VERSION=3
VCHORD_VERSION=0.5.3
context:postgres fingerprint_command:{ dpkg-query -W -f='${binary:Package}=${Version}\n' | LC_ALL=C sort; find /usr/lib/postgresql -type f -exec sha256su… (push) Successful in 55s
Build custom container images / build (map[base_image:python:3 build_args:PYTHON_VERSION=3
context:python-tools fingerprint_command:{ dpkg-query -W -f='${binary:Package}=${Version}\n' | LC_ALL=C sort; pip freeze | LC_ALL=C sort; }
name:python-tools oci_labels:org.opencontainers.ima… (push) Successful in 54s
Build custom container images / build (map[base_image:python:3.12-slim build_args:PYTHON_VERSION=3.12-slim
context:linkki-tiedotus fingerprint_command:{ dpkg-query -W -f='${binary:Package}=${Version}\n' | LC_ALL=C sort; pip freeze | LC_ALL=C sort; }
name:linkki-tiedotus oci_labels:… (push) Successful in 39s
Build custom container images / build (map[base_image:php:8-fpm-alpine build_args:PHP_VERSION=8
context:php8-pgsql fingerprint_command:{ apk info -v | LC_ALL=C sort; find /usr/local/lib/php/extensions /usr/local/etc/php/conf.d -type f -exec sha256sum {} + | LC_ALL=C sort; }
name:ph… (push) Successful in 48s
Build custom container images / build (map[base_image:postgres:18 build_args:PG_VERSION=18
POSTGIS_VERSION=3
VCHORD_VERSION=0.5.3
context:postgres fingerprint_command:{ dpkg-query -W -f='${binary:Package}=${Version}\n' | LC_ALL=C sort; find /usr/lib/postgresql -type f -exec sha256su… (push) Successful in 55s
Build custom container images / build (map[base_image:python:3 build_args:PYTHON_VERSION=3
context:python-tools fingerprint_command:{ dpkg-query -W -f='${binary:Package}=${Version}\n' | LC_ALL=C sort; pip freeze | LC_ALL=C sort; }
name:python-tools oci_labels:org.opencontainers.ima… (push) Successful in 54s
Build custom container images / build (map[base_image:python:3.12-slim build_args:PYTHON_VERSION=3.12-slim
context:linkki-tiedotus fingerprint_command:{ dpkg-query -W -f='${binary:Package}=${Version}\n' | LC_ALL=C sort; pip freeze | LC_ALL=C sort; }
name:linkki-tiedotus oci_labels:… (push) Successful in 39s
This commit is contained in:
@@ -0,0 +1,12 @@
|
||||
import sys
|
||||
import types
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
PROJECT_ROOT = Path(__file__).resolve().parents[1]
|
||||
sys.path.insert(0, str(PROJECT_ROOT))
|
||||
|
||||
sys.modules.setdefault(
|
||||
"requests",
|
||||
types.SimpleNamespace(HTTPError=RuntimeError, Response=object, post=lambda *args, **kwargs: None),
|
||||
)
|
||||
@@ -0,0 +1,25 @@
|
||||
from webapp.app import parse_accept_language, resolve_effective_ui_language
|
||||
|
||||
|
||||
class _Request:
|
||||
def __init__(self, header: str):
|
||||
self.headers = {"accept-language": header}
|
||||
|
||||
|
||||
def test_parse_accept_language_collects_supported_languages_in_browser_order() -> None:
|
||||
assert parse_accept_language("en-US,en;q=0.9,fi;q=0.8,sv;q=0.7") == ["en", "fi", "sv"]
|
||||
|
||||
|
||||
def test_resolve_effective_ui_language_prefers_finnish_then_swedish_then_english() -> None:
|
||||
request = _Request("en-US,en;q=0.9,sv;q=0.8,fi;q=0.7")
|
||||
assert resolve_effective_ui_language(request, "auto") == "fi"
|
||||
|
||||
|
||||
def test_resolve_effective_ui_language_falls_back_to_english_when_no_supported_match() -> None:
|
||||
request = _Request("de-DE,de;q=0.9,fr;q=0.8")
|
||||
assert resolve_effective_ui_language(request, "auto") == "en"
|
||||
|
||||
|
||||
def test_resolve_effective_ui_language_keeps_explicit_language() -> None:
|
||||
request = _Request("fi,sv,en")
|
||||
assert resolve_effective_ui_language(request, "sv") == "sv"
|
||||
@@ -0,0 +1,202 @@
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
from webapp.auth import load_users, set_user_language
|
||||
|
||||
|
||||
def test_load_users_defaults_permissions_to_false(tmp_path: Path) -> None:
|
||||
users_file = tmp_path / "users.json"
|
||||
users_file.write_text(
|
||||
"""
|
||||
{
|
||||
"users": [
|
||||
{
|
||||
"username": "viewer",
|
||||
"password_hash": "hash"
|
||||
}
|
||||
]
|
||||
}
|
||||
""",
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
users = load_users(users_file)
|
||||
|
||||
assert users["viewer"]["password_hash"] == "hash"
|
||||
assert all(value is False for value in users["viewer"]["permissions"].values())
|
||||
assert users["viewer"]["language"] == "en"
|
||||
saved_payload = json.loads(users_file.read_text(encoding="utf-8"))
|
||||
saved_permissions = saved_payload["users"][0]["permissions"]
|
||||
assert all(saved_permissions[key] is False for key in saved_permissions)
|
||||
|
||||
|
||||
def test_load_users_accepts_bool_like_permission_values(tmp_path: Path) -> None:
|
||||
users_file = tmp_path / "users.json"
|
||||
users_file.write_text(
|
||||
"""
|
||||
{
|
||||
"users": [
|
||||
{
|
||||
"username": "editor",
|
||||
"password_hash": "hash",
|
||||
"permissions": {
|
||||
"edit_content": 1,
|
||||
"edit_targets": 1,
|
||||
"save_announcements": 1,
|
||||
"open_telegram": 1,
|
||||
"post_now": "true",
|
||||
"schedule": "yes",
|
||||
"unschedule": true
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
""",
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
users = load_users(users_file)
|
||||
permissions = users["editor"]["permissions"]
|
||||
|
||||
assert permissions["edit_content"] is True
|
||||
assert permissions["edit_targets"] is True
|
||||
assert permissions["save_announcements"] is True
|
||||
assert permissions["open_telegram"] is True
|
||||
assert permissions["post_now"] is True
|
||||
assert permissions["schedule"] is True
|
||||
assert permissions["unschedule"] is True
|
||||
assert permissions["unlink_telegram"] is False
|
||||
|
||||
|
||||
def test_set_user_language_updates_users_file(tmp_path: Path) -> None:
|
||||
users_file = tmp_path / "users.json"
|
||||
users_file.write_text(
|
||||
"""
|
||||
{
|
||||
"users": [
|
||||
{
|
||||
"username": "editor",
|
||||
"password_hash": "hash",
|
||||
"permissions": {}
|
||||
}
|
||||
]
|
||||
}
|
||||
""",
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
set_user_language(users_file, "editor", "fi")
|
||||
|
||||
users = load_users(users_file)
|
||||
assert users["editor"]["language"] == "fi"
|
||||
|
||||
|
||||
def test_load_users_accepts_auto_language(tmp_path: Path) -> None:
|
||||
users_file = tmp_path / "users.json"
|
||||
users_file.write_text(
|
||||
"""
|
||||
{
|
||||
"users": [
|
||||
{
|
||||
"username": "auto_user",
|
||||
"password_hash": "hash",
|
||||
"language": "auto"
|
||||
}
|
||||
]
|
||||
}
|
||||
""",
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
users = load_users(users_file)
|
||||
|
||||
assert users["auto_user"]["language"] == "auto"
|
||||
|
||||
|
||||
def test_set_user_language_can_store_auto(tmp_path: Path) -> None:
|
||||
users_file = tmp_path / "users.json"
|
||||
users_file.write_text(
|
||||
"""
|
||||
{
|
||||
"users": [
|
||||
{
|
||||
"username": "editor",
|
||||
"password_hash": "hash",
|
||||
"permissions": {}
|
||||
}
|
||||
]
|
||||
}
|
||||
""",
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
set_user_language(users_file, "editor", "auto")
|
||||
|
||||
users = load_users(users_file)
|
||||
assert users["editor"]["language"] == "auto"
|
||||
|
||||
|
||||
def test_load_users_adds_missing_permissions_to_existing_permissions_block(tmp_path: Path) -> None:
|
||||
users_file = tmp_path / "users.json"
|
||||
users_file.write_text(
|
||||
"""
|
||||
{
|
||||
"users": [
|
||||
{
|
||||
"username": "editor",
|
||||
"password_hash": "hash",
|
||||
"permissions": {
|
||||
"edit_content": true
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
""",
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
users = load_users(users_file)
|
||||
|
||||
assert users["editor"]["permissions"]["edit_content"] is True
|
||||
assert users["editor"]["permissions"]["edit_targets"] is False
|
||||
assert users["editor"]["permissions"]["save_announcements"] is False
|
||||
assert users["editor"]["permissions"]["open_telegram"] is False
|
||||
saved_payload = json.loads(users_file.read_text(encoding="utf-8"))
|
||||
saved_permissions = saved_payload["users"][0]["permissions"]
|
||||
assert saved_permissions["edit_content"] is True
|
||||
assert saved_permissions["edit_targets"] is False
|
||||
assert saved_permissions["save_announcements"] is False
|
||||
assert saved_permissions["open_telegram"] is False
|
||||
assert saved_permissions["post_now"] is False
|
||||
assert saved_permissions["schedule"] is False
|
||||
assert saved_permissions["unschedule"] is False
|
||||
assert saved_permissions["unlink_telegram"] is False
|
||||
assert saved_permissions["delete_telegram"] is False
|
||||
assert saved_permissions["delete_storage"] is False
|
||||
|
||||
|
||||
def test_load_users_removes_legacy_edit_broadcasts_permission(tmp_path: Path) -> None:
|
||||
users_file = tmp_path / "users.json"
|
||||
users_file.write_text(
|
||||
"""
|
||||
{
|
||||
"users": [
|
||||
{
|
||||
"username": "editor",
|
||||
"password_hash": "hash",
|
||||
"permissions": {
|
||||
"edit_broadcasts": true
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
""",
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
users = load_users(users_file)
|
||||
|
||||
assert "edit_broadcasts" not in users["editor"]["permissions"]
|
||||
saved_payload = json.loads(users_file.read_text(encoding="utf-8"))
|
||||
saved_permissions = saved_payload["users"][0]["permissions"]
|
||||
assert "edit_broadcasts" not in saved_permissions
|
||||
@@ -0,0 +1,204 @@
|
||||
from linkki_poster.broadcast_ops import (
|
||||
DiscussionResolutionStats,
|
||||
apply_discussion_group_pinning,
|
||||
build_discussion_resolution_detail,
|
||||
prune_binding_after_deletions,
|
||||
)
|
||||
from linkki_poster.models import Config, Language, LanguageBinding, PublishBinding, TextSegmentBinding
|
||||
from linkki_poster.telegram_api import TelegramApiError
|
||||
|
||||
|
||||
def test_discussion_resolution_detail_mentions_missing_group_updates() -> None:
|
||||
detail = build_discussion_resolution_detail(
|
||||
DiscussionResolutionStats(
|
||||
updates_seen=4,
|
||||
discussion_group_updates=0,
|
||||
automatic_forward_updates=0,
|
||||
linked_channel_forward_updates=0,
|
||||
),
|
||||
expected_count=2,
|
||||
matched_count=0,
|
||||
)
|
||||
|
||||
assert "matched 0/2" in detail
|
||||
assert "none were from the configured discussion group" in detail
|
||||
|
||||
|
||||
def test_discussion_resolution_detail_mentions_linked_channel_forwards() -> None:
|
||||
detail = build_discussion_resolution_detail(
|
||||
DiscussionResolutionStats(
|
||||
updates_seen=6,
|
||||
discussion_group_updates=5,
|
||||
automatic_forward_updates=3,
|
||||
linked_channel_forward_updates=2,
|
||||
),
|
||||
expected_count=3,
|
||||
matched_count=2,
|
||||
)
|
||||
|
||||
assert "matched 2/3" in detail
|
||||
assert "saw 2 automatic forwards from the linked channel" in detail
|
||||
|
||||
|
||||
def test_discussion_resolution_detail_mentions_manual_linking_for_old_posts() -> None:
|
||||
detail = build_discussion_resolution_detail(
|
||||
DiscussionResolutionStats(
|
||||
updates_seen=0,
|
||||
discussion_group_updates=0,
|
||||
automatic_forward_updates=0,
|
||||
linked_channel_forward_updates=0,
|
||||
),
|
||||
expected_count=3,
|
||||
matched_count=0,
|
||||
)
|
||||
|
||||
assert "they must be linked manually" in detail
|
||||
|
||||
|
||||
def test_apply_discussion_group_pinning_skips_service_message_cleanup_when_disabled(monkeypatch) -> None:
|
||||
calls = []
|
||||
|
||||
class FakeTelegramAPI:
|
||||
def __init__(self, bot_token: str):
|
||||
self.bot_token = bot_token
|
||||
|
||||
def pin_chat_message(self, chat_id: str, message_id: int, disable_notification: bool = True) -> bool:
|
||||
calls.append(("pin", chat_id, message_id, disable_notification))
|
||||
return True
|
||||
|
||||
def unpin_chat_message(self, chat_id: str, message_id: int) -> bool:
|
||||
calls.append(("unpin", chat_id, message_id))
|
||||
return True
|
||||
|
||||
def fail_if_delete_called(**kwargs):
|
||||
raise AssertionError("delete_pin_service_message should not be called")
|
||||
|
||||
monkeypatch.setattr("linkki_poster.broadcast_ops.TelegramAPI", FakeTelegramAPI)
|
||||
monkeypatch.setattr("linkki_poster.broadcast_ops.delete_pin_service_message", fail_if_delete_called)
|
||||
|
||||
binding = PublishBinding(
|
||||
global_message_ids=[],
|
||||
global_attached_to_language=None,
|
||||
languages={
|
||||
Language.FI: LanguageBinding(
|
||||
language=Language.FI,
|
||||
image_message_ids=[],
|
||||
text_segments=[TextSegmentBinding(message_id=101, mode="message")],
|
||||
primary_message_id=101,
|
||||
primary_message_url="https://t.me/c/123/101",
|
||||
discussion_message_id=201,
|
||||
),
|
||||
Language.SV: LanguageBinding(
|
||||
language=Language.SV,
|
||||
image_message_ids=[],
|
||||
text_segments=[TextSegmentBinding(message_id=102, mode="message")],
|
||||
primary_message_id=102,
|
||||
primary_message_url="https://t.me/c/123/102",
|
||||
discussion_message_id=202,
|
||||
),
|
||||
},
|
||||
discussion_message_ids={101: 201, 102: 202},
|
||||
)
|
||||
|
||||
result = apply_discussion_group_pinning(
|
||||
Config(bot_token="token", chat_id="-1001", channel_username="channel", discussion_group_id="-2001"),
|
||||
binding,
|
||||
[Language.FI, Language.SV],
|
||||
"first",
|
||||
delete_service_messages=False,
|
||||
)
|
||||
|
||||
assert result.binding == binding
|
||||
assert result.pinning_failed is False
|
||||
assert calls == [
|
||||
("pin", "-2001", 201, True),
|
||||
("unpin", "-2001", 202),
|
||||
]
|
||||
|
||||
|
||||
def test_prune_binding_after_deletions_keeps_remaining_channel_links() -> None:
|
||||
binding = PublishBinding(
|
||||
global_message_ids=[],
|
||||
global_attached_to_language=None,
|
||||
languages={
|
||||
Language.FI: LanguageBinding(
|
||||
language=Language.FI,
|
||||
image_message_ids=[],
|
||||
text_segments=[TextSegmentBinding(message_id=101, mode="message")],
|
||||
primary_message_id=101,
|
||||
primary_message_url="https://t.me/c/123/101",
|
||||
discussion_message_id=201,
|
||||
),
|
||||
Language.SV: LanguageBinding(
|
||||
language=Language.SV,
|
||||
image_message_ids=[],
|
||||
text_segments=[TextSegmentBinding(message_id=102, mode="message")],
|
||||
primary_message_id=102,
|
||||
primary_message_url="https://t.me/c/123/102",
|
||||
discussion_message_id=202,
|
||||
),
|
||||
},
|
||||
discussion_message_ids={101: 201, 102: 202},
|
||||
)
|
||||
|
||||
remaining = prune_binding_after_deletions(
|
||||
binding,
|
||||
deleted_channel_ids=set(),
|
||||
deleted_discussion_channel_ids={101},
|
||||
deleted_discussion_message_ids={201},
|
||||
chat_id="-100123",
|
||||
channel_username="",
|
||||
)
|
||||
|
||||
assert remaining is not None
|
||||
assert remaining.languages[Language.FI].text_segments[0].message_id == 101
|
||||
assert remaining.languages[Language.SV].text_segments[0].message_id == 102
|
||||
assert remaining.discussion_message_ids == {102: 202}
|
||||
|
||||
|
||||
def test_apply_discussion_group_pinning_reports_permission_failures(monkeypatch) -> None:
|
||||
class FakeTelegramAPI:
|
||||
def __init__(self, bot_token: str):
|
||||
self.bot_token = bot_token
|
||||
|
||||
def pin_chat_message(self, chat_id: str, message_id: int, disable_notification: bool = True) -> bool:
|
||||
raise TelegramApiError("Bad Request: not enough rights to manage pinned messages in the chat")
|
||||
|
||||
def unpin_chat_message(self, chat_id: str, message_id: int) -> bool:
|
||||
raise TelegramApiError("Bad Request: not enough rights to manage pinned messages in the chat")
|
||||
|
||||
monkeypatch.setattr("linkki_poster.broadcast_ops.TelegramAPI", FakeTelegramAPI)
|
||||
|
||||
binding = PublishBinding(
|
||||
global_message_ids=[],
|
||||
global_attached_to_language=None,
|
||||
languages={
|
||||
Language.FI: LanguageBinding(
|
||||
language=Language.FI,
|
||||
image_message_ids=[],
|
||||
text_segments=[TextSegmentBinding(message_id=101, mode="message")],
|
||||
primary_message_id=101,
|
||||
primary_message_url="https://t.me/c/123/101",
|
||||
discussion_message_id=201,
|
||||
),
|
||||
Language.SV: LanguageBinding(
|
||||
language=Language.SV,
|
||||
image_message_ids=[],
|
||||
text_segments=[TextSegmentBinding(message_id=102, mode="message")],
|
||||
primary_message_id=102,
|
||||
primary_message_url="https://t.me/c/123/102",
|
||||
discussion_message_id=202,
|
||||
),
|
||||
},
|
||||
discussion_message_ids={101: 201, 102: 202},
|
||||
)
|
||||
|
||||
result = apply_discussion_group_pinning(
|
||||
Config(bot_token="token", chat_id="-1001", channel_username="channel", discussion_group_id="-2001"),
|
||||
binding,
|
||||
[Language.FI, Language.SV],
|
||||
"first",
|
||||
)
|
||||
|
||||
assert result.binding == binding
|
||||
assert result.pinning_failed is True
|
||||
@@ -0,0 +1,53 @@
|
||||
from pathlib import Path
|
||||
|
||||
from linkki_poster.cli import apply_language_order, load_config, load_config_strict
|
||||
from linkki_poster.models import Language, LanguageAssets
|
||||
|
||||
|
||||
def test_load_config_strict_reads_required_fields(tmp_path: Path) -> None:
|
||||
config_path = tmp_path / "telegram_config.json"
|
||||
config_path.write_text('{"bot_token":"token","chat_id":"-100123"}', encoding="utf-8")
|
||||
|
||||
config = load_config_strict(tmp_path)
|
||||
|
||||
assert config.bot_token == "token"
|
||||
assert config.chat_id == "-100123"
|
||||
assert config.channel_username == ""
|
||||
|
||||
|
||||
def test_load_config_strict_fails_when_missing(tmp_path: Path) -> None:
|
||||
try:
|
||||
load_config_strict(tmp_path)
|
||||
except FileNotFoundError:
|
||||
return
|
||||
raise AssertionError("Expected FileNotFoundError")
|
||||
|
||||
|
||||
def test_load_config_strict_fails_when_required_field_missing(tmp_path: Path) -> None:
|
||||
config_path = tmp_path / "telegram_config.json"
|
||||
config_path.write_text('{"bot_token":"token"}', encoding="utf-8")
|
||||
|
||||
try:
|
||||
load_config_strict(tmp_path)
|
||||
except ValueError:
|
||||
return
|
||||
raise AssertionError("Expected ValueError")
|
||||
|
||||
|
||||
def test_apply_language_order_uses_requested_prefix_and_keeps_rest(tmp_path: Path) -> None:
|
||||
fi = LanguageAssets(Language.FI, tmp_path / "fi.md", "fi", [])
|
||||
sv = LanguageAssets(Language.SV, tmp_path / "sv.md", "sv", [])
|
||||
en = LanguageAssets(Language.EN, tmp_path / "en.md", "en", [])
|
||||
|
||||
ordered = apply_language_order([fi, sv, en], "en,fi")
|
||||
|
||||
assert [item.language for item in ordered] == [Language.EN, Language.FI, Language.SV]
|
||||
|
||||
|
||||
def test_load_config_interactive_prompts_missing_values(tmp_path: Path) -> None:
|
||||
answers = iter(["token_from_prompt", "-100123", "publicname"])
|
||||
config = load_config(tmp_path, non_interactive=False, input_fn=lambda _: next(answers))
|
||||
|
||||
assert config.bot_token == "token_from_prompt"
|
||||
assert config.chat_id == "-100123"
|
||||
assert config.channel_username == "publicname"
|
||||
@@ -0,0 +1,78 @@
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
from webapp.auth import load_users
|
||||
from webapp.config import ConfigFileError, load_webapp_config
|
||||
from webapp.i18n import load_translation_catalog
|
||||
from webapp.telegram_targets import load_telegram_target_catalog
|
||||
|
||||
|
||||
def test_load_users_reports_invalid_json_with_path(tmp_path: Path) -> None:
|
||||
users_file = tmp_path / "users.json"
|
||||
users_file.write_text("{broken", encoding="utf-8")
|
||||
|
||||
try:
|
||||
load_users(users_file)
|
||||
except ConfigFileError as error:
|
||||
assert str(users_file) in str(error)
|
||||
else:
|
||||
raise AssertionError("Expected ConfigFileError")
|
||||
|
||||
|
||||
def test_load_telegram_target_catalog_reports_invalid_json_with_path(tmp_path: Path) -> None:
|
||||
config_dir = tmp_path / "config"
|
||||
config_dir.mkdir()
|
||||
config_file = config_dir / "telegram_config.json"
|
||||
config_file.write_text("{broken", encoding="utf-8")
|
||||
|
||||
try:
|
||||
load_telegram_target_catalog(config_dir)
|
||||
except ConfigFileError as error:
|
||||
assert str(config_file) in str(error)
|
||||
else:
|
||||
raise AssertionError("Expected ConfigFileError")
|
||||
|
||||
|
||||
def test_load_webapp_config_reports_invalid_json_with_path(tmp_path: Path) -> None:
|
||||
config_dir = tmp_path / "config"
|
||||
config_dir.mkdir()
|
||||
config_file = config_dir / "app_config.json"
|
||||
config_file.write_text("{broken", encoding="utf-8")
|
||||
|
||||
previous = os.environ.get("LINKKI_DATA_DIR")
|
||||
os.environ["LINKKI_DATA_DIR"] = str(tmp_path)
|
||||
try:
|
||||
try:
|
||||
load_webapp_config()
|
||||
except ConfigFileError as error:
|
||||
assert str(config_file) in str(error)
|
||||
else:
|
||||
raise AssertionError("Expected ConfigFileError")
|
||||
finally:
|
||||
if previous is None:
|
||||
os.environ.pop("LINKKI_DATA_DIR", None)
|
||||
else:
|
||||
os.environ["LINKKI_DATA_DIR"] = previous
|
||||
|
||||
|
||||
def test_load_translation_catalog_reports_invalid_json_with_path(tmp_path: Path) -> None:
|
||||
translations_dir = tmp_path / "translations"
|
||||
translations_dir.mkdir()
|
||||
config_file = translations_dir / "catalog.json"
|
||||
config_file.write_text('{"a":{"en":"line1\nline2"}}', encoding="utf-8")
|
||||
|
||||
from webapp import i18n as i18n_module
|
||||
|
||||
original = i18n_module.TRANSLATION_CATALOG_FILE
|
||||
i18n_module.TRANSLATION_CATALOG_FILE = config_file
|
||||
try:
|
||||
try:
|
||||
load_translation_catalog()
|
||||
except ConfigFileError as error:
|
||||
assert str(config_file) in str(error)
|
||||
assert "raw line breaks" in str(error)
|
||||
else:
|
||||
raise AssertionError("Expected ConfigFileError")
|
||||
finally:
|
||||
i18n_module.TRANSLATION_CATALOG_FILE = original
|
||||
@@ -0,0 +1,125 @@
|
||||
from linkki_poster.formatting import (
|
||||
CAPTION_LIMIT,
|
||||
estimate_caption_length,
|
||||
parse_markdown_template,
|
||||
render_preview_html,
|
||||
render_template,
|
||||
validate_markdown_syntax,
|
||||
)
|
||||
from linkki_poster.models import Language
|
||||
|
||||
|
||||
def test_markdown_subset_and_placeholder_rendering() -> None:
|
||||
raw = (
|
||||
"# title\n"
|
||||
"**bold** _italic_ ++under++ ~~strike~~ ||spoil|| `code` [site](https://example.com)\n"
|
||||
"Go [English](en) and [Finnish](fi)\n"
|
||||
)
|
||||
parsed = parse_markdown_template(raw)
|
||||
|
||||
unresolved = render_template(parsed, {})
|
||||
assert "<b>TITLE</b>" in unresolved
|
||||
assert "<b>bold</b>" in unresolved
|
||||
assert "<i>italic</i>" in unresolved
|
||||
assert "<u>under</u>" in unresolved
|
||||
assert "<s>strike</s>" in unresolved
|
||||
assert "<tg-spoiler>spoil</tg-spoiler>" in unresolved
|
||||
assert "<code>code</code>" in unresolved
|
||||
assert '<a href="https://example.com">site</a>' in unresolved
|
||||
assert "English" in unresolved
|
||||
assert "Finnish" in unresolved
|
||||
assert 'href="https://t.me/' not in unresolved
|
||||
|
||||
resolved = render_template(
|
||||
parsed,
|
||||
{
|
||||
Language.EN: "https://t.me/testchan/123",
|
||||
Language.FI: "https://t.me/testchan/456",
|
||||
},
|
||||
)
|
||||
assert '<a href="https://t.me/testchan/123">English</a>' in resolved
|
||||
assert '<a href="https://t.me/testchan/456">Finnish</a>' in resolved
|
||||
|
||||
|
||||
def test_caption_length_estimate_uses_worst_case_links() -> None:
|
||||
parsed = parse_markdown_template("[EN](en) " * 80)
|
||||
assert estimate_caption_length(parsed, "channel") > CAPTION_LIMIT
|
||||
|
||||
|
||||
def test_multiline_spoiler_is_supported() -> None:
|
||||
parsed = parse_markdown_template("||line1\n\nline2||")
|
||||
rendered = render_template(parsed, {})
|
||||
assert "<tg-spoiler>line1\n\nline2</tg-spoiler>" in rendered
|
||||
|
||||
|
||||
def test_validate_markdown_syntax_finds_unclosed_markers() -> None:
|
||||
issues = validate_markdown_syntax("Can we do **[bold links](fi)")
|
||||
messages = [issue.message for issue in issues]
|
||||
assert any("**" in message for message in messages)
|
||||
|
||||
|
||||
def test_lists_and_blockquotes_are_rendered_for_telegram_html() -> None:
|
||||
parsed = parse_markdown_template(
|
||||
"- item\n"
|
||||
" - nested\n"
|
||||
"1. first\n"
|
||||
" 1. nested first\n"
|
||||
"> quoted\n"
|
||||
"> text\n"
|
||||
)
|
||||
rendered = render_template(parsed, {})
|
||||
assert "• item" in rendered
|
||||
assert "\u00a0\u00a0\u00a0\u00a0• nested" in rendered
|
||||
assert "1. first" in rendered
|
||||
assert "\u00a0\u00a0\u00a0\u00a01. nested first" in rendered
|
||||
assert "<blockquote>quoted\ntext</blockquote>" in rendered
|
||||
|
||||
|
||||
def test_escaped_list_markers_and_heading_markers_stay_literal() -> None:
|
||||
parsed = parse_markdown_template("\\* not a list\n1\\. not numbered\n\\# not a heading")
|
||||
rendered = render_template(parsed, {})
|
||||
assert "* not a list" in rendered
|
||||
assert "1. not numbered" in rendered
|
||||
assert "# not a heading" in rendered
|
||||
|
||||
|
||||
def test_escaped_backticks_and_link_brackets_stay_literal() -> None:
|
||||
parsed = parse_markdown_template(
|
||||
r"test \`code` test" "\n"
|
||||
r"\```" "\n"
|
||||
"code\n"
|
||||
"```\n"
|
||||
r"\[label\]\(url\)"
|
||||
)
|
||||
rendered = render_template(parsed, {})
|
||||
assert r"test `code` test" in rendered
|
||||
assert "<code>code</code>" not in rendered
|
||||
assert "<pre><code>" not in rendered
|
||||
assert "[label](url)" in rendered
|
||||
|
||||
|
||||
def test_escaped_cross_language_link_stays_literal() -> None:
|
||||
parsed = parse_markdown_template(r"\[English](en)")
|
||||
rendered = render_template(parsed, {Language.EN: "https://t.me/test/123"})
|
||||
assert "[English](en)" in rendered
|
||||
assert 'href="https://t.me/test/123"' not in rendered
|
||||
|
||||
|
||||
def test_preview_renders_fake_links_for_available_targets() -> None:
|
||||
preview = render_preview_html("[English](en) [Finnish](fi)", {Language.EN})
|
||||
assert '<a href="#en">English</a>' in preview
|
||||
assert "Finnish" in preview
|
||||
assert 'href="#fi"' not in preview
|
||||
|
||||
|
||||
def test_apostrophes_are_not_escaped_in_headings_or_plain_text() -> None:
|
||||
heading = render_template(parse_markdown_template("#THERE'S"), {})
|
||||
plain_heading_char = render_template(parse_markdown_template("#'"), {})
|
||||
bold = render_template(parse_markdown_template("**THERE'S**"), {})
|
||||
|
||||
assert "<b>THERE'S</b>" in heading
|
||||
assert "<b>'</b>" in plain_heading_char
|
||||
assert "<b>THERE'S</b>" in bold
|
||||
assert "'" not in heading
|
||||
assert "'" not in plain_heading_char
|
||||
assert "'" not in bold
|
||||
@@ -0,0 +1,46 @@
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
from webapp.i18n import load_translations
|
||||
|
||||
|
||||
def test_load_translations_uses_english_fallback_for_missing_values() -> None:
|
||||
translations = load_translations("fi")
|
||||
|
||||
assert translations["nav.instructions"]
|
||||
assert translations["app.title"] == "Linkki Botti"
|
||||
|
||||
|
||||
def test_load_translations_prefers_requested_language_value() -> None:
|
||||
translations = load_translations("sv")
|
||||
|
||||
assert translations["nav.instructions"] != "Instructions"
|
||||
|
||||
|
||||
def test_translation_catalog_has_no_common_utf8_mojibake_in_finnish_or_swedish() -> None:
|
||||
catalog = json.loads(
|
||||
(Path(__file__).resolve().parents[1] / "webapp/translations/catalog.json").read_text(
|
||||
encoding="utf-8"
|
||||
)
|
||||
)
|
||||
|
||||
def visit(value: object) -> list[str]:
|
||||
if isinstance(value, str):
|
||||
return [value]
|
||||
if isinstance(value, list):
|
||||
result: list[str] = []
|
||||
for item in value:
|
||||
result.extend(visit(item))
|
||||
return result
|
||||
if isinstance(value, dict):
|
||||
result: list[str] = []
|
||||
for nested in value.values():
|
||||
result.extend(visit(nested))
|
||||
return result
|
||||
return []
|
||||
|
||||
suspicious_markers = ("\u00c3", "\u00c2", "\ufffd")
|
||||
for entry in catalog.values():
|
||||
for language in ("fi", "sv"):
|
||||
for text in visit(entry.get(language, "")):
|
||||
assert not any(marker in text for marker in suspicious_markers), text
|
||||
@@ -0,0 +1,25 @@
|
||||
from pathlib import Path
|
||||
|
||||
from linkki_poster.models import Language, LanguageAssets
|
||||
from linkki_poster.ordering import parse_order_input, prompt_language_order
|
||||
|
||||
|
||||
def test_parse_order_input_accepts_expected_formats() -> None:
|
||||
assert parse_order_input("12", 2) == [1, 2]
|
||||
assert parse_order_input("2 1", 2) == [2, 1]
|
||||
assert parse_order_input("1,2", 2) == [1, 2]
|
||||
|
||||
|
||||
def test_parse_order_input_rejects_invalid_orders() -> None:
|
||||
assert parse_order_input("11", 2) is None
|
||||
assert parse_order_input("13", 2) is None
|
||||
assert parse_order_input("1", 2) is None
|
||||
|
||||
|
||||
def test_prompt_language_order_accepts_empty_as_default(tmp_path: Path) -> None:
|
||||
fi = LanguageAssets(Language.FI, tmp_path / "fi.md", "fi", [])
|
||||
en = LanguageAssets(Language.EN, tmp_path / "en.md", "en", [])
|
||||
|
||||
ordered = prompt_language_order([fi, en], input_fn=lambda _: "")
|
||||
|
||||
assert [item.language for item in ordered] == [Language.FI, Language.EN]
|
||||
@@ -0,0 +1,10 @@
|
||||
from linkki_poster.progress import LogProgressDisplay
|
||||
|
||||
|
||||
def test_log_progress_display_keeps_only_latest_step_state() -> None:
|
||||
progress = LogProgressDisplay()
|
||||
step = progress.add_step("Post English text")
|
||||
progress.update(step, "in_progress")
|
||||
progress.update(step, "done", "split part 1/2")
|
||||
|
||||
assert progress.get_output() == "[x] Post English text - split part 1/2"
|
||||
@@ -0,0 +1,60 @@
|
||||
from pathlib import Path
|
||||
|
||||
from linkki_poster.models import Language
|
||||
from linkki_poster.scanner import scan_directory
|
||||
|
||||
|
||||
def write_file(path: Path, text: str = "x") -> None:
|
||||
path.write_text(text, encoding="utf-8")
|
||||
|
||||
|
||||
def test_global_prefix_first_family_wins(tmp_path: Path) -> None:
|
||||
write_file(tmp_path / "Kuva1.jpg")
|
||||
write_file(tmp_path / "Image1.jpg")
|
||||
write_file(tmp_path / "Suomi.md", "hello")
|
||||
|
||||
result = scan_directory(tmp_path)
|
||||
assert [path.name for path in result.global_images] == ["Kuva1.jpg"]
|
||||
|
||||
|
||||
def test_numeric_suffix_rule_and_case_insensitive_matching(tmp_path: Path) -> None:
|
||||
write_file(tmp_path / "KuvaA.jpg")
|
||||
write_file(tmp_path / "Kuva2.jpg")
|
||||
write_file(tmp_path / "sUoMi.MD", "hello")
|
||||
write_file(tmp_path / "sUoMi10.PNG")
|
||||
write_file(tmp_path / "sUoMi2.jpg")
|
||||
|
||||
result = scan_directory(tmp_path)
|
||||
assert [path.name for path in result.global_images] == ["Kuva2.jpg"]
|
||||
assert len(result.languages) == 1
|
||||
assert result.languages[0].language == Language.FI
|
||||
assert [path.name for path in result.languages[0].images] == ["sUoMi2.jpg", "sUoMi10.PNG"]
|
||||
|
||||
|
||||
def test_natural_sort_and_text_precedence(tmp_path: Path) -> None:
|
||||
write_file(tmp_path / "English.md", "right")
|
||||
write_file(tmp_path / "english10.png")
|
||||
write_file(tmp_path / "english9.webp")
|
||||
write_file(tmp_path / "english9.jpg")
|
||||
write_file(tmp_path / "english.jpg")
|
||||
|
||||
result = scan_directory(tmp_path)
|
||||
assert len(result.languages) == 1
|
||||
assets = result.languages[0]
|
||||
assert assets.text_file.name == "English.md"
|
||||
assert [path.name for path in assets.images] == [
|
||||
"english.jpg",
|
||||
"english9.jpg",
|
||||
"english9.webp",
|
||||
"english10.png",
|
||||
]
|
||||
|
||||
|
||||
def test_empty_text_file_is_skipped_as_missing_language(tmp_path: Path) -> None:
|
||||
write_file(tmp_path / "Suomi.md", "Hei")
|
||||
write_file(tmp_path / "Svenska.md", " \n\t")
|
||||
write_file(tmp_path / "English.md", "")
|
||||
|
||||
result = scan_directory(tmp_path)
|
||||
|
||||
assert [assets.language for assets in result.languages] == [Language.FI]
|
||||
@@ -0,0 +1,50 @@
|
||||
import asyncio
|
||||
from datetime import datetime, timedelta
|
||||
from pathlib import Path
|
||||
|
||||
from fastapi import FastAPI
|
||||
|
||||
from webapp import app as app_module
|
||||
|
||||
|
||||
def test_startup_recovery_runs_overdue_scheduled_posts_without_waiting_for_retry() -> None:
|
||||
now = datetime.now().astimezone()
|
||||
item = {
|
||||
"status": "scheduled",
|
||||
"scheduled_for": (now - timedelta(minutes=5)).isoformat(),
|
||||
"scheduled_next_attempt_at": (now + timedelta(minutes=30)).isoformat(),
|
||||
}
|
||||
|
||||
assert app_module.is_scheduled_publish_due(item, recover_missed_jobs=True) is True
|
||||
assert app_module.is_scheduled_publish_due(item, recover_missed_jobs=False) is False
|
||||
|
||||
|
||||
def test_manual_publish_retries_once_after_a_failed_attempt(monkeypatch) -> None:
|
||||
app = FastAPI()
|
||||
directory_name = "retry-once"
|
||||
app.state.operations = {
|
||||
directory_name: {
|
||||
"running": True,
|
||||
"completed": False,
|
||||
"output": "",
|
||||
"result": None,
|
||||
"error": None,
|
||||
}
|
||||
}
|
||||
app.state.operation_tasks = {directory_name: object()}
|
||||
app.state.scheduler_lock = asyncio.Lock()
|
||||
attempts = []
|
||||
|
||||
async def fake_run_publish_action(post_dir: Path, config, progress):
|
||||
attempts.append(post_dir)
|
||||
return {"status": "failed", "output": "still unavailable"}
|
||||
|
||||
monkeypatch.setattr(app_module, "run_publish_action_with_progress", fake_run_publish_action)
|
||||
|
||||
asyncio.run(app_module.run_publish_operation(app, directory_name, Path("/tmp/retry-once"), None))
|
||||
|
||||
assert attempts == [Path("/tmp/retry-once"), Path("/tmp/retry-once")]
|
||||
operation = app.state.operations[directory_name]
|
||||
assert operation["completed"] is True
|
||||
assert operation["result"] == "failed"
|
||||
assert directory_name not in app.state.operation_tasks
|
||||
@@ -0,0 +1,588 @@
|
||||
from datetime import datetime, timedelta
|
||||
from pathlib import Path
|
||||
|
||||
from linkki_poster.models import Language, LanguageBinding, PublishBinding, TextSegmentBinding
|
||||
from webapp.storage import (
|
||||
append_status_output,
|
||||
clear_status_history,
|
||||
create_broadcast,
|
||||
is_telegram_delete_expired,
|
||||
list_broadcasts,
|
||||
load_metadata,
|
||||
mark_publish_success,
|
||||
mark_publish_success_with_result,
|
||||
mark_scheduled_publish_failure,
|
||||
mark_scheduled,
|
||||
move_broadcast,
|
||||
normalize_saved_text,
|
||||
scheduled_retry_delay,
|
||||
save_broadcast_changes,
|
||||
telegram_delete_deadline,
|
||||
normalize_discussion_pinning_mode,
|
||||
unschedule,
|
||||
)
|
||||
|
||||
|
||||
def test_title_change_renames_directory_and_updates_slug(tmp_path: Path) -> None:
|
||||
directory_name = create_broadcast(tmp_path, "First title")
|
||||
post_dir = tmp_path / directory_name
|
||||
|
||||
result = save_broadcast_changes(
|
||||
post_dir,
|
||||
{
|
||||
"title": {
|
||||
"base_revision": 0,
|
||||
"value": "Renamed title",
|
||||
},
|
||||
"texts": {},
|
||||
"images": {},
|
||||
},
|
||||
)
|
||||
|
||||
renamed_dir = result["post_dir"]
|
||||
assert renamed_dir.name == "renamed-title"
|
||||
assert renamed_dir.exists()
|
||||
metadata = load_metadata(renamed_dir)
|
||||
assert metadata["title"] == "Renamed title"
|
||||
assert metadata["slug"] == "renamed-title"
|
||||
|
||||
|
||||
def test_linked_broadcast_allows_title_and_text_changes_but_rejects_other_locked_fields(tmp_path: Path) -> None:
|
||||
directory_name = create_broadcast(tmp_path, "Linked")
|
||||
post_dir = tmp_path / directory_name
|
||||
binding = PublishBinding(
|
||||
global_message_ids=[],
|
||||
global_attached_to_language=None,
|
||||
languages={
|
||||
Language.FI: LanguageBinding(
|
||||
language=Language.FI,
|
||||
image_message_ids=[],
|
||||
text_segments=[TextSegmentBinding(message_id=101, mode="message")],
|
||||
primary_message_id=101,
|
||||
primary_message_url="https://t.me/c/123/101",
|
||||
)
|
||||
},
|
||||
discussion_message_ids={},
|
||||
)
|
||||
mark_publish_success(post_dir, binding, "ok")
|
||||
|
||||
result = save_broadcast_changes(
|
||||
post_dir,
|
||||
{
|
||||
"title": {"base_revision": 0, "value": "Renamed linked"},
|
||||
"language_order": {"base_revision": 0, "value": ["en", "fi", "sv"]},
|
||||
"images": {
|
||||
"global": {"base_revision": 0, "value": []},
|
||||
},
|
||||
"texts": {
|
||||
"fi": {"base_revision": 0, "value": "Updated"},
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
renamed_dir = result["post_dir"]
|
||||
assert renamed_dir.name == "renamed-linked"
|
||||
assert result["results"]["title"]["status"] == "saved"
|
||||
assert result["results"]["language_order"]["status"] == "rejected"
|
||||
assert result["results"]["images"]["status"] == "rejected"
|
||||
assert result["results"]["fi.text"]["status"] == "saved"
|
||||
metadata = load_metadata(renamed_dir)
|
||||
assert metadata["title"] == "Renamed linked"
|
||||
assert metadata["status"] == "modified"
|
||||
assert metadata["dirty_text_languages"] == ["fi"]
|
||||
|
||||
|
||||
def test_normalize_saved_text_ignores_trailing_whitespace() -> None:
|
||||
assert normalize_saved_text("Line 1 \nLine 2\t\t\n\n") == "Line 1\nLine 2"
|
||||
|
||||
|
||||
def test_unschedule_restores_modified_when_linked_and_dirty(tmp_path: Path) -> None:
|
||||
directory_name = create_broadcast(tmp_path, "Scheduled update")
|
||||
post_dir = tmp_path / directory_name
|
||||
binding = PublishBinding(
|
||||
global_message_ids=[],
|
||||
global_attached_to_language=None,
|
||||
languages={
|
||||
Language.FI: LanguageBinding(
|
||||
language=Language.FI,
|
||||
image_message_ids=[],
|
||||
text_segments=[TextSegmentBinding(message_id=101, mode="message")],
|
||||
primary_message_id=101,
|
||||
primary_message_url="https://t.me/c/123/101",
|
||||
)
|
||||
},
|
||||
discussion_message_ids={},
|
||||
)
|
||||
mark_publish_success(post_dir, binding, "ok")
|
||||
save_broadcast_changes(
|
||||
post_dir,
|
||||
{
|
||||
"texts": {
|
||||
"fi": {"base_revision": 0, "value": "Updated"},
|
||||
},
|
||||
},
|
||||
)
|
||||
mark_scheduled(post_dir, "2026-03-02T12:00:00+02:00")
|
||||
|
||||
unschedule(post_dir)
|
||||
|
||||
metadata = load_metadata(post_dir)
|
||||
assert metadata["status"] == "modified"
|
||||
assert metadata["scheduled_for"] is None
|
||||
|
||||
|
||||
def test_create_broadcast_uses_slug_only_directory_name(tmp_path: Path) -> None:
|
||||
directory_name = create_broadcast(tmp_path, "Test Broadcast")
|
||||
assert directory_name == "test-broadcast"
|
||||
assert (tmp_path / "test-broadcast").exists()
|
||||
|
||||
|
||||
def test_create_broadcast_defaults_pinning_mode_to_first(tmp_path: Path) -> None:
|
||||
directory_name = create_broadcast(tmp_path, "Pin first")
|
||||
|
||||
metadata = load_metadata(tmp_path / directory_name)
|
||||
|
||||
assert metadata["discussion_pinning_mode"] == "first"
|
||||
assert metadata["delete_pin_service_messages"] is True
|
||||
|
||||
|
||||
def test_normalize_discussion_pinning_mode_accepts_none() -> None:
|
||||
assert normalize_discussion_pinning_mode("none") == "none"
|
||||
|
||||
|
||||
def test_append_status_output_keeps_previous_entries() -> None:
|
||||
first = append_status_output("", "First log line", "published", "2026-04-17T10:00:00+00:00")
|
||||
combined = append_status_output(first, "Second log line", "updated", "2026-04-17T11:00:00+00:00")
|
||||
|
||||
assert "[2026-04-17T10:00:00+00:00] published" in combined
|
||||
assert "First log line" in combined
|
||||
assert "[2026-04-17T11:00:00+00:00] updated" in combined
|
||||
assert "Second log line" in combined
|
||||
|
||||
|
||||
def test_load_metadata_recovers_from_broken_post_json_and_manual_directory_name(tmp_path: Path) -> None:
|
||||
post_dir = tmp_path / "manual broadcast_name"
|
||||
post_dir.mkdir()
|
||||
(post_dir / "post.json").write_text("{broken", encoding="utf-8")
|
||||
|
||||
metadata = load_metadata(post_dir)
|
||||
|
||||
assert metadata["title"] == "Manual broadcast name"
|
||||
assert metadata["slug"] == "manual-broadcast-name"
|
||||
assert metadata["status"] == "draft"
|
||||
|
||||
|
||||
def test_schedule_draft_can_be_saved_without_scheduling(tmp_path: Path) -> None:
|
||||
directory_name = create_broadcast(tmp_path, "Saved schedule")
|
||||
post_dir = tmp_path / directory_name
|
||||
|
||||
result = save_broadcast_changes(
|
||||
post_dir,
|
||||
{
|
||||
"schedule_draft_for": {
|
||||
"base_revision": 0,
|
||||
"value": "2026-03-30T14:45",
|
||||
},
|
||||
"texts": {},
|
||||
"images": {},
|
||||
},
|
||||
)
|
||||
|
||||
assert result["results"]["schedule_draft_for"]["status"] == "saved"
|
||||
metadata = load_metadata(post_dir)
|
||||
assert metadata["schedule_draft_for"] == "2026-03-30T14:45"
|
||||
assert metadata["scheduled_for"] is None
|
||||
assert metadata["status"] == "draft"
|
||||
|
||||
mark_scheduled(post_dir, "2026-03-30T14:45:00+02:00", "2026-03-30T14:45")
|
||||
unschedule(post_dir)
|
||||
|
||||
metadata = load_metadata(post_dir)
|
||||
assert metadata["schedule_draft_for"] == "2026-03-30T14:45"
|
||||
|
||||
|
||||
def test_scheduled_publish_failure_keeps_the_schedule_and_records_backoff(tmp_path: Path) -> None:
|
||||
directory_name = create_broadcast(tmp_path, "Retry scheduled")
|
||||
post_dir = tmp_path / directory_name
|
||||
mark_scheduled(post_dir, "2026-03-30T14:45:00+02:00")
|
||||
|
||||
mark_scheduled_publish_failure(post_dir, "Temporary Telegram connection failure")
|
||||
|
||||
metadata = load_metadata(post_dir)
|
||||
assert metadata["status"] == "scheduled"
|
||||
assert metadata["scheduled_for"] == "2026-03-30T14:45:00+02:00"
|
||||
assert metadata["scheduled_retry_count"] == 1
|
||||
assert metadata["scheduled_next_attempt_at"] is not None
|
||||
assert metadata["last_result"] == "scheduled_publish_retry"
|
||||
assert "Temporary Telegram connection failure" in metadata["last_output"]
|
||||
|
||||
|
||||
def test_scheduled_retry_backoff_caps_at_thirty_minutes() -> None:
|
||||
assert scheduled_retry_delay(1) == timedelta(minutes=1)
|
||||
assert scheduled_retry_delay(2) == timedelta(minutes=5)
|
||||
assert scheduled_retry_delay(3) == timedelta(minutes=15)
|
||||
assert scheduled_retry_delay(4) == timedelta(minutes=30)
|
||||
assert scheduled_retry_delay(99) == timedelta(minutes=30)
|
||||
|
||||
|
||||
def test_telegram_target_ids_are_saved_for_unlinked_broadcast(tmp_path: Path) -> None:
|
||||
directory_name = create_broadcast(tmp_path, "Telegram target test")
|
||||
post_dir = tmp_path / directory_name
|
||||
|
||||
result = save_broadcast_changes(
|
||||
post_dir,
|
||||
{
|
||||
"telegram_bot_id": {
|
||||
"base_revision": 0,
|
||||
"value": "123456",
|
||||
},
|
||||
"telegram_chat_id": {
|
||||
"base_revision": 0,
|
||||
"value": "-100123",
|
||||
},
|
||||
"texts": {},
|
||||
"images": {},
|
||||
},
|
||||
)
|
||||
|
||||
assert result["results"]["telegram_bot_id"]["status"] == "saved"
|
||||
assert result["results"]["telegram_chat_id"]["status"] == "saved"
|
||||
metadata = load_metadata(post_dir)
|
||||
assert metadata["telegram_bot_id"] == "123456"
|
||||
assert metadata["telegram_chat_id"] == "-100123"
|
||||
|
||||
|
||||
def test_linked_broadcast_rejects_telegram_target_id_change(tmp_path: Path) -> None:
|
||||
directory_name = create_broadcast(tmp_path, "Target lock")
|
||||
post_dir = tmp_path / directory_name
|
||||
binding = PublishBinding(
|
||||
global_message_ids=[],
|
||||
global_attached_to_language=None,
|
||||
languages={
|
||||
Language.FI: LanguageBinding(
|
||||
language=Language.FI,
|
||||
image_message_ids=[],
|
||||
text_segments=[TextSegmentBinding(message_id=101, mode="message")],
|
||||
primary_message_id=101,
|
||||
primary_message_url="https://t.me/c/123/101",
|
||||
)
|
||||
},
|
||||
discussion_message_ids={},
|
||||
)
|
||||
mark_publish_success(post_dir, binding, "ok")
|
||||
|
||||
result = save_broadcast_changes(
|
||||
post_dir,
|
||||
{
|
||||
"telegram_bot_id": {"base_revision": 0, "value": "999999"},
|
||||
"telegram_chat_id": {"base_revision": 0, "value": "-100999"},
|
||||
"texts": {},
|
||||
"images": {},
|
||||
},
|
||||
)
|
||||
|
||||
assert result["results"]["telegram_bot_id"]["status"] == "rejected"
|
||||
assert result["results"]["telegram_chat_id"]["status"] == "rejected"
|
||||
|
||||
|
||||
def test_scheduled_broadcast_rejects_telegram_target_id_change(tmp_path: Path) -> None:
|
||||
directory_name = create_broadcast(tmp_path, "Scheduled target lock")
|
||||
post_dir = tmp_path / directory_name
|
||||
mark_scheduled(post_dir, "2026-03-02T12:00:00+02:00")
|
||||
|
||||
result = save_broadcast_changes(
|
||||
post_dir,
|
||||
{
|
||||
"telegram_bot_id": {"base_revision": 0, "value": "999999"},
|
||||
"telegram_chat_id": {"base_revision": 0, "value": "-100999"},
|
||||
"texts": {},
|
||||
"images": {},
|
||||
},
|
||||
)
|
||||
|
||||
assert result["results"]["telegram_bot_id"]["status"] == "rejected"
|
||||
assert result["results"]["telegram_chat_id"]["status"] == "rejected"
|
||||
|
||||
|
||||
def test_publish_success_sets_telegram_published_at_and_delete_deadline(tmp_path: Path) -> None:
|
||||
directory_name = create_broadcast(tmp_path, "Delete window")
|
||||
post_dir = tmp_path / directory_name
|
||||
binding = PublishBinding(
|
||||
global_message_ids=[],
|
||||
global_attached_to_language=None,
|
||||
languages={
|
||||
Language.FI: LanguageBinding(
|
||||
language=Language.FI,
|
||||
image_message_ids=[],
|
||||
text_segments=[TextSegmentBinding(message_id=101, mode="message")],
|
||||
primary_message_id=101,
|
||||
primary_message_url="https://t.me/c/123/101",
|
||||
)
|
||||
},
|
||||
discussion_message_ids={},
|
||||
)
|
||||
|
||||
mark_publish_success(post_dir, binding, "ok")
|
||||
|
||||
metadata = load_metadata(post_dir)
|
||||
assert metadata["telegram_published_at"] is not None
|
||||
deadline = telegram_delete_deadline(metadata)
|
||||
assert deadline is not None
|
||||
assert deadline > datetime.fromisoformat(metadata["telegram_published_at"])
|
||||
|
||||
|
||||
def test_delete_expiry_uses_original_publish_time_not_last_update(tmp_path: Path) -> None:
|
||||
directory_name = create_broadcast(tmp_path, "Delete expiry")
|
||||
post_dir = tmp_path / directory_name
|
||||
binding = PublishBinding(
|
||||
global_message_ids=[],
|
||||
global_attached_to_language=None,
|
||||
languages={
|
||||
Language.FI: LanguageBinding(
|
||||
language=Language.FI,
|
||||
image_message_ids=[],
|
||||
text_segments=[TextSegmentBinding(message_id=101, mode="message")],
|
||||
primary_message_id=101,
|
||||
primary_message_url="https://t.me/c/123/101",
|
||||
)
|
||||
},
|
||||
discussion_message_ids={},
|
||||
)
|
||||
mark_publish_success(post_dir, binding, "ok")
|
||||
metadata = load_metadata(post_dir)
|
||||
original_publish_time = metadata["telegram_published_at"]
|
||||
|
||||
save_broadcast_changes(
|
||||
post_dir,
|
||||
{
|
||||
"texts": {
|
||||
"fi": {"base_revision": 0, "value": "Updated"},
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
metadata = load_metadata(post_dir)
|
||||
assert metadata["telegram_published_at"] == original_publish_time
|
||||
assert is_telegram_delete_expired(
|
||||
metadata,
|
||||
now=datetime.fromisoformat(original_publish_time) + timedelta(hours=48, seconds=1),
|
||||
)
|
||||
|
||||
|
||||
def test_clear_status_history_keeps_status_but_removes_attempt_log(tmp_path: Path) -> None:
|
||||
directory_name = create_broadcast(tmp_path, "History clear")
|
||||
post_dir = tmp_path / directory_name
|
||||
binding = PublishBinding(
|
||||
global_message_ids=[],
|
||||
global_attached_to_language=None,
|
||||
languages={
|
||||
Language.FI: LanguageBinding(
|
||||
language=Language.FI,
|
||||
image_message_ids=[],
|
||||
text_segments=[TextSegmentBinding(message_id=101, mode="message")],
|
||||
primary_message_id=101,
|
||||
primary_message_url="https://t.me/c/123/101",
|
||||
)
|
||||
},
|
||||
discussion_message_ids={},
|
||||
)
|
||||
|
||||
|
||||
def test_linked_announcement_pinning_change_marks_modified_and_unschedule_preserves_it(tmp_path: Path) -> None:
|
||||
directory_name = create_broadcast(tmp_path, "Pinning update")
|
||||
post_dir = tmp_path / directory_name
|
||||
binding = PublishBinding(
|
||||
global_message_ids=[],
|
||||
global_attached_to_language=None,
|
||||
languages={
|
||||
Language.FI: LanguageBinding(
|
||||
language=Language.FI,
|
||||
image_message_ids=[],
|
||||
text_segments=[TextSegmentBinding(message_id=101, mode="message")],
|
||||
primary_message_id=101,
|
||||
primary_message_url="https://t.me/c/123/101",
|
||||
discussion_message_id=201,
|
||||
)
|
||||
},
|
||||
discussion_message_ids={101: 201},
|
||||
)
|
||||
mark_publish_success(post_dir, binding, "ok")
|
||||
|
||||
result = save_broadcast_changes(
|
||||
post_dir,
|
||||
{
|
||||
"discussion_pinning_mode": {"base_revision": 0, "value": "all"},
|
||||
"texts": {},
|
||||
"images": {},
|
||||
},
|
||||
)
|
||||
|
||||
assert result["results"]["discussion_pinning_mode"]["status"] == "saved"
|
||||
metadata = load_metadata(post_dir)
|
||||
assert metadata["status"] == "modified"
|
||||
assert metadata["dirty_discussion_pinning"] is True
|
||||
|
||||
mark_scheduled(post_dir, "2026-03-02T12:00:00+02:00")
|
||||
unschedule(post_dir)
|
||||
metadata = load_metadata(post_dir)
|
||||
assert metadata["status"] == "modified"
|
||||
mark_publish_success(post_dir, binding, "posted output")
|
||||
|
||||
clear_status_history(post_dir)
|
||||
|
||||
metadata = load_metadata(post_dir)
|
||||
assert metadata["status"] == "published"
|
||||
assert metadata["last_attempt_at"] is None
|
||||
assert metadata["last_output"] == ""
|
||||
assert metadata["last_result"] is None
|
||||
|
||||
|
||||
def test_linked_announcement_pinning_mode_none_is_saved(tmp_path: Path) -> None:
|
||||
directory_name = create_broadcast(tmp_path, "Pinning none")
|
||||
post_dir = tmp_path / directory_name
|
||||
binding = PublishBinding(
|
||||
global_message_ids=[],
|
||||
global_attached_to_language=None,
|
||||
languages={
|
||||
Language.FI: LanguageBinding(
|
||||
language=Language.FI,
|
||||
image_message_ids=[],
|
||||
text_segments=[TextSegmentBinding(message_id=101, mode="message")],
|
||||
primary_message_id=101,
|
||||
primary_message_url="https://t.me/c/123/101",
|
||||
discussion_message_id=201,
|
||||
)
|
||||
},
|
||||
discussion_message_ids={101: 201},
|
||||
)
|
||||
mark_publish_success(post_dir, binding, "ok")
|
||||
|
||||
result = save_broadcast_changes(
|
||||
post_dir,
|
||||
{
|
||||
"discussion_pinning_mode": {"base_revision": 0, "value": "none"},
|
||||
"texts": {},
|
||||
"images": {},
|
||||
},
|
||||
)
|
||||
|
||||
assert result["results"]["discussion_pinning_mode"]["status"] == "saved"
|
||||
metadata = load_metadata(post_dir)
|
||||
assert metadata["discussion_pinning_mode"] == "none"
|
||||
assert metadata["status"] == "modified"
|
||||
assert metadata["dirty_discussion_pinning"] is True
|
||||
|
||||
|
||||
def test_linked_announcement_delete_service_message_toggle_marks_modified(tmp_path: Path) -> None:
|
||||
directory_name = create_broadcast(tmp_path, "Service cleanup update")
|
||||
post_dir = tmp_path / directory_name
|
||||
binding = PublishBinding(
|
||||
global_message_ids=[],
|
||||
global_attached_to_language=None,
|
||||
languages={
|
||||
Language.FI: LanguageBinding(
|
||||
language=Language.FI,
|
||||
image_message_ids=[],
|
||||
text_segments=[TextSegmentBinding(message_id=101, mode="message")],
|
||||
primary_message_id=101,
|
||||
primary_message_url="https://t.me/c/123/101",
|
||||
discussion_message_id=201,
|
||||
)
|
||||
},
|
||||
discussion_message_ids={101: 201},
|
||||
)
|
||||
mark_publish_success(post_dir, binding, "ok")
|
||||
|
||||
result = save_broadcast_changes(
|
||||
post_dir,
|
||||
{
|
||||
"delete_pin_service_messages": {"base_revision": 0, "value": False},
|
||||
"texts": {},
|
||||
"images": {},
|
||||
},
|
||||
)
|
||||
|
||||
assert result["results"]["delete_pin_service_messages"]["status"] == "saved"
|
||||
metadata = load_metadata(post_dir)
|
||||
assert metadata["delete_pin_service_messages"] is False
|
||||
assert metadata["status"] == "modified"
|
||||
assert metadata["dirty_discussion_pinning"] is True
|
||||
|
||||
|
||||
def test_publish_success_can_persist_effective_pinning_fallback(tmp_path: Path) -> None:
|
||||
directory_name = create_broadcast(tmp_path, "Fallback pin mode")
|
||||
post_dir = tmp_path / directory_name
|
||||
binding = PublishBinding(
|
||||
global_message_ids=[],
|
||||
global_attached_to_language=None,
|
||||
languages={
|
||||
Language.FI: LanguageBinding(
|
||||
language=Language.FI,
|
||||
image_message_ids=[],
|
||||
text_segments=[TextSegmentBinding(message_id=101, mode="message")],
|
||||
primary_message_id=101,
|
||||
primary_message_url="https://t.me/c/123/101",
|
||||
discussion_message_id=201,
|
||||
)
|
||||
},
|
||||
discussion_message_ids={101: 201},
|
||||
)
|
||||
|
||||
save_broadcast_changes(
|
||||
post_dir,
|
||||
{
|
||||
"discussion_pinning_mode": {"base_revision": 0, "value": "all"},
|
||||
"texts": {},
|
||||
"images": {},
|
||||
},
|
||||
)
|
||||
mark_publish_success_with_result(
|
||||
post_dir,
|
||||
binding,
|
||||
"ok",
|
||||
"updated",
|
||||
effective_discussion_pinning_mode="last",
|
||||
)
|
||||
|
||||
metadata = load_metadata(post_dir)
|
||||
assert metadata["discussion_pinning_mode"] == "last"
|
||||
assert metadata["dirty_discussion_pinning"] is False
|
||||
|
||||
|
||||
def test_list_broadcasts_uses_manual_sort_order_instead_of_updated_time(tmp_path: Path) -> None:
|
||||
first = create_broadcast(tmp_path, "First")
|
||||
second = create_broadcast(tmp_path, "Second")
|
||||
|
||||
save_broadcast_changes(
|
||||
tmp_path / first,
|
||||
{
|
||||
"texts": {
|
||||
"fi": {"base_revision": 0, "value": "Updated later"},
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
ordered = list_broadcasts(tmp_path)
|
||||
|
||||
assert [item["directory"] for item in ordered] == [second, first]
|
||||
|
||||
|
||||
def test_new_broadcasts_are_inserted_at_top_of_sidebar_order(tmp_path: Path) -> None:
|
||||
first = create_broadcast(tmp_path, "First")
|
||||
second = create_broadcast(tmp_path, "Second")
|
||||
third = create_broadcast(tmp_path, "Third")
|
||||
|
||||
ordered = list_broadcasts(tmp_path)
|
||||
|
||||
assert [item["directory"] for item in ordered] == [third, second, first]
|
||||
|
||||
|
||||
def test_move_broadcast_swaps_manual_order(tmp_path: Path) -> None:
|
||||
first = create_broadcast(tmp_path, "First")
|
||||
second = create_broadcast(tmp_path, "Second")
|
||||
third = create_broadcast(tmp_path, "Third")
|
||||
|
||||
move_broadcast(tmp_path, first, "up")
|
||||
ordered = list_broadcasts(tmp_path)
|
||||
|
||||
assert [item["directory"] for item in ordered] == [third, first, second]
|
||||
@@ -0,0 +1,97 @@
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
from webapp.telegram_targets import (
|
||||
default_target_selection,
|
||||
load_telegram_target_catalog,
|
||||
resolve_target_config,
|
||||
)
|
||||
|
||||
|
||||
def test_multi_bot_catalog_picks_first_marked_defaults(tmp_path: Path) -> None:
|
||||
config_dir = tmp_path / "config"
|
||||
config_dir.mkdir()
|
||||
(config_dir / "telegram_config.json").write_text(
|
||||
json.dumps(
|
||||
{
|
||||
"bots": [
|
||||
{
|
||||
"display_name": "Bot A",
|
||||
"token": "token-a",
|
||||
"default": True,
|
||||
"channels": [
|
||||
{"display_name": "Channel A1", "channel_id": "-1001", "default": True},
|
||||
{"display_name": "Channel A2", "channel_id": "-1002", "default": True},
|
||||
],
|
||||
},
|
||||
{
|
||||
"display_name": "Bot B",
|
||||
"token": "token-b",
|
||||
"default": True,
|
||||
"channels": [
|
||||
{"display_name": "Channel B1", "channel_id": "-2001", "default": True},
|
||||
],
|
||||
},
|
||||
]
|
||||
}
|
||||
),
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
catalog = load_telegram_target_catalog(config_dir)
|
||||
|
||||
assert default_target_selection(catalog) == ("token-a", "-1001")
|
||||
|
||||
|
||||
def test_resolve_target_config_uses_selected_bot_id_and_chat_id(tmp_path: Path) -> None:
|
||||
config_dir = tmp_path / "config"
|
||||
config_dir.mkdir()
|
||||
(config_dir / "telegram_config.json").write_text(
|
||||
json.dumps(
|
||||
{
|
||||
"bots": [
|
||||
{
|
||||
"display_name": "Bot A",
|
||||
"token": "token-a",
|
||||
"channels": [
|
||||
{
|
||||
"display_name": "Channel A1",
|
||||
"channel_id": "-1001",
|
||||
"channel_username": "public_a",
|
||||
"discussion_group_id": "-2001",
|
||||
},
|
||||
],
|
||||
}
|
||||
]
|
||||
}
|
||||
),
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
catalog = load_telegram_target_catalog(config_dir)
|
||||
config = resolve_target_config(catalog, "token-a", "-1001")
|
||||
|
||||
assert config.bot_token == "token-a"
|
||||
assert config.chat_id == "-1001"
|
||||
assert config.channel_username == "public_a"
|
||||
assert config.discussion_group_id == "-2001"
|
||||
|
||||
|
||||
def test_telegram_config_requires_top_level_bots_list(tmp_path: Path) -> None:
|
||||
config_dir = tmp_path / "config"
|
||||
config_dir.mkdir()
|
||||
(config_dir / "telegram_config.json").write_text(
|
||||
json.dumps(
|
||||
{
|
||||
"bot_token": "legacy-token",
|
||||
}
|
||||
),
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
try:
|
||||
load_telegram_target_catalog(config_dir)
|
||||
except Exception as error:
|
||||
assert "top-level 'bots' list" in str(error)
|
||||
else:
|
||||
raise AssertionError("Expected invalid config format to be rejected.")
|
||||
@@ -0,0 +1,250 @@
|
||||
from pathlib import Path
|
||||
|
||||
from linkki_poster.models import Config, Language, LanguageAssets
|
||||
from linkki_poster.broadcast_ops import order_scan_languages
|
||||
from linkki_poster.models import ScanResult
|
||||
from linkki_poster.telegram_api import TelegramApiError
|
||||
from linkki_poster.workflow import (
|
||||
extract_internal_chat_id,
|
||||
post_assets,
|
||||
resolve_link_target,
|
||||
split_markdown_text_for_messages,
|
||||
)
|
||||
|
||||
|
||||
class FakeTelegramAPI:
|
||||
def __init__(self, fail_caption_too_long: bool = False) -> None:
|
||||
self.next_message_id = 100
|
||||
self.calls: list[tuple[str, dict]] = []
|
||||
self.fail_caption_too_long = fail_caption_too_long
|
||||
|
||||
def send_media_group(
|
||||
self,
|
||||
chat_id: str,
|
||||
image_paths: list[Path],
|
||||
caption: str | None = None,
|
||||
parse_mode: str | None = None,
|
||||
) -> list[dict]:
|
||||
self.calls.append(
|
||||
(
|
||||
"send_media_group",
|
||||
{
|
||||
"chat_id": chat_id,
|
||||
"images": [path.name for path in image_paths],
|
||||
"caption": caption,
|
||||
"parse_mode": parse_mode,
|
||||
},
|
||||
)
|
||||
)
|
||||
if caption is not None and self.fail_caption_too_long:
|
||||
raise TelegramApiError("Bad Request: caption is too long")
|
||||
base = self.next_message_id
|
||||
self.next_message_id += len(image_paths)
|
||||
return [{"message_id": base + index} for index, _ in enumerate(image_paths)]
|
||||
|
||||
def send_message(self, chat_id: str, text: str, parse_mode: str | None = None) -> dict:
|
||||
self.calls.append(
|
||||
(
|
||||
"send_message",
|
||||
{
|
||||
"chat_id": chat_id,
|
||||
"text": text,
|
||||
"parse_mode": parse_mode,
|
||||
},
|
||||
)
|
||||
)
|
||||
message_id = self.next_message_id
|
||||
self.next_message_id += 1
|
||||
return {"message_id": message_id}
|
||||
|
||||
def edit_message_text(self, chat_id: str, message_id: int, text: str, parse_mode: str | None = None) -> dict:
|
||||
self.calls.append(
|
||||
(
|
||||
"edit_message_text",
|
||||
{
|
||||
"chat_id": chat_id,
|
||||
"message_id": message_id,
|
||||
"text": text,
|
||||
"parse_mode": parse_mode,
|
||||
},
|
||||
)
|
||||
)
|
||||
return {"message_id": message_id}
|
||||
|
||||
def edit_message_caption(
|
||||
self,
|
||||
chat_id: str,
|
||||
message_id: int,
|
||||
caption: str,
|
||||
parse_mode: str | None = None,
|
||||
) -> dict:
|
||||
self.calls.append(
|
||||
(
|
||||
"edit_message_caption",
|
||||
{
|
||||
"chat_id": chat_id,
|
||||
"message_id": message_id,
|
||||
"caption": caption,
|
||||
"parse_mode": parse_mode,
|
||||
},
|
||||
)
|
||||
)
|
||||
return {"message_id": message_id}
|
||||
|
||||
def delete_message(self, chat_id: str, message_id: int) -> bool:
|
||||
self.calls.append(
|
||||
(
|
||||
"delete_message",
|
||||
{
|
||||
"chat_id": chat_id,
|
||||
"message_id": message_id,
|
||||
},
|
||||
)
|
||||
)
|
||||
return True
|
||||
|
||||
|
||||
def test_post_assets_attaches_globals_to_first_language_without_images(tmp_path: Path) -> None:
|
||||
config = Config(
|
||||
bot_token="token",
|
||||
chat_id="-100123",
|
||||
channel_username="",
|
||||
)
|
||||
fi = LanguageAssets(
|
||||
language=Language.FI,
|
||||
text_file=tmp_path / "Finnish.md",
|
||||
text_raw="Read [English](en)",
|
||||
images=[],
|
||||
)
|
||||
en = LanguageAssets(
|
||||
language=Language.EN,
|
||||
text_file=tmp_path / "English.md",
|
||||
text_raw="Hello",
|
||||
images=[tmp_path / "English1.jpg"],
|
||||
)
|
||||
global_images = [tmp_path / "bild1.jpg", tmp_path / "bild2.jpg"]
|
||||
|
||||
api = FakeTelegramAPI()
|
||||
refs = post_assets(api=api, config=config, global_images=global_images, ordered_languages=[fi, en])
|
||||
|
||||
assert api.calls[0][0] == "send_media_group"
|
||||
assert api.calls[0][1]["images"] == ["bild1.jpg", "bild2.jpg"]
|
||||
assert api.calls[0][1]["caption"] is not None
|
||||
|
||||
send_message_calls = [call for call in api.calls if call[0] == "send_message"]
|
||||
assert len(send_message_calls) == 0
|
||||
|
||||
edit_caption_calls = [call for call in api.calls if call[0] == "edit_message_caption"]
|
||||
assert len(edit_caption_calls) >= 1
|
||||
assert 'href="https://t.me/c/123/' in edit_caption_calls[-1][1]["caption"]
|
||||
|
||||
assert len(refs) == 2
|
||||
assert refs[0].language == Language.FI
|
||||
assert refs[1].language == Language.EN
|
||||
assert refs[0].text_mode == "caption"
|
||||
|
||||
|
||||
def test_post_assets_posts_globals_first_when_first_language_has_images(tmp_path: Path) -> None:
|
||||
config = Config(bot_token="token", chat_id="-100123", channel_username="")
|
||||
fi = LanguageAssets(
|
||||
language=Language.FI,
|
||||
text_file=tmp_path / "Finnish.md",
|
||||
text_raw="Hei",
|
||||
images=[tmp_path / "suomi1.jpg"],
|
||||
)
|
||||
en = LanguageAssets(
|
||||
language=Language.EN,
|
||||
text_file=tmp_path / "English.md",
|
||||
text_raw="Hello",
|
||||
images=[],
|
||||
)
|
||||
global_images = [tmp_path / "bild1.jpg", tmp_path / "bild2.jpg"]
|
||||
|
||||
api = FakeTelegramAPI()
|
||||
post_assets(api=api, config=config, global_images=global_images, ordered_languages=[fi, en])
|
||||
|
||||
assert api.calls[0][0] == "send_media_group"
|
||||
assert api.calls[0][1]["images"] == ["bild1.jpg", "bild2.jpg"]
|
||||
assert api.calls[0][1]["caption"] is None
|
||||
|
||||
|
||||
def test_long_text_with_images_is_not_caption(tmp_path: Path) -> None:
|
||||
config = Config(bot_token="token", chat_id="-100123", channel_username="")
|
||||
long_text = "x" * 1200
|
||||
en = LanguageAssets(
|
||||
language=Language.EN,
|
||||
text_file=tmp_path / "English.md",
|
||||
text_raw=long_text,
|
||||
images=[tmp_path / "english1.jpg"],
|
||||
)
|
||||
|
||||
api = FakeTelegramAPI()
|
||||
refs = post_assets(api=api, config=config, global_images=[], ordered_languages=[en])
|
||||
|
||||
first_call = api.calls[0]
|
||||
assert first_call[0] == "send_media_group"
|
||||
assert first_call[1]["caption"] is None
|
||||
second_call = api.calls[1]
|
||||
assert second_call[0] == "send_message"
|
||||
assert refs[0].text_mode == "message"
|
||||
|
||||
|
||||
def test_caption_too_long_error_falls_back_to_media_then_message(tmp_path: Path) -> None:
|
||||
config = Config(bot_token="token", chat_id="-100123", channel_username="")
|
||||
en = LanguageAssets(
|
||||
language=Language.EN,
|
||||
text_file=tmp_path / "English.md",
|
||||
text_raw="short text",
|
||||
images=[tmp_path / "english1.jpg"],
|
||||
)
|
||||
|
||||
api = FakeTelegramAPI(fail_caption_too_long=True)
|
||||
refs = post_assets(api=api, config=config, global_images=[], ordered_languages=[en])
|
||||
|
||||
assert api.calls[0][0] == "send_media_group"
|
||||
assert api.calls[0][1]["caption"] == "short text"
|
||||
assert api.calls[1][0] == "send_media_group"
|
||||
assert api.calls[1][1]["caption"] is None
|
||||
assert api.calls[2][0] == "send_message"
|
||||
assert refs[0].text_mode == "message"
|
||||
|
||||
|
||||
def test_split_prefers_double_newline_boundaries() -> None:
|
||||
text = ("a" * 2500) + "\n\n" + ("b" * 2500)
|
||||
chunks = split_markdown_text_for_messages(text, link_target="chan", message_limit=4096)
|
||||
|
||||
assert len(chunks) == 2
|
||||
assert chunks[0].endswith("\n\n")
|
||||
assert chunks[1].startswith("b")
|
||||
|
||||
|
||||
def test_split_does_not_force_extra_paragraph_split_when_mid_split_is_needed() -> None:
|
||||
text = ("x" * 5000) + "\n\n" + ("tail" * 10)
|
||||
chunks = split_markdown_text_for_messages(text, link_target="chan", message_limit=4096)
|
||||
|
||||
assert len(chunks) == 2
|
||||
assert chunks[0].endswith("x")
|
||||
|
||||
|
||||
def test_resolve_link_target_supports_private_channel_style() -> None:
|
||||
assert resolve_link_target("-1003896755764", "") == "c/3896755764"
|
||||
assert extract_internal_chat_id("-1003896755764") == "3896755764"
|
||||
|
||||
|
||||
def test_resolve_link_target_prefers_public_username() -> None:
|
||||
assert resolve_link_target("-1003896755764", "mychannel") == "mychannel"
|
||||
|
||||
|
||||
def test_order_scan_languages_respects_explicit_allowlist() -> None:
|
||||
scan_result = ScanResult(
|
||||
global_images=[],
|
||||
languages=[
|
||||
LanguageAssets(language=Language.FI, text_file=Path("Suomi.md"), text_raw="Hei", images=[]),
|
||||
LanguageAssets(language=Language.SV, text_file=Path("Svenska.md"), text_raw="Hej", images=[]),
|
||||
LanguageAssets(language=Language.EN, text_file=Path("English.md"), text_raw="Hello", images=[]),
|
||||
],
|
||||
)
|
||||
|
||||
ordered = order_scan_languages(scan_result, [Language.FI, Language.SV])
|
||||
|
||||
assert [assets.language for assets in ordered] == [Language.FI, Language.SV]
|
||||
Reference in New Issue
Block a user