feat(MAT-174): Add cron job scheduler and executors

Cron package that syncs jobs from matrixhost portal API, schedules execution
with timezone-aware timing, and posts results to Matrix rooms. Includes
Brave Search, reminder, and browser scrape (placeholder) executors with
formatter. 31 pytest tests.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Christian Gick
2026-03-16 09:31:19 +02:00
parent 21b8a4efb1
commit 4d8ea44b3d
15 changed files with 1009 additions and 0 deletions

0
tests/__init__.py Normal file
View File

View File

@@ -0,0 +1,150 @@
"""Tests for the Brave Search cron executor."""
import os
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
from cron.brave_search import execute_brave_search
@pytest.fixture
def job():
return {
"id": "j1",
"name": "BMW Search",
"jobType": "brave_search",
"config": {"query": "BMW X3 damaged Cyprus", "maxResults": 5},
"targetRoom": "!room:cars",
"dedupKeys": ["https://old-result.com"],
}
class TestBraveSearchExecutor:
@pytest.mark.asyncio
async def test_returns_error_without_api_key(self, job):
with patch.dict(os.environ, {"BRAVE_API_KEY": ""}, clear=False):
# Need to reload module to pick up empty env
import importlib
import cron.brave_search as bs
original_key = bs.BRAVE_API_KEY
bs.BRAVE_API_KEY = ""
try:
result = await execute_brave_search(job=job, send_text=AsyncMock())
assert result["status"] == "error"
assert "BRAVE_API_KEY" in result["error"]
finally:
bs.BRAVE_API_KEY = original_key
@pytest.mark.asyncio
async def test_returns_error_without_query(self):
job = {
"id": "j1",
"name": "Empty",
"jobType": "brave_search",
"config": {},
"targetRoom": "!room:test",
"dedupKeys": [],
}
import cron.brave_search as bs
original_key = bs.BRAVE_API_KEY
bs.BRAVE_API_KEY = "test-key"
try:
result = await execute_brave_search(job=job, send_text=AsyncMock())
assert result["status"] == "error"
assert "query" in result["error"].lower()
finally:
bs.BRAVE_API_KEY = original_key
@pytest.mark.asyncio
async def test_deduplicates_results(self, job):
"""Results with URLs already in dedupKeys should be filtered out."""
import cron.brave_search as bs
original_key = bs.BRAVE_API_KEY
bs.BRAVE_API_KEY = "test-key"
mock_response = MagicMock()
mock_response.json.return_value = {
"web": {
"results": [
{"title": "Old Result", "url": "https://old-result.com", "description": "Already seen"},
{"title": "New BMW", "url": "https://new-result.com", "description": "Fresh listing"},
]
}
}
mock_response.raise_for_status = MagicMock()
send_text = AsyncMock()
with patch("httpx.AsyncClient") as mock_client_cls:
mock_client = AsyncMock()
mock_client_cls.return_value.__aenter__ = AsyncMock(return_value=mock_client)
mock_client_cls.return_value.__aexit__ = AsyncMock(return_value=False)
mock_client.get = AsyncMock(return_value=mock_response)
try:
result = await execute_brave_search(job=job, send_text=send_text)
finally:
bs.BRAVE_API_KEY = original_key
assert result["status"] == "success"
assert result["newDedupKeys"] == ["https://new-result.com"]
send_text.assert_called_once()
# Message should contain only the new result
msg = send_text.call_args[0][1]
assert "New BMW" in msg
assert "Old Result" not in msg
@pytest.mark.asyncio
async def test_no_results_status(self, job):
"""When API returns empty results, status should be no_results."""
import cron.brave_search as bs
original_key = bs.BRAVE_API_KEY
bs.BRAVE_API_KEY = "test-key"
mock_response = MagicMock()
mock_response.json.return_value = {"web": {"results": []}}
mock_response.raise_for_status = MagicMock()
with patch("httpx.AsyncClient") as mock_client_cls:
mock_client = AsyncMock()
mock_client_cls.return_value.__aenter__ = AsyncMock(return_value=mock_client)
mock_client_cls.return_value.__aexit__ = AsyncMock(return_value=False)
mock_client.get = AsyncMock(return_value=mock_response)
try:
result = await execute_brave_search(job=job, send_text=AsyncMock())
finally:
bs.BRAVE_API_KEY = original_key
assert result["status"] == "no_results"
@pytest.mark.asyncio
async def test_all_results_already_seen(self, job):
"""When all results are already in dedupKeys, status should be no_results."""
import cron.brave_search as bs
original_key = bs.BRAVE_API_KEY
bs.BRAVE_API_KEY = "test-key"
mock_response = MagicMock()
mock_response.json.return_value = {
"web": {
"results": [
{"title": "Old", "url": "https://old-result.com", "description": "Seen"},
]
}
}
mock_response.raise_for_status = MagicMock()
with patch("httpx.AsyncClient") as mock_client_cls:
mock_client = AsyncMock()
mock_client_cls.return_value.__aenter__ = AsyncMock(return_value=mock_client)
mock_client_cls.return_value.__aexit__ = AsyncMock(return_value=False)
mock_client.get = AsyncMock(return_value=mock_response)
try:
result = await execute_brave_search(job=job, send_text=AsyncMock())
finally:
bs.BRAVE_API_KEY = original_key
assert result["status"] == "no_results"

