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

This commit is contained in:
ajp_anton
2026-08-19 20:51:26 +00:00
parent 7e6dea8b24
commit 8ec3cde4f2
56 changed files with 13441 additions and 0 deletions
+12
View File
@@ -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"
+202
View File
@@ -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
+204
View File
@@ -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
+53
View File
@@ -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
+125
View File
@@ -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 "&#x27;" not in heading
assert "&#x27;" not in plain_heading_char
assert "&#x27;" not in bold
+46
View File
@@ -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
+25
View File
@@ -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]
+10
View File
@@ -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"
+60
View File
@@ -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]
+50
View File
@@ -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
+588
View File
@@ -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.")
+250
View File
@@ -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]