Files
docker-images/linkki-tiedotus/tests/test_storage.py
T
ajp_anton 8ec3cde4f2
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
Add Linkki broadcast image
2026-08-19 20:51:26 +00:00

589 lines
20 KiB
Python

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]