intervall
This commit is contained in:
@@ -9,7 +9,13 @@ from sqlalchemy.pool import StaticPool
|
||||
|
||||
from app.config import Settings
|
||||
from app.db.models import Activity, Base, HealthState
|
||||
from app.db.repositories import ActivityRepository, SyncRunRepository, SystemLogRepository, UserRepository
|
||||
from app.db.repositories import (
|
||||
ActivityRepository,
|
||||
SchedulerSettingsRepository,
|
||||
SyncRunRepository,
|
||||
SystemLogRepository,
|
||||
UserRepository,
|
||||
)
|
||||
from app.main import create_app
|
||||
|
||||
|
||||
@@ -44,6 +50,11 @@ def sync_run_repository(db_session: Session) -> SyncRunRepository:
|
||||
return SyncRunRepository(db_session)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def scheduler_settings_repository(db_session: Session) -> SchedulerSettingsRepository:
|
||||
return SchedulerSettingsRepository(db_session)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def system_log_repository(db_session: Session) -> SystemLogRepository:
|
||||
return SystemLogRepository(db_session)
|
||||
|
||||
@@ -93,3 +93,39 @@ def test_system_log_respects_limit(system_log_repository) -> None:
|
||||
entries = system_log_repository.list_recent(limit=2)
|
||||
|
||||
assert len(entries) == 2
|
||||
|
||||
|
||||
def test_scheduler_settings_get_or_create_seeds_defaults(scheduler_settings_repository) -> None:
|
||||
row = scheduler_settings_repository.get_or_create(default_minutes=7)
|
||||
|
||||
assert row.day_interval_minutes == 7
|
||||
assert row.night_interval_minutes == 7
|
||||
assert row.day_start_hour == 6
|
||||
assert row.night_start_hour == 22
|
||||
|
||||
|
||||
def test_scheduler_settings_get_or_create_is_idempotent_after_update(scheduler_settings_repository) -> None:
|
||||
row = scheduler_settings_repository.get_or_create(default_minutes=5)
|
||||
scheduler_settings_repository.update(row, day_interval_minutes=15)
|
||||
|
||||
reloaded = scheduler_settings_repository.get_or_create(default_minutes=5)
|
||||
|
||||
assert reloaded.day_interval_minutes == 15
|
||||
|
||||
|
||||
def test_scheduler_settings_update_persists_all_fields(scheduler_settings_repository) -> None:
|
||||
row = scheduler_settings_repository.get_or_create(default_minutes=5)
|
||||
|
||||
scheduler_settings_repository.update(
|
||||
row,
|
||||
day_start_hour=8,
|
||||
night_start_hour=20,
|
||||
day_interval_minutes=10,
|
||||
night_interval_minutes=45,
|
||||
)
|
||||
|
||||
reloaded = scheduler_settings_repository.get_or_create(default_minutes=5)
|
||||
assert reloaded.day_start_hour == 8
|
||||
assert reloaded.night_start_hour == 20
|
||||
assert reloaded.day_interval_minutes == 10
|
||||
assert reloaded.night_interval_minutes == 45
|
||||
|
||||
@@ -1,8 +1,14 @@
|
||||
import asyncio
|
||||
from datetime import datetime
|
||||
|
||||
import pytest
|
||||
from sqlalchemy import create_engine
|
||||
from sqlalchemy.pool import StaticPool
|
||||
|
||||
from app.sync.scheduler import SyncScheduler
|
||||
from app.db.models import Base
|
||||
from app.db.repositories import SchedulerSettingsRepository
|
||||
from app.db.session import create_session_factory
|
||||
from app.sync.scheduler import DayNightIntervalProvider, SyncScheduler, is_daytime
|
||||
|
||||
|
||||
class FakeSyncManager:
|
||||
@@ -40,3 +46,91 @@ async def test_scheduler_stop_cancels_the_loop() -> None:
|
||||
calls_after_stop = fake.calls
|
||||
await asyncio.sleep(0.05)
|
||||
assert fake.calls == calls_after_stop
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("hour", "day_start", "night_start", "expected"),
|
||||
[
|
||||
(6, 6, 22, True),
|
||||
(21, 6, 22, True),
|
||||
(5, 6, 22, False),
|
||||
(22, 6, 22, False),
|
||||
(0, 6, 22, False),
|
||||
# wraps past midnight: day period is [20, 6)
|
||||
(23, 20, 6, True),
|
||||
(2, 20, 6, True),
|
||||
(10, 20, 6, False),
|
||||
(20, 20, 6, True),
|
||||
(6, 20, 6, False),
|
||||
# degenerate: identical start hours means always day
|
||||
(13, 9, 9, True),
|
||||
],
|
||||
)
|
||||
def test_is_daytime(hour, day_start, night_start, expected) -> None:
|
||||
assert is_daytime(hour, day_start, night_start) is expected
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def db_session_factory():
|
||||
engine = create_engine("sqlite://", connect_args={"check_same_thread": False}, poolclass=StaticPool)
|
||||
Base.metadata.create_all(engine)
|
||||
factory = create_session_factory(engine)
|
||||
try:
|
||||
yield factory
|
||||
finally:
|
||||
engine.dispose()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_interval_provider_uses_day_interval_during_the_day(db_session_factory) -> None:
|
||||
provider = DayNightIntervalProvider(
|
||||
db_session_factory, default_minutes=5, now=lambda: datetime(2026, 1, 1, 12, 0)
|
||||
)
|
||||
assert provider() == 5 * 60
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_interval_provider_uses_night_interval_at_night(db_session_factory) -> None:
|
||||
with db_session_factory() as session:
|
||||
repo = SchedulerSettingsRepository(session)
|
||||
row = repo.get_or_create(default_minutes=5)
|
||||
repo.update(row, night_interval_minutes=30)
|
||||
|
||||
provider = DayNightIntervalProvider(
|
||||
db_session_factory, default_minutes=5, now=lambda: datetime(2026, 1, 1, 23, 0)
|
||||
)
|
||||
assert provider() == 30 * 60
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_scheduler_uses_interval_provider_for_next_tick(db_session_factory) -> None:
|
||||
with db_session_factory() as session:
|
||||
repo = SchedulerSettingsRepository(session)
|
||||
row = repo.get_or_create(default_minutes=5)
|
||||
repo.update(row, day_interval_minutes=1, night_interval_minutes=1)
|
||||
|
||||
provider = DayNightIntervalProvider(
|
||||
db_session_factory, default_minutes=5, now=lambda: datetime(2026, 1, 1, 12, 0)
|
||||
)
|
||||
fake = FakeSyncManager()
|
||||
scheduler = SyncScheduler(fake, interval_seconds=999, interval_provider=provider)
|
||||
|
||||
await scheduler.run_once()
|
||||
|
||||
expected_seconds = 60 # day_interval_minutes=1 -> 60s, not the unrelated interval_seconds=999 fallback
|
||||
delta = (scheduler.next_tick - scheduler.last_tick).total_seconds()
|
||||
assert abs(delta - expected_seconds) < 1
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_scheduler_falls_back_when_interval_provider_raises() -> None:
|
||||
def broken_provider():
|
||||
raise RuntimeError("db unavailable")
|
||||
|
||||
fake = FakeSyncManager()
|
||||
scheduler = SyncScheduler(fake, interval_seconds=42, interval_provider=broken_provider)
|
||||
|
||||
await scheduler.run_once()
|
||||
|
||||
delta = (scheduler.next_tick - scheduler.last_tick).total_seconds()
|
||||
assert abs(delta - 42) < 1
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from app.db.repositories import SystemLogRepository
|
||||
from app.db.repositories import SchedulerSettingsRepository, SystemLogRepository
|
||||
|
||||
|
||||
def test_manual_sync_calls_shared_manager(authenticated_client, fake_sync_manager) -> None:
|
||||
@@ -89,3 +89,115 @@ def test_system_page_shows_recorded_log_entries(app, authenticated_client) -> No
|
||||
assert response.status_code == 200
|
||||
assert "email_notification" in response.text
|
||||
assert "SMTP timeout" in response.text
|
||||
|
||||
|
||||
def _extract_csrf(html: str) -> str:
|
||||
marker = 'name="csrf_token" value="'
|
||||
start = html.index(marker) + len(marker)
|
||||
end = html.index('"', start)
|
||||
return html[start:end]
|
||||
|
||||
|
||||
def test_scheduler_settings_page_prefills_defaults(app, authenticated_client) -> None:
|
||||
app.state.scheduler = _FakeScheduler()
|
||||
|
||||
response = authenticated_client.get("/system")
|
||||
|
||||
assert response.status_code == 200
|
||||
assert 'id="day_start_hour"' in response.text
|
||||
assert 'value="6"' in response.text
|
||||
assert 'value="22"' in response.text
|
||||
|
||||
|
||||
def test_update_scheduler_settings_persists_and_takes_effect_next_tick(app, authenticated_client) -> None:
|
||||
app.state.scheduler = _FakeScheduler()
|
||||
page = authenticated_client.get("/system")
|
||||
csrf = _extract_csrf(page.text)
|
||||
|
||||
response = authenticated_client.post(
|
||||
"/system/scheduler-settings",
|
||||
data={
|
||||
"csrf_token": csrf,
|
||||
"day_start_hour": "8",
|
||||
"night_start_hour": "20",
|
||||
"day_interval_minutes": "3",
|
||||
"night_interval_minutes": "45",
|
||||
},
|
||||
follow_redirects=False,
|
||||
)
|
||||
assert response.status_code == 303
|
||||
assert response.headers["location"] == "/system"
|
||||
|
||||
with app.state.session_factory() as session:
|
||||
row = SchedulerSettingsRepository(session).get_or_create(default_minutes=5)
|
||||
assert row.day_start_hour == 8
|
||||
assert row.night_start_hour == 20
|
||||
assert row.day_interval_minutes == 3
|
||||
assert row.night_interval_minutes == 45
|
||||
|
||||
|
||||
def test_update_scheduler_settings_rejects_out_of_range_hour(app, authenticated_client) -> None:
|
||||
app.state.scheduler = _FakeScheduler()
|
||||
page = authenticated_client.get("/system")
|
||||
csrf = _extract_csrf(page.text)
|
||||
|
||||
response = authenticated_client.post(
|
||||
"/system/scheduler-settings",
|
||||
data={
|
||||
"csrf_token": csrf,
|
||||
"day_start_hour": "24",
|
||||
"night_start_hour": "22",
|
||||
"day_interval_minutes": "5",
|
||||
"night_interval_minutes": "5",
|
||||
},
|
||||
)
|
||||
assert response.status_code == 400
|
||||
|
||||
|
||||
def test_update_scheduler_settings_rejects_non_positive_interval(app, authenticated_client) -> None:
|
||||
app.state.scheduler = _FakeScheduler()
|
||||
page = authenticated_client.get("/system")
|
||||
csrf = _extract_csrf(page.text)
|
||||
|
||||
response = authenticated_client.post(
|
||||
"/system/scheduler-settings",
|
||||
data={
|
||||
"csrf_token": csrf,
|
||||
"day_start_hour": "6",
|
||||
"night_start_hour": "22",
|
||||
"day_interval_minutes": "0",
|
||||
"night_interval_minutes": "5",
|
||||
},
|
||||
)
|
||||
assert response.status_code == 400
|
||||
|
||||
|
||||
def test_update_scheduler_settings_requires_admin(client: TestClient) -> None:
|
||||
response = client.post(
|
||||
"/system/scheduler-settings",
|
||||
data={
|
||||
"csrf_token": "whatever",
|
||||
"day_start_hour": "6",
|
||||
"night_start_hour": "22",
|
||||
"day_interval_minutes": "5",
|
||||
"night_interval_minutes": "5",
|
||||
},
|
||||
follow_redirects=False,
|
||||
)
|
||||
assert response.status_code == 303
|
||||
assert response.headers["location"] == "/login"
|
||||
|
||||
|
||||
def test_update_scheduler_settings_rejects_invalid_csrf(app, authenticated_client) -> None:
|
||||
app.state.scheduler = _FakeScheduler()
|
||||
response = authenticated_client.post(
|
||||
"/system/scheduler-settings",
|
||||
data={
|
||||
"csrf_token": "invalid-token",
|
||||
"day_start_hour": "6",
|
||||
"night_start_hour": "22",
|
||||
"day_interval_minutes": "5",
|
||||
"night_interval_minutes": "5",
|
||||
},
|
||||
)
|
||||
assert response.status_code == 403
|
||||
|
||||
Reference in New Issue
Block a user