2195 lines
71 KiB
Python
2195 lines
71 KiB
Python
from __future__ import annotations
|
|
|
|
import asyncio
|
|
import json
|
|
import os
|
|
import re
|
|
from datetime import UTC, datetime, timedelta
|
|
from pathlib import Path
|
|
from typing import Any, cast
|
|
|
|
import pytest
|
|
|
|
from repub.components import action_button, status_badge, toggle_field
|
|
from repub.datastar import RefreshBroker, render_sse_event, render_stream
|
|
from repub.jobs import load_dashboard_view
|
|
from repub.model import (
|
|
Job,
|
|
JobExecution,
|
|
JobExecutionStatus,
|
|
Source,
|
|
SourceFeed,
|
|
SourcePangea,
|
|
create_source,
|
|
load_max_concurrent_jobs,
|
|
load_settings_form,
|
|
save_setting,
|
|
)
|
|
from repub.pages import dashboard_page_with_data
|
|
from repub.pages.runs import runs_page
|
|
from repub.pages.sources import sources_page
|
|
from repub.web import (
|
|
create_app,
|
|
get_refresh_broker,
|
|
get_tab_state_store,
|
|
render_create_source,
|
|
render_dashboard,
|
|
render_edit_source,
|
|
render_execution_logs,
|
|
render_runs,
|
|
render_settings,
|
|
render_sources,
|
|
versioned_static_asset_href,
|
|
)
|
|
|
|
|
|
def test_status_badge_uses_green_done_tone() -> None:
|
|
badge = str(status_badge(label="Succeeded", tone="done"))
|
|
|
|
assert "bg-emerald-100 text-emerald-800" in badge
|
|
assert "Succeeded" in badge
|
|
|
|
|
|
def test_toggle_field_active_state_utilities_exist_in_built_css() -> None:
|
|
markup = str(
|
|
toggle_field(
|
|
label="Enabled",
|
|
description="Enable this source",
|
|
signal_name="enabled",
|
|
checked=True,
|
|
)
|
|
)
|
|
css = (
|
|
Path(__file__).resolve().parents[1] / "repub" / "static" / "app.css"
|
|
).read_text(encoding="utf-8")
|
|
|
|
assert "data-class:bg-amber-500" in markup
|
|
assert "data-class:translate-x-5" in markup
|
|
assert ".bg-amber-500" in css
|
|
assert ".translate-x-5" in css
|
|
|
|
|
|
def test_action_button_adds_cursor_pointer_for_active_buttons() -> None:
|
|
markup = str(action_button(label="Run now"))
|
|
|
|
assert "cursor-pointer" in markup
|
|
assert 'type="button"' in markup
|
|
|
|
|
|
def test_action_button_omits_post_handler_when_disabled() -> None:
|
|
markup = str(
|
|
action_button(
|
|
label="Queued",
|
|
disabled=True,
|
|
post_path="/actions/jobs/7/run-now",
|
|
)
|
|
)
|
|
|
|
assert "cursor-not-allowed" in markup
|
|
assert "@post(" not in markup
|
|
|
|
|
|
def test_action_button_supports_submit_variant() -> None:
|
|
markup = str(
|
|
action_button(
|
|
label="Save settings",
|
|
tone="dark",
|
|
button_type="submit",
|
|
)
|
|
)
|
|
|
|
assert 'type="submit"' in markup
|
|
assert "bg-slate-950" in markup
|
|
assert "cursor-pointer" in markup
|
|
|
|
|
|
def test_action_button_supports_datastar_pointerdown_post() -> None:
|
|
markup = str(
|
|
action_button(
|
|
label="Delete",
|
|
tone="danger",
|
|
post_path="/actions/jobs/7/delete",
|
|
)
|
|
)
|
|
|
|
assert 'data-on:pointerdown="@post('/actions/jobs/7/delete')"' in markup
|
|
|
|
|
|
def test_runs_page_renders_completed_execution_end_time_as_relative_hoverable_time() -> (
|
|
None
|
|
):
|
|
ended_at = "2026-01-15T10:00:00+00:00"
|
|
body = str(
|
|
runs_page(
|
|
completed_executions=(
|
|
{
|
|
"source": "Completed source",
|
|
"slug": "completed-source",
|
|
"job_id": 7,
|
|
"execution_id": 42,
|
|
"ended_at": "2 hours ago",
|
|
"ended_at_iso": ended_at,
|
|
"status": "Succeeded",
|
|
"status_tone": "done",
|
|
"stats": "1 requests • 1 items • 1 bytes",
|
|
"summary": "Worker exited successfully",
|
|
"log_href": "/job/7/execution/42/logs",
|
|
},
|
|
)
|
|
)
|
|
)
|
|
|
|
assert "data-ended-at" in body
|
|
assert f'data-ended-at="{ended_at}"' in body
|
|
assert f'datetime="{ended_at}"' in body
|
|
assert f'title="{ended_at}"' in body
|
|
assert ">2 hours ago<" in body
|
|
|
|
|
|
def test_runs_page_renders_completed_execution_state_cell_with_duration_and_end_time() -> (
|
|
None
|
|
):
|
|
ended_at = "2026-01-15T10:00:00+00:00"
|
|
body = str(
|
|
runs_page(
|
|
completed_executions=(
|
|
{
|
|
"source": "Completed source",
|
|
"slug": "completed-source",
|
|
"job_id": 7,
|
|
"execution_id": 42,
|
|
"ended_at": "2 hours ago",
|
|
"ended_at_iso": ended_at,
|
|
"duration": "00:00:48",
|
|
"status": "Succeeded",
|
|
"status_tone": "done",
|
|
"stats": "1 requests • 1 items • 1 bytes",
|
|
"summary": "Worker exited successfully",
|
|
"log_href": "/job/7/execution/42/logs",
|
|
},
|
|
)
|
|
)
|
|
)
|
|
|
|
assert re.search(
|
|
r"<th[^>]*>State</th>\s*<th[^>]*>Source</th>\s*<th[^>]*>Summary</th>",
|
|
body,
|
|
)
|
|
assert not re.search(
|
|
r"<th[^>]*>#</th>\s*<th[^>]*>State</th>\s*<th[^>]*>Source</th>\s*<th[^>]*>Summary</th>",
|
|
body,
|
|
)
|
|
assert ">#42<" in body
|
|
assert ">Ended<" not in body
|
|
assert ">00:00:48<" in body
|
|
assert 'data-ended-at="2026-01-15T10:00:00+00:00"' in body
|
|
assert "M12 6v6h4.5" in body
|
|
assert "M6.75 3v2.25M17.25 3v2.25" in body
|
|
assert "Succeeded" in body
|
|
|
|
|
|
def test_runs_page_renders_combined_running_jobs_table() -> None:
|
|
body = str(
|
|
runs_page(
|
|
queued_executions=(
|
|
{
|
|
"source": "Queued source",
|
|
"slug": "queued-source",
|
|
"job_id": 7,
|
|
"execution_id": 42,
|
|
"queued_at": "2 minutes ago",
|
|
"queued_at_iso": "2026-03-30T12:28:00+00:00",
|
|
"queue_position": 1,
|
|
"status": "Queued",
|
|
"status_tone": "idle",
|
|
"run_label": "Queued",
|
|
"run_disabled": True,
|
|
"run_post_path": "/actions/jobs/7/run-now",
|
|
"cancel_post_path": "/actions/queued-executions/42/cancel",
|
|
"move_up_disabled": True,
|
|
"move_up_post_path": None,
|
|
"move_down_disabled": True,
|
|
"move_down_post_path": None,
|
|
},
|
|
)
|
|
)
|
|
)
|
|
|
|
assert "Running jobs" in body
|
|
assert "queued-source" in body
|
|
assert ">Queued<" in body
|
|
assert "bg-amber-200 text-amber-950" in body
|
|
assert "/actions/queued-executions/42/cancel" in body
|
|
|
|
|
|
def test_runs_page_renders_running_state_cell_with_duration_and_started_at() -> None:
|
|
started_at = "2026-03-30T12:00:00+00:00"
|
|
body = str(
|
|
runs_page(
|
|
running_executions=(
|
|
{
|
|
"source": "Running source",
|
|
"slug": "running-source",
|
|
"job_id": 1,
|
|
"execution_id": 11,
|
|
"started_at": "2 minutes ago",
|
|
"started_at_iso": started_at,
|
|
"duration": "00:00:10",
|
|
"runtime": "running for 10s",
|
|
"status": "Running",
|
|
"stats": "1 requests • 1 items • 1 byte",
|
|
"worker": "streaming stats from worker",
|
|
"log_href": "/job/1/execution/11/logs",
|
|
"cancel_label": "Stop",
|
|
"cancel_post_path": "/actions/executions/11/cancel",
|
|
},
|
|
)
|
|
)
|
|
)
|
|
|
|
assert re.search(
|
|
r"<th[^>]*>State</th>\s*<th[^>]*>Source</th>\s*<th[^>]*>Details</th>",
|
|
body,
|
|
)
|
|
assert not re.search(
|
|
r"<th[^>]*>#</th>\s*<th[^>]*>Source</th>\s*<th[^>]*>Activity</th>\s*<th[^>]*>State</th>",
|
|
body,
|
|
)
|
|
assert ">#11<" in body
|
|
assert ">00:00:10<" in body
|
|
assert ">2 minutes ago<" in body
|
|
assert 'data-started-at="2026-03-30T12:00:00+00:00"' in body
|
|
assert 'title="2026-03-30T12:00:00+00:00"' in body
|
|
assert "bg-sky-100 text-sky-800" in body
|
|
assert 'viewBox="0 0 640 640"' in body
|
|
assert "rotate-180" in body
|
|
assert "M512 320C512 214 426 128 320 128L320 512" in body
|
|
assert "M12 6v6h4.5" in body
|
|
assert "M6.75 3v2.25M17.25 3v2.25" in body
|
|
assert "Running" in body
|
|
assert "time[data-next-run-at], time[data-ended-at], time[data-started-at]" in body
|
|
|
|
|
|
def test_runs_page_moves_scheduled_jobs_state_column_to_second_position() -> None:
|
|
body = str(
|
|
runs_page(
|
|
upcoming_jobs=(
|
|
{
|
|
"source": "Parity source",
|
|
"slug": "parity-source",
|
|
"job_id": 7,
|
|
"next_run": "in 5 minutes",
|
|
"next_run_at": "2026-03-30T12:35:00+00:00",
|
|
"schedule": "*/5 * * * *",
|
|
"enabled_label": "Enabled",
|
|
"enabled_tone": "scheduled",
|
|
"run_disabled": False,
|
|
"run_reason": "Ready",
|
|
"toggle_label": "Disable",
|
|
"toggle_post_path": "/actions/jobs/7/toggle-enabled",
|
|
"run_post_path": "/actions/jobs/7/run-now",
|
|
"delete_post_path": "/actions/jobs/7/delete",
|
|
},
|
|
)
|
|
)
|
|
)
|
|
|
|
assert re.search(
|
|
r"<th[^>]*>Source</th>\s*<th[^>]*>State</th>\s*<th[^>]*>Next run</th>",
|
|
body,
|
|
)
|
|
assert re.search(
|
|
r"Parity source.*?>Enabled<.*?>in 5 minutes<.*?>\*/5 \* \* \* \*<",
|
|
body,
|
|
)
|
|
|
|
|
|
def test_sources_page_removes_view_runs_action_and_last_run_caption() -> None:
|
|
body = str(
|
|
sources_page(
|
|
sources=(
|
|
{
|
|
"name": "Source one",
|
|
"slug": "source-one",
|
|
"source_type": "Feed",
|
|
"upstream": "https://example.com/feed.xml",
|
|
"schedule": "cron: */5 * * * *",
|
|
"last_run": "Never run",
|
|
"state": "Enabled",
|
|
"state_tone": "scheduled",
|
|
},
|
|
)
|
|
)
|
|
)
|
|
|
|
assert ">Edit<" in body
|
|
assert ">Delete<" in body
|
|
assert "View runs" not in body
|
|
assert "Never run" not in body
|
|
|
|
|
|
def test_runs_page_renders_clear_completed_button_and_pagination() -> None:
|
|
completed_executions = tuple(
|
|
{
|
|
"source": f"Completed source {index}",
|
|
"slug": f"completed-source-{index}",
|
|
"job_id": 7,
|
|
"execution_id": index,
|
|
"ended_at": "2 hours ago",
|
|
"ended_at_iso": "2026-01-15T10:00:00+00:00",
|
|
"status": "Succeeded",
|
|
"status_tone": "done",
|
|
"stats": "1 requests • 1 items • 1 bytes",
|
|
"summary": "Worker exited successfully",
|
|
"log_href": f"/job/7/execution/{index}/logs",
|
|
}
|
|
for index in range(1, 21)
|
|
)
|
|
body = str(
|
|
runs_page(
|
|
completed_executions=completed_executions,
|
|
completed_page=2,
|
|
completed_page_size=20,
|
|
completed_total_count=21,
|
|
completed_total_pages=2,
|
|
)
|
|
)
|
|
|
|
assert "/actions/completed-executions/clear" in body
|
|
assert ">Clear history<" in body
|
|
assert "Showing" in body
|
|
assert "21" in body
|
|
assert "@post('/actions/runs/completed-page/1')" in body
|
|
assert "@post('/actions/runs/completed-page/2')" in body
|
|
assert 'aria-current="page"' in body
|
|
|
|
|
|
def test_root_get_serves_datastar_shim() -> None:
|
|
async def run() -> None:
|
|
client = create_app().test_client()
|
|
|
|
response = await client.get("/")
|
|
body = await response.get_data(as_text=True)
|
|
stylesheet_href = versioned_static_asset_href("app.css")
|
|
|
|
assert response.status_code == 200
|
|
assert response.headers["ETag"]
|
|
assert body.startswith("<!doctype html>")
|
|
assert f'<link rel="stylesheet" href="{stylesheet_href}">' in body
|
|
assert (
|
|
'<script id="js" defer type="module" src="/static/datastar@1.0.0-RC.8.js"></script>'
|
|
in body
|
|
)
|
|
assert 'data-signals:tabid="self.crypto.randomUUID().substring(0,8)"' in body
|
|
assert 'data-init="@post(window.location.pathname +' in body
|
|
assert "retryMaxCount: Infinity" in body
|
|
assert "data-on:online__window=" in body
|
|
assert '<main id="morph"' in body
|
|
assert "lg:grid-cols-[14rem_minmax(0,1fr)]" in body
|
|
assert "lg:px-5 lg:py-4" in body
|
|
assert 'href="/sources"' in body
|
|
assert 'href="/runs"' in body
|
|
assert 'href="/settings"' in body
|
|
assert "Connecting" in body
|
|
|
|
asyncio.run(run())
|
|
|
|
|
|
def test_versioned_static_asset_href_uses_truncated_file_hash() -> None:
|
|
href = versioned_static_asset_href("app.css")
|
|
|
|
assert re.fullmatch(r"/static/app-[0-9a-f]{12}\.css", href)
|
|
|
|
|
|
def test_versioned_static_asset_route_serves_registered_css_file() -> None:
|
|
async def run() -> None:
|
|
client = create_app().test_client()
|
|
expected = (
|
|
Path(__file__).resolve().parents[1] / "repub" / "static" / "app.css"
|
|
).read_text(encoding="utf-8")
|
|
|
|
response = await client.get("/static/app-deadbeefcafe.css")
|
|
body = await response.get_data(as_text=True)
|
|
|
|
assert response.status_code == 200
|
|
assert response.mimetype == "text/css"
|
|
assert body == expected
|
|
|
|
asyncio.run(run())
|
|
|
|
|
|
def test_versioned_static_asset_route_preserves_existing_hyphenated_files() -> None:
|
|
async def run() -> None:
|
|
client = create_app().test_client()
|
|
|
|
response = await client.get("/static/datastar@1.0.0-RC.8.js")
|
|
body = await response.get_data(as_text=True)
|
|
|
|
assert response.status_code == 200
|
|
assert response.mimetype == "text/javascript"
|
|
assert body.startswith("// Datastar v1.0.0-RC.8")
|
|
|
|
asyncio.run(run())
|
|
|
|
|
|
def test_create_app_bootstraps_default_database_path(
|
|
monkeypatch, tmp_path: Path
|
|
) -> None:
|
|
monkeypatch.chdir(tmp_path)
|
|
|
|
app = create_app()
|
|
|
|
assert Path(app.config["REPUB_DB_PATH"]) == tmp_path / "republisher.db"
|
|
assert (tmp_path / "republisher.db").exists()
|
|
|
|
|
|
def test_root_get_honors_if_none_match() -> None:
|
|
async def run() -> None:
|
|
client = create_app().test_client()
|
|
|
|
initial = await client.get("/")
|
|
etag = initial.headers["ETag"]
|
|
|
|
response = await client.get("/", headers={"If-None-Match": etag})
|
|
|
|
assert response.status_code == 304
|
|
assert response.headers["ETag"] == etag
|
|
|
|
asyncio.run(run())
|
|
|
|
|
|
def test_dashboard_post_serves_morph_component() -> None:
|
|
async def run() -> None:
|
|
client = create_app().test_client()
|
|
async with client.request("/?u=shim", method="POST") as connection:
|
|
await connection.send_complete()
|
|
chunk = await asyncio.wait_for(connection.receive(), timeout=1)
|
|
raw_connection = cast(Any, connection)
|
|
|
|
assert raw_connection.status_code == 200
|
|
assert raw_connection.headers["Content-Type"] == "text/event-stream"
|
|
assert b"event: datastar-patch-elements" in chunk
|
|
assert b"id: " in chunk
|
|
assert b'<main id="morph"' in chunk
|
|
assert b"Operational snapshot" in chunk
|
|
assert b"Running jobs" in chunk
|
|
await connection.disconnect()
|
|
|
|
asyncio.run(run())
|
|
|
|
|
|
def test_render_sse_event_skips_unchanged_view() -> None:
|
|
async def run() -> None:
|
|
async def render() -> str:
|
|
return '<main id="morph">same</main>'
|
|
|
|
event_id, event = await render_sse_event(render)
|
|
repeated_id, repeated_event = await render_sse_event(
|
|
render, last_event_id=event_id
|
|
)
|
|
|
|
assert repeated_id == event_id
|
|
assert event is not None
|
|
assert repeated_event is None
|
|
|
|
asyncio.run(run())
|
|
|
|
|
|
def test_app_refresh_broker_publishes_events() -> None:
|
|
async def run() -> None:
|
|
app = create_app()
|
|
broker = get_refresh_broker(app)
|
|
queue = broker.subscribe()
|
|
|
|
broker.publish()
|
|
event = await asyncio.wait_for(queue.get(), timeout=1)
|
|
|
|
assert event == "refresh-event"
|
|
broker.unsubscribe(queue)
|
|
|
|
asyncio.run(run())
|
|
|
|
|
|
def test_render_stream_yields_on_connect_and_refresh() -> None:
|
|
async def run() -> None:
|
|
queue = RefreshBroker().subscribe()
|
|
renders = 0
|
|
|
|
async def render() -> str:
|
|
nonlocal renders
|
|
renders += 1
|
|
return f'<main id="morph">{renders}</main>'
|
|
|
|
stream = render_stream(queue, render)
|
|
first = await anext(stream)
|
|
await queue.put("refresh-event")
|
|
second = await anext(stream)
|
|
await stream.aclose()
|
|
|
|
assert "1</main>" in first
|
|
assert "2</main>" in second
|
|
|
|
asyncio.run(run())
|
|
|
|
|
|
def test_render_stream_uses_view_transition_for_queue_reorders() -> None:
|
|
async def run() -> None:
|
|
queue = RefreshBroker().subscribe()
|
|
|
|
async def render() -> str:
|
|
return '<main id="morph">queue</main>'
|
|
|
|
stream = render_stream(queue, render, render_on_connect=False)
|
|
await queue.put("queue-reordered")
|
|
event = await anext(stream)
|
|
await stream.aclose()
|
|
|
|
assert "useViewTransition true" in str(event)
|
|
|
|
asyncio.run(run())
|
|
|
|
|
|
def test_render_stream_stops_when_shutdown_is_requested() -> None:
|
|
async def run() -> None:
|
|
queue = RefreshBroker().subscribe()
|
|
shutdown_event = asyncio.Event()
|
|
|
|
async def render() -> str:
|
|
return '<main id="morph">queue</main>'
|
|
|
|
stream = render_stream(
|
|
queue,
|
|
render,
|
|
render_on_connect=False,
|
|
shutdown_event=shutdown_event,
|
|
)
|
|
next_event = asyncio.create_task(anext(stream))
|
|
await asyncio.sleep(0)
|
|
shutdown_event.set()
|
|
|
|
with pytest.raises(StopAsyncIteration):
|
|
await asyncio.wait_for(next_event, timeout=1)
|
|
|
|
await stream.aclose()
|
|
|
|
asyncio.run(run())
|
|
|
|
|
|
def test_render_dashboard_shows_dashboard_information_architecture(
|
|
monkeypatch, tmp_path: Path
|
|
) -> None:
|
|
db_path = tmp_path / "dashboard-render.db"
|
|
monkeypatch.setenv("REPUBLISHER_DB_PATH", str(db_path))
|
|
|
|
async def run() -> None:
|
|
app = create_app()
|
|
body = str(await render_dashboard(app))
|
|
|
|
assert "Operational snapshot" in body
|
|
assert "Running jobs" in body
|
|
assert "Published feeds" in body
|
|
assert 'href="/sources"' in body
|
|
assert 'href="/runs"' in body
|
|
assert "Create source" in body
|
|
assert "lg:grid-cols-[14rem_minmax(0,1fr)]" in body
|
|
assert "lg:px-5 lg:py-4" in body
|
|
|
|
asyncio.run(run())
|
|
|
|
|
|
def test_render_dashboard_shows_empty_state_rows(monkeypatch, tmp_path: Path) -> None:
|
|
db_path = tmp_path / "dashboard-empty.db"
|
|
monkeypatch.setenv("REPUBLISHER_DB_PATH", str(db_path))
|
|
|
|
async def run() -> None:
|
|
app = create_app()
|
|
body = str(await render_dashboard(app))
|
|
|
|
assert "No jobs are running or queued." in body
|
|
assert "No feeds have been published yet." in body
|
|
|
|
asyncio.run(run())
|
|
|
|
|
|
def test_dashboard_running_table_matches_runs_page_live_table_markup() -> None:
|
|
running_executions = (
|
|
{
|
|
"source": "Running source",
|
|
"slug": "running-source",
|
|
"job_id": 1,
|
|
"execution_id": 11,
|
|
"started_at": "2 minutes ago",
|
|
"started_at_iso": "2026-03-30T12:00:00+00:00",
|
|
"duration": "00:00:10",
|
|
"runtime": "running for 10s",
|
|
"status": "Running",
|
|
"stats": "1 requests • 1 items • 1 byte",
|
|
"worker": "streaming stats from worker",
|
|
"log_href": "/job/1/execution/11/logs",
|
|
"cancel_label": "Stop",
|
|
"cancel_post_path": "/actions/executions/11/cancel",
|
|
},
|
|
)
|
|
queued_executions = (
|
|
{
|
|
"source": "Queued source",
|
|
"slug": "queued-source",
|
|
"job_id": 2,
|
|
"execution_id": 22,
|
|
"queued_at": "2 minutes ago",
|
|
"queued_at_iso": "2026-03-30T12:28:00+00:00",
|
|
"queue_position": 1,
|
|
"status": "Queued",
|
|
"status_tone": "idle",
|
|
"run_label": "Queued",
|
|
"run_disabled": True,
|
|
"run_post_path": "/actions/jobs/2/run-now",
|
|
"cancel_post_path": "/actions/queued-executions/22/cancel",
|
|
"move_up_disabled": True,
|
|
"move_up_post_path": None,
|
|
"move_down_disabled": True,
|
|
"move_down_post_path": None,
|
|
},
|
|
)
|
|
runs_body = str(
|
|
runs_page(
|
|
running_executions=running_executions,
|
|
queued_executions=queued_executions,
|
|
)
|
|
)
|
|
dashboard_body = str(
|
|
dashboard_page_with_data(
|
|
running_executions=running_executions,
|
|
queued_executions=queued_executions,
|
|
)
|
|
)
|
|
|
|
assert "Running jobs" in dashboard_body
|
|
assert "Running executions" not in dashboard_body
|
|
assert "Running source" in dashboard_body
|
|
assert "running-source" in dashboard_body
|
|
assert "queued-source" in dashboard_body
|
|
assert "bg-sky-100 text-sky-800" in dashboard_body
|
|
assert "/job/1/execution/11/logs" in dashboard_body
|
|
assert runs_body.count(">State<") >= 1
|
|
|
|
|
|
def test_load_dashboard_view_measures_log_artifact_path(
|
|
monkeypatch, tmp_path: Path
|
|
) -> None:
|
|
db_path = tmp_path / "dashboard-footprint.db"
|
|
monkeypatch.setenv("REPUBLISHER_DB_PATH", str(db_path))
|
|
create_app()
|
|
out_dir = tmp_path / "out"
|
|
log_dir = out_dir / "logs"
|
|
cache_dir = out_dir / "httpcache"
|
|
log_dir.mkdir(parents=True)
|
|
cache_dir.mkdir(parents=True)
|
|
(log_dir / "run.log").write_bytes(b"x" * 1024)
|
|
(cache_dir / "cache.bin").write_bytes(b"y" * 2048)
|
|
|
|
snapshot = load_dashboard_view(log_dir=log_dir)["snapshot"]
|
|
|
|
assert cast(dict[str, str], snapshot)["artifact_footprint"] == "3.0 KB"
|
|
|
|
|
|
def test_render_dashboard_describes_log_artifact_footprint(
|
|
monkeypatch, tmp_path: Path
|
|
) -> None:
|
|
db_path = tmp_path / "dashboard-footprint-copy.db"
|
|
monkeypatch.setenv("REPUBLISHER_DB_PATH", str(db_path))
|
|
|
|
async def run() -> None:
|
|
app = create_app()
|
|
body = str(await render_dashboard(app))
|
|
|
|
assert "Current artifact size under the output path." in body
|
|
|
|
asyncio.run(run())
|
|
|
|
|
|
def test_load_dashboard_view_lists_source_feed_artifacts(
|
|
monkeypatch, tmp_path: Path
|
|
) -> None:
|
|
db_path = tmp_path / "dashboard-feeds.db"
|
|
monkeypatch.setenv("REPUBLISHER_DB_PATH", str(db_path))
|
|
app = create_app()
|
|
out_dir = tmp_path / "out"
|
|
log_dir = out_dir / "logs"
|
|
app.config["REPUB_LOG_DIR"] = log_dir
|
|
log_dir.mkdir(parents=True)
|
|
|
|
create_source(
|
|
name="Available source",
|
|
slug="available-source",
|
|
source_type="feed",
|
|
notes="",
|
|
spider_arguments="",
|
|
enabled=False,
|
|
cron_minute="*/5",
|
|
cron_hour="*",
|
|
cron_day_of_month="*",
|
|
cron_day_of_week="*",
|
|
cron_month="*",
|
|
feed_url="https://example.com/available.xml",
|
|
)
|
|
create_source(
|
|
name="Missing source",
|
|
slug="missing-source",
|
|
source_type="feed",
|
|
notes="",
|
|
spider_arguments="",
|
|
enabled=False,
|
|
cron_minute="*/5",
|
|
cron_hour="*",
|
|
cron_day_of_month="*",
|
|
cron_day_of_week="*",
|
|
cron_month="*",
|
|
feed_url="https://example.com/missing.xml",
|
|
)
|
|
|
|
feed_dir = out_dir / "feeds" / "available-source"
|
|
feed_dir.mkdir(parents=True)
|
|
feed_path = feed_dir / "feed.rss"
|
|
feed_path.write_bytes(b"x" * 1024)
|
|
(feed_dir / "audio.mp3").write_bytes(b"y" * 2048)
|
|
reference_time = datetime(2026, 3, 30, 12, 30, tzinfo=UTC)
|
|
updated_at = reference_time - timedelta(minutes=32)
|
|
updated_at_epoch = updated_at.timestamp()
|
|
os.utime(feed_path, (updated_at_epoch, updated_at_epoch))
|
|
|
|
source_feeds = cast(
|
|
tuple[dict[str, object], ...],
|
|
load_dashboard_view(log_dir=log_dir, now=reference_time)["source_feeds"],
|
|
)
|
|
|
|
assert source_feeds == (
|
|
{
|
|
"source": "Available source",
|
|
"slug": "available-source",
|
|
"feed_href": "/feeds/available-source/feed.rss",
|
|
"feed_status_label": "Available",
|
|
"feed_status_tone": "done",
|
|
"feed_exists": True,
|
|
"last_updated": "32 minutes ago",
|
|
"last_updated_iso": updated_at.isoformat(),
|
|
"artifact_footprint": "3.0 KB",
|
|
},
|
|
{
|
|
"source": "Missing source",
|
|
"slug": "missing-source",
|
|
"feed_href": "/feeds/missing-source/feed.rss",
|
|
"feed_status_label": "Missing",
|
|
"feed_status_tone": "failed",
|
|
"feed_exists": False,
|
|
"last_updated": "Never published",
|
|
"last_updated_iso": None,
|
|
"artifact_footprint": "0 B",
|
|
},
|
|
)
|
|
|
|
|
|
def test_render_dashboard_shows_source_feed_links_and_statuses(
|
|
monkeypatch, tmp_path: Path
|
|
) -> None:
|
|
db_path = tmp_path / "dashboard-feed-links.db"
|
|
monkeypatch.setenv("REPUBLISHER_DB_PATH", str(db_path))
|
|
app = create_app()
|
|
app.config["REPUB_LOG_DIR"] = tmp_path / "out" / "logs"
|
|
|
|
create_source(
|
|
name="Published source",
|
|
slug="published-source",
|
|
source_type="feed",
|
|
notes="",
|
|
spider_arguments="",
|
|
enabled=False,
|
|
cron_minute="*/5",
|
|
cron_hour="*",
|
|
cron_day_of_month="*",
|
|
cron_day_of_week="*",
|
|
cron_month="*",
|
|
feed_url="https://example.com/published.xml",
|
|
)
|
|
create_source(
|
|
name="Missing source",
|
|
slug="missing-source",
|
|
source_type="feed",
|
|
notes="",
|
|
spider_arguments="",
|
|
enabled=False,
|
|
cron_minute="*/5",
|
|
cron_hour="*",
|
|
cron_day_of_month="*",
|
|
cron_day_of_week="*",
|
|
cron_month="*",
|
|
feed_url="https://example.com/missing.xml",
|
|
)
|
|
|
|
async def run() -> None:
|
|
published_feed = tmp_path / "out" / "feeds" / "published-source" / "feed.rss"
|
|
published_feed.parent.mkdir(parents=True)
|
|
published_feed.write_text("<rss/>\n", encoding="utf-8")
|
|
|
|
body = str(await render_dashboard(app))
|
|
|
|
assert "Published feeds" in body
|
|
assert 'href="/feeds/published-source/feed.rss"' in body
|
|
assert 'href="/feeds/missing-source/feed.rss"' in body
|
|
assert "Available" in body
|
|
assert "Missing" in body
|
|
assert "Never published" in body
|
|
|
|
asyncio.run(run())
|
|
|
|
|
|
def test_render_sources_shows_table_and_create_link() -> None:
|
|
async def run() -> None:
|
|
body = str(await render_sources())
|
|
|
|
assert ">Sources<" in body
|
|
assert 'href="/sources/create"' in body
|
|
assert "No sources yet." in body
|
|
assert "guardian-feed" not in body
|
|
assert "podcast-audio" not in body
|
|
|
|
asyncio.run(run())
|
|
|
|
|
|
def test_render_sources_shows_live_sidebar_badges(monkeypatch, tmp_path: Path) -> None:
|
|
db_path = tmp_path / "sources-sidebar.db"
|
|
monkeypatch.setenv("REPUBLISHER_DB_PATH", str(db_path))
|
|
app = create_app()
|
|
create_source(
|
|
name="First source",
|
|
slug="first-source",
|
|
source_type="feed",
|
|
notes="",
|
|
spider_arguments="",
|
|
enabled=True,
|
|
cron_minute="0",
|
|
cron_hour="*",
|
|
cron_day_of_month="*",
|
|
cron_day_of_week="*",
|
|
cron_month="*",
|
|
feed_url="https://example.com/first.xml",
|
|
)
|
|
create_source(
|
|
name="Second source",
|
|
slug="second-source",
|
|
source_type="feed",
|
|
notes="",
|
|
spider_arguments="",
|
|
enabled=True,
|
|
cron_minute="0",
|
|
cron_hour="*",
|
|
cron_day_of_month="*",
|
|
cron_day_of_week="*",
|
|
cron_month="*",
|
|
feed_url="https://example.com/second.xml",
|
|
)
|
|
|
|
async def run() -> None:
|
|
body = str(await render_sources(app))
|
|
|
|
assert re.search(
|
|
r'href="/sources"[^>]*>.*?<span>Sources</span>\s*<span[^>]*>2</span>',
|
|
body,
|
|
re.S,
|
|
)
|
|
assert re.search(
|
|
r'href="/runs"[^>]*>.*?<span>Runs</span>\s*<span[^>]*>0</span>',
|
|
body,
|
|
re.S,
|
|
)
|
|
|
|
asyncio.run(run())
|
|
|
|
|
|
def test_render_dashboard_shows_live_sidebar_badges(
|
|
monkeypatch, tmp_path: Path
|
|
) -> None:
|
|
db_path = tmp_path / "dashboard-sidebar.db"
|
|
monkeypatch.setenv("REPUBLISHER_DB_PATH", str(db_path))
|
|
app = create_app()
|
|
create_source(
|
|
name="Dashboard source",
|
|
slug="dashboard-source",
|
|
source_type="feed",
|
|
notes="",
|
|
spider_arguments="",
|
|
enabled=True,
|
|
cron_minute="0",
|
|
cron_hour="*",
|
|
cron_day_of_month="*",
|
|
cron_day_of_week="*",
|
|
cron_month="*",
|
|
feed_url="https://example.com/dashboard.xml",
|
|
)
|
|
|
|
async def run() -> None:
|
|
body = str(await render_dashboard(app))
|
|
|
|
assert re.search(
|
|
r'href="/sources"[^>]*>.*?<span>Sources</span>\s*<span[^>]*>1</span>',
|
|
body,
|
|
re.S,
|
|
)
|
|
assert re.search(
|
|
r'href="/runs"[^>]*>.*?<span>Runs</span>\s*<span[^>]*>0</span>',
|
|
body,
|
|
re.S,
|
|
)
|
|
|
|
asyncio.run(run())
|
|
|
|
|
|
def test_render_sources_shows_delete_action_for_each_source(
|
|
monkeypatch, tmp_path: Path
|
|
) -> None:
|
|
db_path = tmp_path / "sources-delete-row.db"
|
|
monkeypatch.setenv("REPUBLISHER_DB_PATH", str(db_path))
|
|
app = create_app()
|
|
create_source(
|
|
name="Delete me",
|
|
slug="delete-me",
|
|
source_type="feed",
|
|
notes="",
|
|
spider_arguments="",
|
|
enabled=True,
|
|
cron_minute="0",
|
|
cron_hour="*",
|
|
cron_day_of_month="*",
|
|
cron_day_of_week="*",
|
|
cron_month="*",
|
|
feed_url="https://example.com/delete.xml",
|
|
)
|
|
|
|
async def run() -> None:
|
|
body = str(await render_sources(app))
|
|
|
|
assert "Delete" in body
|
|
assert "data-on:pointerdown" in body
|
|
assert "/actions/sources/delete-me/delete" in body
|
|
|
|
asyncio.run(run())
|
|
|
|
|
|
def test_render_create_source_shows_dedicated_form_page() -> None:
|
|
async def run() -> None:
|
|
body = str(await render_create_source())
|
|
|
|
assert ">Create source<" in body
|
|
assert "Source and job setup" in body
|
|
assert "data-signals__ifmissing" in body
|
|
assert "/actions/sources/create" in body
|
|
assert 'data-show="$sourceType === 'feed'"' in body
|
|
assert 'data-show="$sourceType === 'pangea'"' in body
|
|
assert "jobEnabled" in body
|
|
assert "onlyNewest" in body
|
|
assert "includeAuthors" in body
|
|
assert "excludeMedia" in body
|
|
assert "includeContent" in body
|
|
assert "convertImages" in body
|
|
assert "convertVideo" in body
|
|
assert "TEXT_ONLY" in body
|
|
assert "breakingnews" in body
|
|
assert "Pangea domain" in body
|
|
assert "Feed URL" in body
|
|
assert "Cron schedule" in body
|
|
assert "Initial job state" in body
|
|
assert "Pangea mobile articles" not in body
|
|
assert "pangea-mobile" not in body
|
|
assert "guardianproject.info" not in body
|
|
assert (
|
|
"Primary Pangea mobile article mirror for the operator landing page."
|
|
not in body
|
|
)
|
|
assert "language=en,download_media=true" not in body
|
|
assert 'id="spider-arguments"' in body
|
|
assert "language=en\ndownload_media=true" not in body
|
|
assert 'value="articles"' in body
|
|
assert 'value="10"' in body
|
|
assert 'value="3"' in body
|
|
assert 'value="*/30"' in body
|
|
assert 'value="*"' in body
|
|
|
|
asyncio.run(run())
|
|
|
|
|
|
def test_render_edit_source_shows_existing_values(monkeypatch, tmp_path: Path) -> None:
|
|
db_path = tmp_path / "edit-page.db"
|
|
monkeypatch.setenv("REPUBLISHER_DB_PATH", str(db_path))
|
|
create_app()
|
|
create_source(
|
|
name="Kenya health desk",
|
|
slug="kenya-health",
|
|
source_type="pangea",
|
|
notes="Regional health alerts.",
|
|
spider_arguments="language=en\ndownload_media=true",
|
|
enabled=True,
|
|
convert_images=False,
|
|
convert_video=False,
|
|
cron_minute="0",
|
|
cron_hour="*/6",
|
|
cron_day_of_month="*",
|
|
cron_day_of_week="*",
|
|
cron_month="*",
|
|
pangea_domain="example.org",
|
|
pangea_category="Health",
|
|
content_type="breakingnews",
|
|
only_newest=True,
|
|
max_articles=12,
|
|
oldest_article=5,
|
|
include_authors=True,
|
|
exclude_media=False,
|
|
include_content=True,
|
|
content_format="MOBILE_3",
|
|
)
|
|
|
|
async def run() -> None:
|
|
body = str(await render_edit_source("kenya-health"))
|
|
|
|
assert "Edit source" in body
|
|
assert "/actions/sources/kenya-health/edit" in body
|
|
assert "Kenya health desk" in body
|
|
assert "kenya-health" in body
|
|
assert 'id="source-slug"' in body
|
|
assert (
|
|
'id="source-slug" name="source-slug" type="text" value="kenya-health"'
|
|
in body
|
|
)
|
|
assert " disabled " in body
|
|
assert "cursor-not-allowed bg-slate-100 text-slate-500" in body
|
|
assert "example.org" in body
|
|
assert "Health" in body
|
|
assert "language=en\ndownload_media=true" in body
|
|
assert "convertImages: false" in body
|
|
assert "convertVideo: false" in body
|
|
|
|
asyncio.run(run())
|
|
|
|
|
|
def test_render_settings_shows_current_max_concurrent_jobs(
|
|
monkeypatch, tmp_path: Path
|
|
) -> None:
|
|
db_path = tmp_path / "settings-page.db"
|
|
monkeypatch.setenv("REPUBLISHER_DB_PATH", str(db_path))
|
|
create_app()
|
|
save_setting("max_concurrent_jobs", 3)
|
|
save_setting("feed_url", "https://mirror.example")
|
|
|
|
async def run() -> None:
|
|
app = create_app()
|
|
body = str(await render_settings(app))
|
|
|
|
assert ">Settings<" in body
|
|
assert "/actions/settings" in body
|
|
assert 'value="3"' in body
|
|
assert 'value="https://mirror.example"' in body
|
|
assert "Max concurrent jobs" in body
|
|
assert "Feed URL" in body
|
|
assert "Example: http://localhost:8080" in body
|
|
assert "Must include http:// or https://" in body
|
|
assert 'type="submit"' in body
|
|
assert "cursor-pointer" in body
|
|
|
|
asyncio.run(run())
|
|
|
|
|
|
def test_create_source_action_creates_pangea_source_and_job_in_database(
|
|
monkeypatch, tmp_path: Path
|
|
) -> None:
|
|
db_path = tmp_path / "sources.db"
|
|
monkeypatch.setenv("REPUBLISHER_DB_PATH", str(db_path))
|
|
|
|
async def run() -> None:
|
|
app = create_app()
|
|
client = app.test_client()
|
|
|
|
response = await client.post(
|
|
"/actions/sources/create",
|
|
headers={"Datastar-Request": "true"},
|
|
json={
|
|
"sourceName": "Kenya health desk",
|
|
"sourceSlug": "kenya-health",
|
|
"sourceType": "pangea",
|
|
"pangeaDomain": "example.org",
|
|
"pangeaCategory": " Health ",
|
|
"contentFormat": "MOBILE_3",
|
|
"contentType": "breakingnews",
|
|
"maxArticles": "12",
|
|
"oldestArticle": "5",
|
|
"sourceNotes": "Regional health alerts.",
|
|
"spiderArguments": "language=en\ndownload_media=true",
|
|
"cronMinute": "0",
|
|
"cronHour": "*/6",
|
|
"cronDayOfMonth": "*",
|
|
"cronDayOfWeek": "*",
|
|
"cronMonth": "*",
|
|
"jobEnabled": True,
|
|
"onlyNewest": True,
|
|
"includeAuthors": True,
|
|
"excludeMedia": False,
|
|
},
|
|
)
|
|
body = await response.get_data(as_text=True)
|
|
|
|
assert response.status_code == 200
|
|
assert "window.location = '/sources'" in body
|
|
|
|
source = Source.get(Source.slug == "kenya-health")
|
|
pangea = SourcePangea.get(SourcePangea.source == source)
|
|
job = Job.get(Job.source == source)
|
|
rendered_sources = str(await render_sources(app))
|
|
|
|
assert source.name == "Kenya health desk"
|
|
assert source.source_type == "pangea"
|
|
assert pangea.category_name == " Health "
|
|
assert pangea.content_type == "breakingnews"
|
|
assert pangea.include_content is True
|
|
assert job.enabled is True
|
|
assert job.convert_images is True
|
|
assert job.convert_video is True
|
|
assert job.spider_arguments == "language=en\ndownload_media=true"
|
|
assert job.cron_hour == "*/6"
|
|
assert "kenya-health" in rendered_sources
|
|
assert "Enabled" in rendered_sources
|
|
|
|
asyncio.run(run())
|
|
|
|
|
|
def test_create_source_action_creates_feed_source_and_job_in_database(
|
|
monkeypatch, tmp_path: Path
|
|
) -> None:
|
|
db_path = tmp_path / "feed-sources.db"
|
|
monkeypatch.setenv("REPUBLISHER_DB_PATH", str(db_path))
|
|
|
|
async def run() -> None:
|
|
app = create_app()
|
|
client = app.test_client()
|
|
|
|
response = await client.post(
|
|
"/actions/sources/create",
|
|
headers={"Datastar-Request": "true"},
|
|
json={
|
|
"sourceName": "NASA feed",
|
|
"sourceSlug": "nasa-feed",
|
|
"sourceType": "feed",
|
|
"feedUrl": "https://www.nasa.gov/rss/dyn/breaking_news.rss",
|
|
"sourceNotes": "Primary NASA mirror.",
|
|
"spiderArguments": "",
|
|
"cronMinute": "30",
|
|
"cronHour": "*",
|
|
"cronDayOfMonth": "*",
|
|
"cronDayOfWeek": "*",
|
|
"cronMonth": "*",
|
|
"jobEnabled": False,
|
|
},
|
|
)
|
|
body = await response.get_data(as_text=True)
|
|
|
|
assert response.status_code == 200
|
|
assert "window.location = '/sources'" in body
|
|
|
|
source = Source.get(Source.slug == "nasa-feed")
|
|
feed = SourceFeed.get(SourceFeed.source == source)
|
|
job = Job.get(Job.source == source)
|
|
rendered_sources = str(await render_sources(app))
|
|
|
|
assert source.source_type == "feed"
|
|
assert feed.feed_url == "https://www.nasa.gov/rss/dyn/breaking_news.rss"
|
|
assert job.enabled is False
|
|
assert "nasa-feed" in rendered_sources
|
|
assert "https://www.nasa.gov/rss/dyn/breaking_news.rss" in rendered_sources
|
|
assert "Disabled" in rendered_sources
|
|
|
|
asyncio.run(run())
|
|
|
|
|
|
def test_edit_source_action_updates_existing_source_and_job_in_database(
|
|
monkeypatch, tmp_path: Path
|
|
) -> None:
|
|
db_path = tmp_path / "edit-source.db"
|
|
monkeypatch.setenv("REPUBLISHER_DB_PATH", str(db_path))
|
|
create_app()
|
|
create_source(
|
|
name="Kenya health desk",
|
|
slug="kenya-health",
|
|
source_type="pangea",
|
|
notes="Regional health alerts.",
|
|
spider_arguments="language=en\ndownload_media=true",
|
|
enabled=True,
|
|
cron_minute="0",
|
|
cron_hour="*/6",
|
|
cron_day_of_month="*",
|
|
cron_day_of_week="*",
|
|
cron_month="*",
|
|
pangea_domain="example.org",
|
|
pangea_category="Health",
|
|
content_type="breakingnews",
|
|
only_newest=True,
|
|
max_articles=12,
|
|
oldest_article=5,
|
|
include_authors=True,
|
|
exclude_media=False,
|
|
include_content=True,
|
|
content_format="MOBILE_3",
|
|
)
|
|
|
|
async def run() -> None:
|
|
app = create_app()
|
|
client = app.test_client()
|
|
|
|
response = await client.post(
|
|
"/actions/sources/kenya-health/edit",
|
|
headers={"Datastar-Request": "true"},
|
|
json={
|
|
"sourceName": "Kenya health desk nightly",
|
|
"sourceSlug": "kenya-health",
|
|
"sourceType": "pangea",
|
|
"pangeaDomain": "example.org",
|
|
"pangeaCategory": "Nightly",
|
|
"contentFormat": "TEXT_ONLY",
|
|
"contentType": "articles",
|
|
"maxArticles": "25",
|
|
"oldestArticle": "7",
|
|
"sourceNotes": "Updated nightly run.",
|
|
"spiderArguments": "language=sw\ninclude_audio=false",
|
|
"cronMinute": "15",
|
|
"cronHour": "2",
|
|
"cronDayOfMonth": "*",
|
|
"cronDayOfWeek": "*",
|
|
"cronMonth": "*",
|
|
"jobEnabled": False,
|
|
"convertImages": False,
|
|
"convertVideo": False,
|
|
"onlyNewest": False,
|
|
"includeAuthors": False,
|
|
"excludeMedia": True,
|
|
"includeContent": True,
|
|
},
|
|
)
|
|
body = await response.get_data(as_text=True)
|
|
|
|
assert response.status_code == 200
|
|
assert "window.location = '/sources'" in body
|
|
|
|
source = Source.get(Source.slug == "kenya-health")
|
|
pangea = SourcePangea.get(SourcePangea.source == source)
|
|
job = Job.get(Job.source == source)
|
|
rendered_sources = str(await render_sources(app))
|
|
|
|
assert source.name == "Kenya health desk nightly"
|
|
assert source.notes == "Updated nightly run."
|
|
assert pangea.category_name == "Nightly"
|
|
assert pangea.content_format == "TEXT_ONLY"
|
|
assert pangea.max_articles == 25
|
|
assert pangea.include_authors is False
|
|
assert pangea.exclude_media is True
|
|
assert job.enabled is False
|
|
assert job.convert_images is False
|
|
assert job.convert_video is False
|
|
assert job.spider_arguments == "language=sw\ninclude_audio=false"
|
|
assert job.cron_hour == "2"
|
|
assert "Kenya health desk nightly" in rendered_sources
|
|
assert "example.org / Nightly" in rendered_sources
|
|
assert "Disabled" in rendered_sources
|
|
|
|
asyncio.run(run())
|
|
|
|
|
|
def test_edit_source_action_rejects_slug_changes(monkeypatch, tmp_path: Path) -> None:
|
|
db_path = tmp_path / "edit-invalid.db"
|
|
monkeypatch.setenv("REPUBLISHER_DB_PATH", str(db_path))
|
|
create_app()
|
|
create_source(
|
|
name="Kenya health desk",
|
|
slug="kenya-health",
|
|
source_type="pangea",
|
|
notes="Regional health alerts.",
|
|
spider_arguments="language=en\ndownload_media=true",
|
|
enabled=True,
|
|
cron_minute="0",
|
|
cron_hour="*/6",
|
|
cron_day_of_month="*",
|
|
cron_day_of_week="*",
|
|
cron_month="*",
|
|
pangea_domain="example.org",
|
|
pangea_category="Health",
|
|
content_type="breakingnews",
|
|
only_newest=True,
|
|
max_articles=12,
|
|
oldest_article=5,
|
|
include_authors=True,
|
|
exclude_media=False,
|
|
include_content=True,
|
|
content_format="MOBILE_3",
|
|
)
|
|
|
|
async def run() -> None:
|
|
app = create_app()
|
|
client = app.test_client()
|
|
|
|
response = await client.post(
|
|
"/actions/sources/kenya-health/edit",
|
|
headers={"Datastar-Request": "true"},
|
|
json={
|
|
"sourceName": "Kenya health desk",
|
|
"sourceSlug": "kenya-health-renamed",
|
|
"sourceType": "pangea",
|
|
"pangeaDomain": "example.org",
|
|
"pangeaCategory": "Health",
|
|
"contentFormat": "MOBILE_3",
|
|
"contentType": "breakingnews",
|
|
"maxArticles": "12",
|
|
"oldestArticle": "5",
|
|
"sourceNotes": "Regional health alerts.",
|
|
"spiderArguments": "language=en\ndownload_media=true",
|
|
"cronMinute": "0",
|
|
"cronHour": "*/6",
|
|
"cronDayOfMonth": "*",
|
|
"cronDayOfWeek": "*",
|
|
"cronMonth": "*",
|
|
"jobEnabled": True,
|
|
"onlyNewest": True,
|
|
"includeAuthors": True,
|
|
"excludeMedia": False,
|
|
"includeContent": True,
|
|
},
|
|
)
|
|
body = await response.get_data(as_text=True)
|
|
|
|
assert response.status_code == 200
|
|
assert "Slug is immutable." in body
|
|
assert Source.get(Source.slug == "kenya-health").name == "Kenya health desk"
|
|
assert Source.select().where(Source.slug == "kenya-health-renamed").count() == 0
|
|
|
|
asyncio.run(run())
|
|
|
|
|
|
def test_create_source_action_validates_duplicate_slug_and_pangea_type(
|
|
monkeypatch, tmp_path: Path
|
|
) -> None:
|
|
db_path = tmp_path / "duplicate.db"
|
|
monkeypatch.setenv("REPUBLISHER_DB_PATH", str(db_path))
|
|
|
|
async def run() -> None:
|
|
app = create_app()
|
|
Source.create(
|
|
name="Guardian feed mirror",
|
|
slug="guardian-feed",
|
|
source_type="feed",
|
|
)
|
|
client = app.test_client()
|
|
|
|
response = await client.post(
|
|
"/actions/sources/create",
|
|
headers={"Datastar-Request": "true"},
|
|
json={
|
|
"sourceName": "Duplicate guardian",
|
|
"sourceSlug": "guardian-feed",
|
|
"sourceType": "pangea",
|
|
"pangeaDomain": "example.org",
|
|
"pangeaCategory": "News",
|
|
"contentFormat": "WEB",
|
|
"contentType": "not-a-real-type",
|
|
"maxArticles": "ten",
|
|
"oldestArticle": "3",
|
|
"cronMinute": "0",
|
|
"cronHour": "*",
|
|
"cronDayOfMonth": "*",
|
|
"cronDayOfWeek": "*",
|
|
"cronMonth": "*",
|
|
"jobEnabled": True,
|
|
},
|
|
)
|
|
body = await response.get_data(as_text=True)
|
|
|
|
assert response.status_code == 200
|
|
assert "Slug must be unique." in body
|
|
assert "Content format is invalid." in body
|
|
assert "Content type is invalid." in body
|
|
assert "Max articles must be an integer." in body
|
|
assert Source.select().where(Source.name == "Duplicate guardian").count() == 0
|
|
|
|
asyncio.run(run())
|
|
|
|
|
|
def test_settings_action_updates_max_concurrent_jobs(
|
|
monkeypatch, tmp_path: Path
|
|
) -> None:
|
|
db_path = tmp_path / "settings-action.db"
|
|
monkeypatch.setenv("REPUBLISHER_DB_PATH", str(db_path))
|
|
|
|
async def run() -> None:
|
|
app = create_app()
|
|
client = app.test_client()
|
|
|
|
response = await client.post(
|
|
"/actions/settings",
|
|
headers={"Datastar-Request": "true"},
|
|
json={
|
|
"maxConcurrentJobs": "3",
|
|
"feedUrl": "https://mirror.example",
|
|
},
|
|
)
|
|
body = await response.get_data(as_text=True)
|
|
|
|
assert response.status_code == 200
|
|
assert "window.location = '/settings'" in body
|
|
assert load_max_concurrent_jobs() == 3
|
|
assert load_settings_form()["feed_url"] == "https://mirror.example"
|
|
assert 'value="3"' in str(await render_settings(app))
|
|
|
|
asyncio.run(run())
|
|
|
|
|
|
def test_settings_action_rejects_non_positive_max_concurrent_jobs(
|
|
monkeypatch, tmp_path: Path
|
|
) -> None:
|
|
db_path = tmp_path / "settings-invalid.db"
|
|
monkeypatch.setenv("REPUBLISHER_DB_PATH", str(db_path))
|
|
|
|
async def run() -> None:
|
|
app = create_app()
|
|
client = app.test_client()
|
|
|
|
response = await client.post(
|
|
"/actions/settings",
|
|
headers={"Datastar-Request": "true"},
|
|
json={"maxConcurrentJobs": "0", "feedUrl": "https://mirror.example"},
|
|
)
|
|
body = await response.get_data(as_text=True)
|
|
|
|
assert response.status_code == 200
|
|
assert "Max concurrent jobs must be at least 1." in body
|
|
assert load_max_concurrent_jobs() == 1
|
|
|
|
asyncio.run(run())
|
|
|
|
|
|
def test_settings_action_rejects_invalid_feed_url(monkeypatch, tmp_path: Path) -> None:
|
|
db_path = tmp_path / "settings-invalid-url.db"
|
|
monkeypatch.setenv("REPUBLISHER_DB_PATH", str(db_path))
|
|
|
|
async def run() -> None:
|
|
app = create_app()
|
|
client = app.test_client()
|
|
|
|
response = await client.post(
|
|
"/actions/settings",
|
|
headers={"Datastar-Request": "true"},
|
|
json={"maxConcurrentJobs": "2", "feedUrl": "mirror.example"},
|
|
)
|
|
body = await response.get_data(as_text=True)
|
|
|
|
assert response.status_code == 200
|
|
assert "Feed URL must be a valid URL." in body
|
|
assert load_settings_form()["feed_url"] == ""
|
|
|
|
asyncio.run(run())
|
|
|
|
|
|
def test_render_runs_shows_running_scheduled_and_completed_tables(
|
|
monkeypatch, tmp_path: Path
|
|
) -> None:
|
|
db_path = tmp_path / "runs-render.db"
|
|
monkeypatch.setenv("REPUBLISHER_DB_PATH", str(db_path))
|
|
|
|
async def run() -> None:
|
|
app = create_app()
|
|
|
|
source = create_source(
|
|
name="Runs render source",
|
|
slug="runs-render-source",
|
|
source_type="feed",
|
|
notes="",
|
|
spider_arguments="",
|
|
enabled=True,
|
|
cron_minute="*/30",
|
|
cron_hour="*",
|
|
cron_day_of_month="*",
|
|
cron_day_of_week="*",
|
|
cron_month="*",
|
|
feed_url="https://example.com/runs.xml",
|
|
)
|
|
job = Job.get(Job.source == source)
|
|
execution = JobExecution.create(
|
|
job=job,
|
|
running_status=JobExecutionStatus.SUCCEEDED,
|
|
)
|
|
|
|
body = str(await render_runs(app))
|
|
|
|
assert "Running jobs" in body
|
|
assert "Scheduled jobs" in body
|
|
assert "Completed job executions" in body
|
|
assert "runs-render-source" in body
|
|
assert f"/job/{job.id}/execution/{execution.get_id()}/logs" in body
|
|
assert "data-next-run-at" in body
|
|
assert "in " in body
|
|
|
|
asyncio.run(run())
|
|
|
|
|
|
def test_render_runs_uses_compact_shell_and_table_classes(
|
|
monkeypatch, tmp_path: Path
|
|
) -> None:
|
|
db_path = tmp_path / "runs-compact.db"
|
|
monkeypatch.setenv("REPUBLISHER_DB_PATH", str(db_path))
|
|
|
|
async def run() -> None:
|
|
app = create_app()
|
|
body = str(await render_runs(app))
|
|
|
|
assert "lg:grid-cols-[14rem_minmax(0,1fr)]" in body
|
|
assert "lg:px-5 lg:py-4" in body
|
|
assert "min-w-[64rem]" in body
|
|
|
|
asyncio.run(run())
|
|
|
|
|
|
def test_render_runs_shows_empty_state_rows(monkeypatch, tmp_path: Path) -> None:
|
|
db_path = tmp_path / "runs-empty.db"
|
|
monkeypatch.setenv("REPUBLISHER_DB_PATH", str(db_path))
|
|
|
|
async def run() -> None:
|
|
app = create_app()
|
|
body = str(await render_runs(app))
|
|
|
|
assert body.count("No jobs are running or queued.") == 1
|
|
assert "No jobs are scheduled." in body
|
|
assert "No job executions have completed yet." in body
|
|
|
|
asyncio.run(run())
|
|
|
|
|
|
def test_runs_pagination_action_updates_only_the_current_tab(
|
|
monkeypatch, tmp_path: Path
|
|
) -> None:
|
|
db_path = tmp_path / "runs-tab-pagination.db"
|
|
monkeypatch.setenv("REPUBLISHER_DB_PATH", str(db_path))
|
|
|
|
async def run() -> None:
|
|
app = create_app()
|
|
client = app.test_client()
|
|
|
|
source = create_source(
|
|
name="Paged runs source",
|
|
slug="paged-runs-source",
|
|
source_type="feed",
|
|
notes="",
|
|
spider_arguments="",
|
|
enabled=True,
|
|
cron_minute="*/30",
|
|
cron_hour="*",
|
|
cron_day_of_month="*",
|
|
cron_day_of_week="*",
|
|
cron_month="*",
|
|
feed_url="https://example.com/paged-runs.xml",
|
|
)
|
|
job = Job.get(Job.source == source)
|
|
|
|
for minute in range(21):
|
|
JobExecution.create(
|
|
job=job,
|
|
ended_at=datetime(2026, 3, 30, 12, minute, tzinfo=UTC),
|
|
running_status=JobExecutionStatus.SUCCEEDED,
|
|
)
|
|
|
|
async with client.request(
|
|
"/runs?u=shim",
|
|
method="POST",
|
|
headers={
|
|
"Datastar-Request": "true",
|
|
"Content-Type": "application/json",
|
|
},
|
|
) as first_connection:
|
|
async with client.request(
|
|
"/runs?u=shim",
|
|
method="POST",
|
|
headers={
|
|
"Datastar-Request": "true",
|
|
"Content-Type": "application/json",
|
|
},
|
|
) as second_connection:
|
|
await first_connection.send(json.dumps({"tabid": "tab-1"}).encode())
|
|
await second_connection.send(json.dumps({"tabid": "tab-2"}).encode())
|
|
await first_connection.send_complete()
|
|
await second_connection.send_complete()
|
|
|
|
first_body = (
|
|
await asyncio.wait_for(first_connection.receive(), timeout=1)
|
|
).decode()
|
|
second_body = (
|
|
await asyncio.wait_for(second_connection.receive(), timeout=1)
|
|
).decode()
|
|
|
|
assert (
|
|
'href="/runs?completed_page=1" aria-current="page"'
|
|
not in first_body
|
|
)
|
|
assert (
|
|
'Showing <span class="font-medium text-slate-950">1</span> to '
|
|
'<span class="font-medium text-slate-950">20</span> of '
|
|
'<span class="font-medium text-slate-950">21</span> results'
|
|
) in first_body
|
|
assert (
|
|
'Showing <span class="font-medium text-slate-950">1</span> to '
|
|
'<span class="font-medium text-slate-950">20</span> of '
|
|
'<span class="font-medium text-slate-950">21</span> results'
|
|
) in second_body
|
|
|
|
response = await client.post(
|
|
"/actions/runs/completed-page/2",
|
|
headers={"Datastar-Request": "true"},
|
|
json={"tabid": "tab-1"},
|
|
)
|
|
|
|
assert response.status_code == 204
|
|
|
|
updated_first_body = (
|
|
await asyncio.wait_for(first_connection.receive(), timeout=1)
|
|
).decode()
|
|
|
|
assert (
|
|
'Showing <span class="font-medium text-slate-950">21</span> to '
|
|
'<span class="font-medium text-slate-950">21</span> of '
|
|
'<span class="font-medium text-slate-950">21</span> results'
|
|
) in updated_first_body
|
|
assert 'aria-current="page"' in updated_first_body
|
|
|
|
with pytest.raises(asyncio.TimeoutError):
|
|
await asyncio.wait_for(second_connection.receive(), timeout=0.2)
|
|
|
|
await second_connection.disconnect()
|
|
await first_connection.disconnect()
|
|
|
|
asyncio.run(run())
|
|
|
|
|
|
def test_runs_patch_creates_and_cleans_up_tab_state(
|
|
monkeypatch, tmp_path: Path
|
|
) -> None:
|
|
db_path = tmp_path / "runs-tab-state.db"
|
|
monkeypatch.setenv("REPUBLISHER_DB_PATH", str(db_path))
|
|
|
|
async def run() -> None:
|
|
app = create_app()
|
|
client = app.test_client()
|
|
|
|
async with client.request(
|
|
"/runs?u=shim",
|
|
method="POST",
|
|
headers={
|
|
"Datastar-Request": "true",
|
|
"Content-Type": "application/json",
|
|
},
|
|
) as connection:
|
|
await connection.send(json.dumps({"tabid": "tab-1"}).encode())
|
|
await connection.send_complete()
|
|
await asyncio.wait_for(connection.receive(), timeout=1)
|
|
|
|
assert get_tab_state_store(app).get_tab_state("tab-1") == {}
|
|
|
|
await connection.disconnect()
|
|
await asyncio.sleep(0)
|
|
|
|
assert get_tab_state_store(app).get_tab_state("tab-1") is None
|
|
|
|
asyncio.run(run())
|
|
|
|
|
|
def test_render_runs_keeps_queued_execution_in_scheduled_jobs_table(
|
|
monkeypatch, tmp_path: Path
|
|
) -> None:
|
|
db_path = tmp_path / "runs-queued-render.db"
|
|
log_dir = tmp_path / "out" / "logs"
|
|
monkeypatch.setenv("REPUBLISHER_DB_PATH", str(db_path))
|
|
app = create_app()
|
|
app.config["REPUB_LOG_DIR"] = log_dir
|
|
|
|
queued_source = create_source(
|
|
name="Queued source",
|
|
slug="queued-source",
|
|
source_type="feed",
|
|
notes="",
|
|
spider_arguments="",
|
|
enabled=True,
|
|
cron_minute="*/5",
|
|
cron_hour="*",
|
|
cron_day_of_month="*",
|
|
cron_day_of_week="*",
|
|
cron_month="*",
|
|
feed_url="https://example.com/queued.xml",
|
|
)
|
|
create_source(
|
|
name="Scheduled source",
|
|
slug="scheduled-source",
|
|
source_type="feed",
|
|
notes="",
|
|
spider_arguments="",
|
|
enabled=True,
|
|
cron_minute="*/5",
|
|
cron_hour="*",
|
|
cron_day_of_month="*",
|
|
cron_day_of_week="*",
|
|
cron_month="*",
|
|
feed_url="https://example.com/scheduled.xml",
|
|
)
|
|
queued_job = Job.get(Job.source == queued_source)
|
|
queued_execution = JobExecution.create(
|
|
job=queued_job,
|
|
running_status=JobExecutionStatus.PENDING,
|
|
)
|
|
|
|
async def run() -> None:
|
|
body = str(await render_runs(app))
|
|
|
|
assert "Running jobs" in body
|
|
assert "Scheduled jobs" in body
|
|
assert "queued-source" in body
|
|
assert "scheduled-source" in body
|
|
assert ">Queued<" in body
|
|
assert (
|
|
f"/actions/queued-executions/{int(queued_execution.get_id())}/cancel"
|
|
in body
|
|
)
|
|
assert "Ready" in body
|
|
|
|
asyncio.run(run())
|
|
|
|
|
|
def test_render_runs_shows_cancel_button_for_running_row_with_queued_follow_up(
|
|
monkeypatch, tmp_path: Path
|
|
) -> None:
|
|
db_path = tmp_path / "runs-cancel-follow-up.db"
|
|
log_dir = tmp_path / "out" / "logs"
|
|
monkeypatch.setenv("REPUBLISHER_DB_PATH", str(db_path))
|
|
app = create_app()
|
|
app.config["REPUB_LOG_DIR"] = log_dir
|
|
|
|
source = create_source(
|
|
name="Busy source",
|
|
slug="busy-source",
|
|
source_type="feed",
|
|
notes="",
|
|
spider_arguments="",
|
|
enabled=True,
|
|
cron_minute="*/5",
|
|
cron_hour="*",
|
|
cron_day_of_month="*",
|
|
cron_day_of_week="*",
|
|
cron_month="*",
|
|
feed_url="https://example.com/busy.xml",
|
|
)
|
|
job = Job.get(Job.source == source)
|
|
running_execution = JobExecution.create(
|
|
job=job,
|
|
started_at=datetime(2026, 3, 30, 12, 0, tzinfo=UTC),
|
|
running_status=JobExecutionStatus.RUNNING,
|
|
)
|
|
pending_execution = JobExecution.create(
|
|
job=job,
|
|
running_status=JobExecutionStatus.PENDING,
|
|
)
|
|
|
|
async def run() -> None:
|
|
body = str(await render_runs(app))
|
|
|
|
assert f"/job/{job.id}/execution/{int(running_execution.get_id())}/logs" in body
|
|
assert (
|
|
f"/actions/queued-executions/{int(pending_execution.get_id())}/cancel"
|
|
in body
|
|
)
|
|
assert ">Cancel<" in body
|
|
assert "Running jobs" in body
|
|
|
|
asyncio.run(run())
|
|
|
|
|
|
def test_render_runs_keeps_all_action_controls_visible_in_html_after_compaction() -> (
|
|
None
|
|
):
|
|
body = str(
|
|
runs_page(
|
|
running_executions=(
|
|
{
|
|
"source": "Running source",
|
|
"slug": "running-source",
|
|
"job_id": 1,
|
|
"execution_id": 11,
|
|
"started_at": "2026-03-30 12:00 UTC",
|
|
"runtime": "running for 10s",
|
|
"status": "Running",
|
|
"stats": "1 requests • 1 items • 1 byte",
|
|
"worker": "streaming stats from worker",
|
|
"log_href": "/job/1/execution/11/logs",
|
|
"cancel_label": "Stop",
|
|
"cancel_post_path": "/actions/executions/11/cancel",
|
|
},
|
|
),
|
|
queued_executions=(
|
|
{
|
|
"source": "Queued source",
|
|
"slug": "queued-source",
|
|
"job_id": 2,
|
|
"execution_id": 22,
|
|
"queued_at": "2 minutes ago",
|
|
"queued_at_iso": "2026-03-30T12:28:00+00:00",
|
|
"queue_position": 1,
|
|
"status": "Queued",
|
|
"status_tone": "idle",
|
|
"run_label": "Queued",
|
|
"run_disabled": True,
|
|
"run_post_path": "/actions/jobs/2/run-now",
|
|
"cancel_post_path": "/actions/queued-executions/22/cancel",
|
|
"move_up_disabled": True,
|
|
"move_up_post_path": None,
|
|
"move_down_disabled": True,
|
|
"move_down_post_path": None,
|
|
},
|
|
),
|
|
upcoming_jobs=(
|
|
{
|
|
"source": "Scheduled source",
|
|
"slug": "scheduled-source",
|
|
"job_id": 3,
|
|
"next_run": "in 5 minutes",
|
|
"next_run_at": "2026-03-30T12:35:00+00:00",
|
|
"schedule": "*/5 * * * *",
|
|
"enabled_label": "Enabled",
|
|
"enabled_tone": "scheduled",
|
|
"run_disabled": False,
|
|
"run_reason": "Ready",
|
|
"toggle_label": "Disable",
|
|
"toggle_post_path": "/actions/jobs/3/toggle-enabled",
|
|
"run_post_path": "/actions/jobs/3/run-now",
|
|
"delete_post_path": "/actions/jobs/3/delete",
|
|
},
|
|
),
|
|
completed_executions=(
|
|
{
|
|
"source": "Completed source",
|
|
"slug": "completed-source",
|
|
"job_id": 4,
|
|
"execution_id": 44,
|
|
"ended_at": "2 minutes ago",
|
|
"ended_at_iso": "2026-03-30T12:28:00+00:00",
|
|
"status": "Succeeded",
|
|
"status_tone": "done",
|
|
"stats": "1 requests • 1 items • 1 byte",
|
|
"summary": "Worker exited successfully",
|
|
"log_href": "/job/4/execution/44/logs",
|
|
},
|
|
),
|
|
)
|
|
)
|
|
|
|
assert "Running jobs" in body
|
|
assert ">Stop<" in body
|
|
assert ">Cancel<" in body
|
|
assert ">Run now<" in body
|
|
assert ">Disable<" in body
|
|
assert "/job/4/execution/44/logs" in body
|
|
|
|
|
|
def test_cancel_queued_execution_action_deletes_pending_row_without_touching_running_execution(
|
|
monkeypatch, tmp_path: Path
|
|
) -> None:
|
|
db_path = tmp_path / "cancel-queued-action.db"
|
|
log_dir = tmp_path / "out" / "logs"
|
|
monkeypatch.setenv("REPUBLISHER_DB_PATH", str(db_path))
|
|
|
|
async def run() -> None:
|
|
app = create_app()
|
|
app.config["REPUB_LOG_DIR"] = log_dir
|
|
client = app.test_client()
|
|
|
|
source = create_source(
|
|
name="Busy source",
|
|
slug="busy-source",
|
|
source_type="feed",
|
|
notes="",
|
|
spider_arguments="",
|
|
enabled=True,
|
|
cron_minute="*/5",
|
|
cron_hour="*",
|
|
cron_day_of_month="*",
|
|
cron_day_of_week="*",
|
|
cron_month="*",
|
|
feed_url="https://example.com/busy.xml",
|
|
)
|
|
job = Job.get(Job.source == source)
|
|
running_execution = JobExecution.create(
|
|
job=job,
|
|
started_at=datetime(2026, 3, 30, 12, 0, tzinfo=UTC),
|
|
running_status=JobExecutionStatus.RUNNING,
|
|
)
|
|
pending_execution = JobExecution.create(
|
|
job=job,
|
|
running_status=JobExecutionStatus.PENDING,
|
|
)
|
|
|
|
response = await client.post(
|
|
f"/actions/queued-executions/{int(pending_execution.get_id())}/cancel"
|
|
)
|
|
|
|
assert response.status_code == 204
|
|
assert JobExecution.get_or_none(id=int(pending_execution.get_id())) is None
|
|
assert (
|
|
JobExecution.get_by_id(int(running_execution.get_id())).running_status
|
|
== JobExecutionStatus.RUNNING
|
|
)
|
|
|
|
asyncio.run(run())
|
|
|
|
|
|
def test_clear_completed_executions_action_removes_history_and_log_artifacts(
|
|
monkeypatch, tmp_path: Path
|
|
) -> None:
|
|
db_path = tmp_path / "clear-completed-action.db"
|
|
log_dir = tmp_path / "out" / "logs"
|
|
monkeypatch.setenv("REPUBLISHER_DB_PATH", str(db_path))
|
|
|
|
async def run() -> None:
|
|
app = create_app()
|
|
app.config["REPUB_LOG_DIR"] = log_dir
|
|
client = app.test_client()
|
|
|
|
source = create_source(
|
|
name="History source",
|
|
slug="history-source",
|
|
source_type="feed",
|
|
notes="",
|
|
spider_arguments="",
|
|
enabled=True,
|
|
cron_minute="*/5",
|
|
cron_hour="*",
|
|
cron_day_of_month="*",
|
|
cron_day_of_week="*",
|
|
cron_month="*",
|
|
feed_url="https://example.com/history.xml",
|
|
)
|
|
job = Job.get(Job.source == source)
|
|
completed_execution = JobExecution.create(
|
|
job=job,
|
|
running_status=JobExecutionStatus.SUCCEEDED,
|
|
ended_at=datetime(2026, 3, 30, 12, 0, tzinfo=UTC),
|
|
)
|
|
running_execution = JobExecution.create(
|
|
job=job,
|
|
running_status=JobExecutionStatus.RUNNING,
|
|
started_at=datetime(2026, 3, 30, 12, 5, tzinfo=UTC),
|
|
)
|
|
log_dir.mkdir(parents=True, exist_ok=True)
|
|
completed_prefix = (
|
|
log_dir / f"job-{job.id}-execution-{int(completed_execution.get_id())}"
|
|
)
|
|
running_log_path = (
|
|
log_dir / f"job-{job.id}-execution-{int(running_execution.get_id())}.log"
|
|
)
|
|
for suffix in (".log", ".jsonl", ".pygea.log"):
|
|
completed_prefix.with_suffix(suffix).write_text("history", encoding="utf-8")
|
|
running_log_path.write_text("running", encoding="utf-8")
|
|
|
|
response = await client.post("/actions/completed-executions/clear")
|
|
|
|
assert response.status_code == 204
|
|
assert JobExecution.get_or_none(id=int(completed_execution.get_id())) is None
|
|
assert JobExecution.get_or_none(id=int(running_execution.get_id())) is not None
|
|
for suffix in (".log", ".jsonl", ".pygea.log"):
|
|
assert not completed_prefix.with_suffix(suffix).exists()
|
|
assert running_log_path.exists()
|
|
|
|
asyncio.run(run())
|
|
|
|
|
|
def test_move_queued_execution_action_reorders_queue(
|
|
monkeypatch, tmp_path: Path
|
|
) -> None:
|
|
db_path = tmp_path / "move-queued-action.db"
|
|
log_dir = tmp_path / "out" / "logs"
|
|
monkeypatch.setenv("REPUBLISHER_DB_PATH", str(db_path))
|
|
|
|
async def run() -> None:
|
|
app = create_app()
|
|
app.config["REPUB_LOG_DIR"] = log_dir
|
|
client = app.test_client()
|
|
|
|
first_source = create_source(
|
|
name="First queued source",
|
|
slug="first-queued-source",
|
|
source_type="feed",
|
|
notes="",
|
|
spider_arguments="",
|
|
enabled=True,
|
|
cron_minute="*/5",
|
|
cron_hour="*",
|
|
cron_day_of_month="*",
|
|
cron_day_of_week="*",
|
|
cron_month="*",
|
|
feed_url="https://example.com/first.xml",
|
|
)
|
|
second_source = create_source(
|
|
name="Second queued source",
|
|
slug="second-queued-source",
|
|
source_type="feed",
|
|
notes="",
|
|
spider_arguments="",
|
|
enabled=True,
|
|
cron_minute="*/5",
|
|
cron_hour="*",
|
|
cron_day_of_month="*",
|
|
cron_day_of_week="*",
|
|
cron_month="*",
|
|
feed_url="https://example.com/second.xml",
|
|
)
|
|
first_job = Job.get(Job.source == first_source)
|
|
second_job = Job.get(Job.source == second_source)
|
|
first_execution = JobExecution.create(
|
|
job=first_job,
|
|
created_at=datetime(2026, 3, 30, 12, 0, tzinfo=UTC),
|
|
running_status=JobExecutionStatus.PENDING,
|
|
)
|
|
second_execution = JobExecution.create(
|
|
job=second_job,
|
|
created_at=datetime(2026, 3, 30, 12, 5, tzinfo=UTC),
|
|
running_status=JobExecutionStatus.PENDING,
|
|
)
|
|
|
|
response = await client.post(
|
|
f"/actions/queued-executions/{int(second_execution.get_id())}/move-up"
|
|
)
|
|
|
|
assert response.status_code == 204
|
|
body = str(await render_runs(app))
|
|
assert body.index("second-queued-source") < body.index("first-queued-source")
|
|
assert (
|
|
f"/actions/queued-executions/{int(second_execution.get_id())}/move-down"
|
|
in body
|
|
)
|
|
assert (
|
|
f"/actions/queued-executions/{int(first_execution.get_id())}/move-up"
|
|
in body
|
|
)
|
|
|
|
asyncio.run(run())
|
|
|
|
|
|
def test_toggle_job_enabled_action_removes_queued_execution(
|
|
monkeypatch, tmp_path: Path
|
|
) -> None:
|
|
db_path = tmp_path / "toggle-removes-queue.db"
|
|
monkeypatch.setenv("REPUBLISHER_DB_PATH", str(db_path))
|
|
|
|
async def run() -> None:
|
|
app = create_app()
|
|
client = app.test_client()
|
|
|
|
source = create_source(
|
|
name="Queued source",
|
|
slug="queued-source",
|
|
source_type="feed",
|
|
notes="",
|
|
spider_arguments="",
|
|
enabled=True,
|
|
cron_minute="*/5",
|
|
cron_hour="*",
|
|
cron_day_of_month="*",
|
|
cron_day_of_week="*",
|
|
cron_month="*",
|
|
feed_url="https://example.com/queued.xml",
|
|
)
|
|
job = Job.get(Job.source == source)
|
|
queued_execution = JobExecution.create(
|
|
job=job,
|
|
running_status=JobExecutionStatus.PENDING,
|
|
)
|
|
|
|
response = await client.post(f"/actions/jobs/{job.id}/toggle-enabled")
|
|
|
|
assert response.status_code == 204
|
|
assert Job.get_by_id(job.id).enabled is False
|
|
assert JobExecution.get_or_none(id=int(queued_execution.get_id())) is None
|
|
body = str(await render_runs(app))
|
|
assert (
|
|
f"/actions/queued-executions/{int(queued_execution.get_id())}/cancel"
|
|
not in body
|
|
)
|
|
assert "Disabled" in body
|
|
|
|
asyncio.run(run())
|
|
|
|
|
|
def test_render_create_source_uses_shared_submit_button(
|
|
monkeypatch, tmp_path: Path
|
|
) -> None:
|
|
db_path = tmp_path / "create-source-shared-submit.db"
|
|
monkeypatch.setenv("REPUBLISHER_DB_PATH", str(db_path))
|
|
|
|
async def run() -> None:
|
|
app = create_app()
|
|
body = str(await render_create_source(app))
|
|
|
|
assert 'type="submit"' in body
|
|
assert "Create source" in body
|
|
assert "cursor-pointer" in body
|
|
assert "bg-slate-950" in body
|
|
|
|
asyncio.run(run())
|
|
|
|
|
|
def test_render_execution_logs_uses_app_route(monkeypatch, tmp_path: Path) -> None:
|
|
db_path = tmp_path / "logs-render.db"
|
|
monkeypatch.setenv("REPUBLISHER_DB_PATH", str(db_path))
|
|
|
|
async def run() -> None:
|
|
log_dir = tmp_path / "out" / "logs"
|
|
app = create_app()
|
|
app.config["REPUB_LOG_DIR"] = log_dir
|
|
|
|
source = create_source(
|
|
name="Log render source",
|
|
slug="log-render-source",
|
|
source_type="feed",
|
|
notes="",
|
|
spider_arguments="",
|
|
enabled=False,
|
|
cron_minute="*/30",
|
|
cron_hour="*",
|
|
cron_day_of_month="*",
|
|
cron_day_of_week="*",
|
|
cron_month="*",
|
|
feed_url="https://example.com/logs.xml",
|
|
)
|
|
job = Job.get(Job.source == source)
|
|
execution = JobExecution.create(
|
|
job=job,
|
|
running_status=JobExecutionStatus.RUNNING,
|
|
)
|
|
log_path = log_dir / f"job-{job.id}-execution-{execution.get_id()}.log"
|
|
log_path.parent.mkdir(parents=True, exist_ok=True)
|
|
log_path.write_text(
|
|
"\n".join(
|
|
(
|
|
"scheduler: run_now requested",
|
|
"worker: starting simulated crawl",
|
|
"worker: waiting for more log lines ...",
|
|
)
|
|
),
|
|
encoding="utf-8",
|
|
)
|
|
|
|
body = str(
|
|
await render_execution_logs(
|
|
app, job_id=job.id, execution_id=int(execution.get_id())
|
|
)
|
|
)
|
|
|
|
assert f"Job {job.id} / execution {execution.get_id()}" in body
|
|
assert f"/job/{job.id}/execution/{execution.get_id()}/logs" in body
|
|
assert "waiting for more log lines" in body
|
|
|
|
asyncio.run(run())
|