View File

@@ -0,0 +1,58 @@
"""Tests for the browser scrape executor."""
from unittest.mock import AsyncMock
import pytest
from cron.browser_executor import execute_browser_scrape
class TestBrowserScrapeExecutor:
@pytest.mark.asyncio
async def test_returns_error_without_profile(self):
job = {
"id": "j1",
"name": "FB Scan",
"config": {"url": "https://facebook.com/marketplace"},
"targetRoom": "!room:test",
"browserProfile": None,
}
send_text = AsyncMock()
result = await execute_browser_scrape(job=job, send_text=send_text)
assert result["status"] == "error"
assert "browser profile" in result["error"].lower()
send_text.assert_called_once()
msg = send_text.call_args[0][1]
assert "matrixhost.eu/settings/automations" in msg
@pytest.mark.asyncio
async def test_returns_error_with_expired_profile(self):
job = {
"id": "j1",
"name": "FB Scan",
"config": {"url": "https://facebook.com/marketplace"},
"targetRoom": "!room:test",
"browserProfile": {"id": "b1", "status": "expired", "name": "facebook"},
}
send_text = AsyncMock()
result = await execute_browser_scrape(job=job, send_text=send_text)
assert result["status"] == "error"
assert "expired" in result["error"].lower()
send_text.assert_called_once()
msg = send_text.call_args[0][1]
assert "re-record" in msg.lower()
@pytest.mark.asyncio
async def test_placeholder_with_active_profile(self):
job = {
"id": "j1",
"name": "FB Scan",
"config": {"url": "https://facebook.com/marketplace"},
"targetRoom": "!room:test",
"browserProfile": {"id": "b1", "status": "active", "name": "facebook"},
}
send_text = AsyncMock()
result = await execute_browser_scrape(job=job, send_text=send_text)
# Currently a placeholder, should indicate not yet implemented
assert result["status"] == "error"
assert "not yet implemented" in result["error"].lower()

View File

@@ -0,0 +1,48 @@
"""Tests for the cron executor dispatch."""
from unittest.mock import AsyncMock
import pytest
from cron.executor import execute_job
class TestExecuteJob:
@pytest.mark.asyncio
async def test_unknown_job_type_returns_error(self):
job = {"jobType": "nonexistent", "config": {}}
result = await execute_job(
job=job, send_text=AsyncMock(), matrix_client=None
)
assert result["status"] == "error"
assert "Unknown job type" in result["error"]
@pytest.mark.asyncio
async def test_dispatches_to_reminder(self):
job = {
"id": "j1",
"name": "Test Reminder",
"jobType": "reminder",
"config": {"message": "Don't forget!"},
"targetRoom": "!room:test",
}
send_text = AsyncMock()
result = await execute_job(job=job, send_text=send_text, matrix_client=None)
assert result["status"] == "success"
send_text.assert_called_once()
assert "Don't forget!" in send_text.call_args[0][1]
@pytest.mark.asyncio
async def test_dispatches_to_browser_scrape_no_profile(self):
job = {
"id": "j1",
"name": "Scrape Test",
"jobType": "browser_scrape",
"config": {"url": "https://example.com"},
"targetRoom": "!room:test",
"browserProfile": None,
}
send_text = AsyncMock()
result = await execute_job(job=job, send_text=send_text, matrix_client=None)
assert result["status"] == "error"
assert "browser profile" in result["error"].lower()

View File

