Files

167 lines
5.9 KiB
Python

import asyncio
import os
import unittest
from datetime import datetime, timezone
from unittest.mock import patch
from core import msg_builder, tasks
from core.send_state import history_entry_is_strong_confirmed_today
from webui import ops
class SendStateTests(unittest.TestCase):
def setUp(self):
self.now = datetime(2026, 7, 10, 14, 0, tzinfo=timezone.utc)
self.window = {
"enabled": True,
"startHour": 10,
"endHour": 18,
"scheduleIntervalMinutes": 20,
}
def test_strong_confirmation_is_the_only_sent_state(self):
strong = {
"sentAt": self.now.isoformat(),
"status": "confirmed",
"confirmationLevel": "strong",
"needsVerification": False,
}
weak = {
"sentAt": self.now.isoformat(),
"status": "unconfirmed",
"confirmationLevel": "weak",
"needsVerification": True,
}
legacy = {"sentAt": self.now.isoformat()}
self.assertTrue(history_entry_is_strong_confirmed_today(strong, self.now))
self.assertFalse(history_entry_is_strong_confirmed_today(weak, self.now))
self.assertFalse(history_entry_is_strong_confirmed_today(legacy, self.now))
def test_unconfirmed_target_is_visible_and_retryable(self):
user = {
"username": "demo",
"unique_id": "demo",
"targets": ["target"],
"message_history": {
"target": {
"sentAt": self.now.isoformat(),
"status": "unconfirmed",
"confirmationLevel": "weak",
"needsVerification": True,
}
},
"failure_queue": {
"target": {
"lastAttemptAt": self.now.isoformat(),
"category": "send_unconfirmed",
"attemptCount": 1,
}
},
}
status = ops._build_target_status(user, "target", self.now, self.window)
self.assertEqual("unconfirmed", status["status"])
self.assertFalse(tasks._target_sent_today(user, "target", self.now))
self.assertEqual(["target"], tasks._pending_failed_targets(user, self.now))
self.assertEqual(["target"], tasks._pending_unsent_targets(user, self.now)[0])
def test_legacy_sent_at_only_record_is_retryable(self):
user = {
"targets": ["target"],
"message_history": {"target": {"sentAt": self.now.isoformat()}},
}
status = ops._build_target_status(user, "target", self.now, self.window)
self.assertEqual("unconfirmed", status["status"])
self.assertTrue(status["legacyUnverified"])
self.assertEqual(["target"], tasks._pending_failed_targets(user, self.now))
self.assertEqual(["target"], tasks._pending_unsent_targets(user, self.now)[0])
def test_unsent_retry_respects_non_retryable_and_attempt_limit(self):
user = {
"targets": ["blocked", "exhausted", "retryable"],
"failure_queue": {
"blocked": {
"lastAttemptAt": self.now.isoformat(),
"category": "protocol_user_blocked",
"attemptCount": 1,
},
"exhausted": {
"lastAttemptAt": self.now.isoformat(),
"category": "timeout",
"attemptCount": 3,
},
"retryable": {
"lastAttemptAt": self.now.isoformat(),
"category": "timeout",
"attemptCount": 2,
},
},
}
retryable, skipped = tasks._pending_unsent_targets(user, self.now)
self.assertEqual(["retryable"], retryable)
self.assertEqual(2, len(skipped))
def test_manual_force_all_still_includes_strong_confirmed_targets(self):
user = {
"username": "demo",
"targets": ["confirmed", "pending"],
"message_history": {
"confirmed": {
"sentAt": self.now.isoformat(),
"status": "confirmed",
"confirmationLevel": "strong",
"needsVerification": False,
}
},
}
config = {"dailySendWindow": self.window}
with patch.dict(os.environ, {"SPARKFLOW_MANUAL_RUN": "1"}, clear=False):
prepared = tasks._prepare_active_users_for_run(config, [user])
self.assertEqual(["confirmed", "pending"], prepared[0]["targets"])
def test_overlapping_task_run_is_skipped_without_traceback(self):
config = {
"multiTask": False,
"taskCount": 1,
"sendStrategy": {},
"messageTemplate": "",
"hitokotoTypes": [],
}
user = {"enabled": True, "username": "demo", "targets": ["friend"]}
with (
patch.object(tasks, "get_config", return_value=config),
patch.object(tasks, "get_userData", return_value=[user]),
patch.object(tasks, "_prepare_active_users_for_run", return_value=[user]),
patch.object(
tasks,
"task_run_lock",
side_effect=tasks.TaskRunAlreadyInProgress("already running"),
),
patch.object(tasks, "run_browser_tasks") as run_browser,
):
asyncio.run(tasks.runTasks())
run_browser.assert_not_called()
def test_message_choice_avoids_previous_and_last_when_possible(self):
with patch.object(
msg_builder,
"build_message_candidates",
return_value=["A", "B", "C"],
):
selected = msg_builder.build_message(previous_message="A", last_message="B")
self.assertEqual("C", selected)
if __name__ == "__main__":
unittest.main()