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]