@@ -0,0 +1,67 @@
"""Tests for the cron result formatter."""
from cron.formatter import format_search_results, format_listings
class TestFormatSearchResults:
def test_single_result(self):
results = [
{"title": "BMW X3 2018", "url": "https://example.com/1", "description": "Unfallwagen"}
]
msg = format_search_results("BMW Scan", results)
assert "BMW Scan" in msg
assert "1 new result" in msg
assert "BMW X3 2018" in msg
assert "https://example.com/1" in msg
assert "Unfallwagen" in msg
assert "matrixhost.eu/settings/automations" in msg
def test_multiple_results(self):
results = [
{"title": "Result 1", "url": "https://a.com", "description": "Desc 1"},
{"title": "Result 2", "url": "https://b.com", "description": "Desc 2"},
{"title": "Result 3", "url": "https://c.com", "description": ""},
]
msg = format_search_results("Test Search", results)
assert "3 new results" in msg
assert "1." in msg
assert "2." in msg
assert "3." in msg
def test_result_without_description(self):
results = [{"title": "No Desc", "url": "https://x.com"}]
msg = format_search_results("Search", results)
assert "No Desc" in msg
# Should not have empty description line
class TestFormatListings:
def test_single_listing(self):
listings = [
{
"title": "BMW X3 2.0i",
"price": "\u20ac4,500",
"location": "Limassol",
"url": "https://fb.com/123",
"age": "2h ago",
}
]
msg = format_listings("Car Scan", listings)
assert "Car Scan" in msg
assert "1 new listing" in msg
assert "BMW X3 2.0i" in msg
assert "\u20ac4,500" in msg
assert "Limassol" in msg
assert "2h ago" in msg
assert "https://fb.com/123" in msg
def test_listing_without_optional_fields(self):
listings = [{"title": "Bare Listing"}]
msg = format_listings("Scan", listings)
assert "Bare Listing" in msg
assert "1 new listing" in msg
def test_multiple_listings_plural(self):
listings = [{"title": f"Item {i}"} for i in range(5)]
msg = format_listings("Multi", listings)
assert "5 new listings" in msg

View File

@@ -0,0 +1,57 @@
"""Tests for the reminder cron executor."""
from unittest.mock import AsyncMock
import pytest
from cron.reminder import execute_reminder
class TestReminderExecutor:
@pytest.mark.asyncio
async def test_sends_reminder_to_room(self):
job = {
"id": "j1",
"name": "Daily Check",
"config": {"message": "Check your portfolio"},
"targetRoom": "!room:finance",
}
send_text = AsyncMock()
result = await execute_reminder(job=job, send_text=send_text)
assert result["status"] == "success"
send_text.assert_called_once()
room_id, msg = send_text.call_args[0]
assert room_id == "!room:finance"
assert "Check your portfolio" in msg
assert "Daily Check" in msg
assert "\u23f0" in msg # alarm clock emoji
@pytest.mark.asyncio
async def test_returns_error_without_message(self):
job = {
"id": "j1",
"name": "Empty",
"config": {},
"targetRoom": "!room:test",
}
send_text = AsyncMock()
result = await execute_reminder(job=job, send_text=send_text)
assert result["status"] == "error"
assert "message" in result["error"].lower()
send_text.assert_not_called()
@pytest.mark.asyncio
async def test_empty_message_returns_error(self):
job = {
"id": "j1",
"name": "Empty",
"config": {"message": ""},
"targetRoom": "!room:test",
}
send_text = AsyncMock()
result = await execute_reminder(job=job, send_text=send_text)
assert result["status"] == "error"
send_text.assert_not_called()

View File

@@ -0,0 +1,217 @@
"""Tests for the cron scheduler module."""
import asyncio
from datetime import datetime, timedelta
from unittest.mock import AsyncMock, MagicMock, patch
import zoneinfo
import pytest
from cron.scheduler import CronScheduler
@pytest.fixture
def scheduler():
send_text = AsyncMock()
matrix_client = MagicMock()
sched = CronScheduler(
portal_url="https://matrixhost.eu",
api_key="test-key",
matrix_client=matrix_client,
send_text_fn=send_text,
)
return sched
class TestSecondsUntilNextRun:
def test_daily_schedule_future_today(self, scheduler):
tz = zoneinfo.ZoneInfo("Europe/Berlin")
now = datetime.now(tz)
# Set scheduleAt to 2 hours from now
future_time = now + timedelta(hours=2)
job = {
"schedule": "daily",
"scheduleAt": f"{future_time.hour:02d}:{future_time.minute:02d}",
"timezone": "Europe/Berlin",
}
secs = scheduler._seconds_until_next_run(job)
assert 7000 < secs < 7300 # roughly 2 hours
def test_daily_schedule_past_today_goes_tomorrow(self, scheduler):
tz = zoneinfo.ZoneInfo("Europe/Berlin")
now = datetime.now(tz)
# Set scheduleAt to 2 hours ago
past_time = now - timedelta(hours=2)
job = {
"schedule": "daily",
"scheduleAt": f"{past_time.hour:02d}:{past_time.minute:02d}",
"timezone": "Europe/Berlin",
}
secs = scheduler._seconds_until_next_run(job)
# Should be ~22 hours from now
assert 78000 < secs < 80000
def test_hourly_schedule(self, scheduler):
job = {
"schedule": "hourly",
"scheduleAt": None,
"timezone": "Europe/Berlin",
}
secs = scheduler._seconds_until_next_run(job)
# Should be between 0 and 3600
assert 0 <= secs <= 3600
def test_weekdays_skips_weekend(self, scheduler):
# Mock a Saturday
job = {
"schedule": "weekdays",
"scheduleAt": "09:00",
"timezone": "UTC",
}
secs = scheduler._seconds_until_next_run(job)
assert secs > 0
def test_weekly_schedule(self, scheduler):
job = {
"schedule": "weekly",
"scheduleAt": "09:00",
"timezone": "Europe/Berlin",
}
secs = scheduler._seconds_until_next_run(job)
# Should be between 0 and 7 days
assert 0 < secs <= 7 * 86400
def test_default_timezone(self, scheduler):
job = {
"schedule": "daily",
"scheduleAt": "23:59",
"timezone": "Europe/Berlin",
}
secs = scheduler._seconds_until_next_run(job)
assert secs > 0
def test_different_timezone(self, scheduler):
job = {
"schedule": "daily",
"scheduleAt": "09:00",
"timezone": "America/New_York",
}
secs = scheduler._seconds_until_next_run(job)
assert secs > 0
class TestSyncJobs:
@pytest.mark.asyncio
async def test_sync_adds_new_jobs(self, scheduler):
"""New jobs from the API should be registered as tasks."""
jobs_response = {
"jobs": [
{
"id": "j1",
"name": "Test Job",
"jobType": "brave_search",
"schedule": "daily",
"scheduleAt": "09:00",
"timezone": "Europe/Berlin",
"config": {"query": "test"},
"targetRoom": "!room:test",
"enabled": True,
"updatedAt": "2026-03-16T00:00:00Z",
}
]
}
with patch("httpx.AsyncClient") as mock_client_cls:
mock_client = AsyncMock()
mock_client_cls.return_value.__aenter__ = AsyncMock(return_value=mock_client)
mock_client_cls.return_value.__aexit__ = AsyncMock(return_value=False)
mock_resp = MagicMock()
mock_resp.json.return_value = jobs_response
mock_resp.raise_for_status = MagicMock()
mock_client.get = AsyncMock(return_value=mock_resp)
await scheduler._sync_jobs()
assert "j1" in scheduler._jobs
assert "j1" in scheduler._tasks
# Clean up the task
scheduler._tasks["j1"].cancel()
try:
await scheduler._tasks["j1"]
except asyncio.CancelledError:
pass
@pytest.mark.asyncio
async def test_sync_removes_deleted_jobs(self, scheduler):
"""Jobs removed from the API should have their tasks cancelled."""
# Pre-populate with a job
mock_task = AsyncMock()
mock_task.cancel = MagicMock()
scheduler._jobs["old_job"] = {"id": "old_job"}
scheduler._tasks["old_job"] = mock_task
with patch("httpx.AsyncClient") as mock_client_cls:
mock_client = AsyncMock()
mock_client_cls.return_value.__aenter__ = AsyncMock(return_value=mock_client)
mock_client_cls.return_value.__aexit__ = AsyncMock(return_value=False)
mock_resp = MagicMock()
mock_resp.json.return_value = {"jobs": []} # No jobs
mock_resp.raise_for_status = MagicMock()
mock_client.get = AsyncMock(return_value=mock_resp)
await scheduler._sync_jobs()
assert "old_job" not in scheduler._tasks
assert "old_job" not in scheduler._jobs
mock_task.cancel.assert_called_once()
class TestRunOnce:
@pytest.mark.asyncio
async def test_run_once_reports_success(self, scheduler):
"""Successful execution should report back to portal."""
job = {
"id": "j1",
"name": "Test",
"jobType": "reminder",
"config": {"message": "Hello"},
"targetRoom": "!room:test",
}
with patch("httpx.AsyncClient") as mock_client_cls:
mock_client = AsyncMock()
mock_client_cls.return_value.__aenter__ = AsyncMock(return_value=mock_client)
mock_client_cls.return_value.__aexit__ = AsyncMock(return_value=False)
mock_client.post = AsyncMock()
await scheduler._run_once(job)
# send_text should have been called with the reminder
scheduler.send_text.assert_called_once()
call_args = scheduler.send_text.call_args
assert call_args[0][0] == "!room:test"
assert "Hello" in call_args[0][1]
@pytest.mark.asyncio
async def test_run_once_reports_error(self, scheduler):
"""Failed execution should report error back to portal."""
job = {
"id": "j1",
"name": "Test",
"jobType": "brave_search",
"config": {}, # Missing query = error
"targetRoom": "!room:test",
"dedupKeys": [],
}
with patch("httpx.AsyncClient") as mock_client_cls:
mock_client = AsyncMock()
mock_client_cls.return_value.__aenter__ = AsyncMock(return_value=mock_client)
mock_client_cls.return_value.__aexit__ = AsyncMock(return_value=False)
mock_client.post = AsyncMock()
await scheduler._run_once(job)
# Should not have sent a message to the room (error in executor)
# But should have reported back
# The report happens via httpx post