This commit is contained in:
timka123 committed 2026-10-18 18:02:05 +03:00
commit 17a0a3c4c9
121 files changed
+17641

No files matched your search

+64
View File
@@ -0,0 +1,64 @@
"""Test fixtures.
The engine is built at import time from the environment, so the database URL has
to be set before anything from `app` is imported — hence the env juggling here
rather than in a fixture.
"""
from __future__ import annotations
import os
import tempfile
import pytest
os.environ.setdefault("DATABASE_URL", "sqlite:///" + tempfile.mktemp(suffix=".db"))
os.environ["SCHEDULER_ENABLED"] = "false"
os.environ["TELEGRAM_BOT_ENABLED"] = "false"
# Keep the seeded settings empty so tests never reach a real Jira/Todoist.
for name in (
"JIRA_BASE_URL",
"JIRA_EMAIL",
"JIRA_API_TOKEN",
"TODOIST_API_TOKEN",
"GOOGLE_CLIENT_ID",
"GOOGLE_CLIENT_SECRET",
"GOOGLE_REFRESH_TOKEN",
"LAUNDRY_SESSION",
"TELEGRAM_BOT_TOKEN",
"TELEGRAM_CHAT_ID",
"SLACK_WEBHOOK_URL",
):
os.environ.pop(name, None)
from app.db import Base, SessionLocal, engine, init_db # noqa: E402
from app.services import settings_service # noqa: E402
from app.settings_schema import AppSettings # noqa: E402
@pytest.fixture()
def session():
Base.metadata.drop_all(bind=engine)
init_db()
db = SessionLocal()
try:
yield db
finally:
db.close()
@pytest.fixture()
def settings(session) -> AppSettings:
return settings_service.load_settings(session)
@pytest.fixture()
def client():
from fastapi.testclient import TestClient
from app.main import app
Base.metadata.drop_all(bind=engine)
init_db()
with TestClient(app) as test_client:
yield test_client
+99
View File
@@ -0,0 +1,99 @@
"""The HTTP surface, driven the way the clients drive it."""
from __future__ import annotations
def test_health_and_info(client):
assert client.get("/api/health").json()["status"] == "ok"
assert "auth_required" in client.get("/api/info").json()
def test_a_whole_pomodoro_through_the_api(client):
client.put("/api/settings", json={"pomodoro": {"daily_goal_pomodoros": 1}})
task = client.post("/api/tasks", json={"title": "Write the API"}).json()
state = client.post("/api/timer/start", json={"kind": "work", "task_id": task["id"]}).json()
assert state["current"]["kind"] == "work"
assert state["current"]["task"]["title"] == "Write the API"
assert client.post("/api/timer/pause").json()["current"]["status"] == "paused"
assert client.post("/api/timer/resume").json()["current"]["status"] == "running"
assert client.post("/api/timer/extend", json={"minutes": 5}).json()["current"]["planned_seconds"] > 0
done = client.post("/api/timer/complete", json={"auto_next": False}).json()
assert done["finished"]["status"] == "completed"
assert done["completed_today"] == 1
marked = client.post(f"/api/tasks/{task['id']}/done", json={"done": True}).json()
assert marked["task"]["is_done"] is True
report = client.get("/api/reports/daily").json()
assert report["report"]["metrics"]["pomodoros"] == 1
assert "Daily report" in report["telegram_preview"]
def test_timer_errors_are_400_not_500(client):
assert client.post("/api/timer/pause").status_code == 400
assert client.post("/api/timer/complete", json={}).status_code == 400
def test_task_crud(client):
created = client.post("/api/tasks", json={"title": "Temporary", "priority": "p2"}).json()
listing = client.get("/api/tasks").json()
assert any(task["id"] == created["id"] for task in listing["tasks"])
patched = client.patch(f"/api/tasks/{created['id']}", json={"pinned": True}).json()
assert patched["pinned"] is True
client.delete(f"/api/tasks/{created['id']}")
assert all(task["id"] != created["id"] for task in client.get("/api/tasks").json()["tasks"])
def test_refresh_reports_per_source_failures_without_failing(client):
client.put(
"/api/settings",
json={"sources": {"todoist": {"enabled": True, "api_token": "definitely-invalid"}}},
)
result = client.post("/api/tasks/refresh").json()
assert result["sources"], "the enabled source should be reported"
assert result["sources"][0]["ok"] is False
assert client.get("/api/tasks").status_code == 200
def test_blocker_policy_and_events(client):
client.put(
"/api/settings",
json={"blocker": {"enabled": True, "blocked_domains": ["youtube.com"]}},
)
assert client.get("/api/blocker/policy").json()["active"] is False
client.post("/api/timer/start", json={"kind": "work"})
policy = client.get("/api/blocker/policy").json()
assert policy["active"] is True
assert "youtube.com" in policy["domains"]
client.post("/api/blocker/events", json={"kind": "domain", "target": "youtube.com"})
events = client.get("/api/blocker/events").json()
assert events["total"] == 1
def test_settings_round_trip_masks_secrets(client):
client.put("/api/settings", json={"sources": {"todoist": {"api_token": "s3cret"}}})
body = client.get("/api/settings").json()
assert body["settings"]["sources"]["todoist"]["api_token"] == "••••••••"
assert body["defaults"]["blocked_domains"]
# Posting the mask back must not wipe the token.
client.put("/api/settings", json=body["settings"])
again = client.get("/api/settings").json()
assert again["settings"]["sources"]["todoist"]["api_token"] == "••••••••"
def test_invalid_settings_are_rejected(client):
assert client.put("/api/settings", json={"pomodoro": {"work_minutes": 0}}).status_code == 422
assert client.put("/api/settings", json={"timezone": "Mars/Olympus"}).status_code == 422
def test_notification_test_endpoint_falls_back_to_the_log(client):
body = client.post("/api/notifications/test", json={}).json()
assert body["deliveries"][0]["channel"] == "log"
+62
View File
@@ -0,0 +1,62 @@
"""Blocking policy: when it is on, and what is on the list."""
from __future__ import annotations
from app.services import blocker_service, pomodoro, settings_service
def test_disabled_blocker_blocks_nothing(session, settings):
pomodoro.start(session, settings, kind="work")
policy = blocker_service.policy(session, settings)
assert policy["active"] is False and policy["domains"] == []
def test_during_work_mode_follows_the_timer(session, settings):
settings.blocker.enabled = True
settings.blocker.blocked_domains = ["youtube.com"]
settings_service.save_settings(session, settings)
assert blocker_service.policy(session, settings)["active"] is False
pomodoro.start(session, settings, kind="work")
policy = blocker_service.policy(session, settings)
assert policy["active"] is True
assert "www.youtube.com" in policy["domains"]
pomodoro.complete(session, settings, auto_next=False)
assert blocker_service.policy(session, settings)["active"] is False
def test_breaks_do_not_block_unless_asked(session, settings):
settings.blocker.enabled = True
settings_service.save_settings(session, settings)
pomodoro.start(session, settings, kind="short_break")
assert blocker_service.policy(session, settings)["active"] is False
settings.blocker.block_during_breaks = True
settings_service.save_settings(session, settings)
assert blocker_service.policy(session, settings)["active"] is True
def test_always_mode_ignores_the_timer(session, settings):
settings.blocker.enabled = True
settings.blocker.mode = "always"
settings_service.save_settings(session, settings)
assert blocker_service.policy(session, settings)["active"] is True
def test_allowlist_wins_over_the_blocklist(session, settings):
settings.blocker.blocked_domains = ["youtube.com"]
settings.blocker.allowed_domains = ["m.youtube.com"]
hosts = blocker_service.expand_domains(settings)
assert "youtube.com" in hosts
assert "m.youtube.com" not in hosts
def test_events_are_grouped_for_the_report(session, settings):
for _ in range(3):
blocker_service.record_event(session, settings, kind="domain", target="YouTube.com")
blocker_service.record_event(session, settings, kind="app", target="steam")
summary = blocker_service.events_for_day(session, settings)
assert summary["total"] == 4
assert summary["by_target"][0] == {"target": "youtube.com", "count": 3}
+93
View File
@@ -0,0 +1,93 @@
"""The timer's state machine."""
from __future__ import annotations
import pytest
from app.models import PomodoroSession
from app.services import pomodoro, settings_service
def test_start_sets_duration_from_settings(session, settings):
row = pomodoro.start(session, settings, kind="work")
assert row.planned_seconds == settings.pomodoro.work_minutes * 60
assert row.status == "running"
assert pomodoro.remaining_seconds(row) <= row.planned_seconds
def test_minutes_override_wins(session, settings):
row = pomodoro.start(session, settings, kind="work", minutes=3)
assert row.planned_seconds == 180
def test_starting_again_abandons_the_previous_interval(session, settings):
first = pomodoro.start(session, settings, kind="work")
pomodoro.start(session, settings, kind="work")
session.refresh(first)
assert first.status == "abandoned"
assert len(session.query(PomodoroSession).all()) == 2
def test_pause_counts_an_interruption_and_freezes_the_clock(session, settings):
pomodoro.start(session, settings, kind="work")
row = pomodoro.pause(session, settings)
assert row.status == "paused"
assert row.interruptions == 1
frozen = pomodoro.elapsed_seconds(row)
assert pomodoro.elapsed_seconds(row) == frozen
def test_pause_can_be_switched_off(session, settings):
settings.pomodoro.allow_pause = False
settings_service.save_settings(session, settings)
pomodoro.start(session, settings, kind="work")
with pytest.raises(pomodoro.TimerError):
pomodoro.pause(session, settings)
def test_long_break_arrives_on_the_configured_cycle(session, settings):
settings.pomodoro.cycles_before_long_break = 2
settings.pomodoro.auto_start_breaks = False
settings_service.save_settings(session, settings)
pomodoro.start(session, settings, kind="work")
pomodoro.complete(session, settings, auto_next=False)
assert pomodoro.next_kind(session, settings) == "short_break"
pomodoro.start(session, settings, kind="short_break")
pomodoro.complete(session, settings, auto_next=False)
pomodoro.start(session, settings, kind="work")
pomodoro.complete(session, settings, auto_next=False)
assert pomodoro.next_kind(session, settings) == "long_break"
def test_auto_start_breaks_rolls_straight_on(session, settings):
settings.pomodoro.auto_start_breaks = True
settings_service.save_settings(session, settings)
pomodoro.start(session, settings, kind="work")
finished, follow_on = pomodoro.complete(session, settings)
assert finished.status == "completed"
assert follow_on is not None and follow_on.kind == "short_break"
def test_strict_mode_demands_a_reason(session, settings):
settings.pomodoro.strict_mode = True
settings_service.save_settings(session, settings)
pomodoro.start(session, settings, kind="work")
with pytest.raises(pomodoro.TimerError):
pomodoro.abandon(session, settings)
row = pomodoro.abandon(session, settings, reason="fire alarm")
assert row.status == "abandoned" and row.abandon_reason == "fire alarm"
def test_reap_closes_intervals_left_running(session, settings):
from datetime import timedelta
from app.models import utcnow
row = pomodoro.start(session, settings, kind="work")
row.started_at = utcnow() - timedelta(hours=9)
session.commit()
assert pomodoro.reap_stale(session, settings) == 1
session.refresh(row)
assert row.status == "abandoned"
+99
View File
@@ -0,0 +1,99 @@
"""Metrics, achievements and the rendered report."""
from __future__ import annotations
from datetime import timedelta
from app.models import utcnow
from app.services import pomodoro, report, settings_service, stats, tasks_service
from app.timeutil import local_date
def _run_pomodoros(session, settings, count: int, *, minutes: int = 25) -> None:
"""Record `count` finished work intervals, backdated so they do not overlap."""
for index in range(count):
row = pomodoro.start(session, settings, kind="work", minutes=minutes)
start = utcnow() - timedelta(minutes=(count - index) * (minutes + 5))
row.started_at = start
row.ended_at = start + timedelta(minutes=minutes)
row.actual_seconds = minutes * 60
row.status = "completed"
session.commit()
def test_day_metrics_counts_what_happened(session, settings):
_run_pomodoros(session, settings, 3)
metrics = stats.day_metrics(session, settings, local_date(settings.timezone))
assert metrics["pomodoros"] == 3
assert metrics["focus_minutes"] == 75
assert metrics["goal"] == settings.pomodoro.daily_goal_pomodoros
assert metrics["first_start"] and metrics["last_end"]
def test_longest_stretch_joins_adjacent_pomodoros(session, settings):
_run_pomodoros(session, settings, 3, minutes=25)
metrics = stats.day_metrics(session, settings, local_date(settings.timezone))
# 25-minute intervals five minutes apart are one unbroken stretch.
assert metrics["longest_stretch_minutes"] >= 75
def test_streak_survives_an_empty_today(session, settings):
today = local_date(settings.timezone)
_run_pomodoros(session, settings, 1)
# Move the work to yesterday and leave today empty.
from app.models import PomodoroSession
for row in session.query(PomodoroSession).all():
row.local_date = today - timedelta(days=1)
session.commit()
streak = stats.streak(session, settings, until=today)
assert streak["current"] == 1
def test_report_payload_has_the_pieces_the_ui_draws(session, settings):
settings.pomodoro.daily_goal_pomodoros = 2
settings_service.save_settings(session, settings)
_run_pomodoros(session, settings, 2)
task = tasks_service.create_local_task(session, title="Ship the thing")
tasks_service.complete_task(session, settings, task.id)
payload = report.build_daily(session, settings, local_date(settings.timezone))
assert payload["metrics"]["pomodoros"] == 2
assert payload["metrics"]["tasks_completed_count"] == 1
assert any(item["code"] == "goal_met" for item in payload["achievements"])
assert payload["fun_facts"]
assert 0 <= payload["score"] <= 100
def test_rendered_report_escapes_borrowed_text(session, settings):
task = tasks_service.create_local_task(session, title="Fix <b>&</b> ship")
tasks_service.complete_task(session, settings, task.id)
payload = report.build_daily(session, settings, local_date(settings.timezone))
text = report.render_daily_telegram(payload, settings)
assert "Fix &lt;b&gt;&amp;&lt;/b&gt; ship" in text
def test_russian_report_renders(session, settings):
settings.notifications.report.language = "ru"
settings.notifications.report.tone = "drill_sergeant"
settings_service.save_settings(session, settings)
_run_pomodoros(session, settings, 1)
payload = report.build_daily(session, settings, local_date(settings.timezone))
text = report.render_daily_telegram(payload, settings)
assert "Отчёт за день" in text
def test_weekly_review_covers_seven_days(session, settings):
_run_pomodoros(session, settings, 2)
payload = report.build_weekly(session, settings, local_date(settings.timezone))
assert len(payload["days"]) == 7
assert payload["pomodoros"] == 2
assert "Weekly review" in report.render_weekly_telegram(payload, settings)
def test_progress_bar_is_proportional():
assert report.progress_bar(0, 8) == "░" * 10
assert report.progress_bar(8, 8) == "█" * 10
assert report.progress_bar(4, 8).count("█") == 5
+72
View File
@@ -0,0 +1,72 @@
"""Settings: deep merge, secret masking, and the environment seed."""
from __future__ import annotations
from app.services import settings_service
from app.settings_schema import SECRET_MASK
def test_patch_merges_without_clobbering_siblings(session):
settings_service.apply_patch(session, {"pomodoro": {"work_minutes": 50}})
updated = settings_service.apply_patch(
session, {"pomodoro": {"daily_goal_pomodoros": 12}}
)
assert updated.pomodoro.work_minutes == 50
assert updated.pomodoro.daily_goal_pomodoros == 12
assert updated.pomodoro.short_break_minutes == 5 # untouched default
def test_secrets_are_masked_on_the_way_out(session):
settings_service.apply_patch(
session,
{"sources": {"todoist": {"enabled": True, "api_token": "super-secret"}}},
)
masked = settings_service.mask_secrets(
settings_service.load_settings(session).model_dump(mode="json")
)
assert masked["sources"]["todoist"]["api_token"] == SECRET_MASK
def test_sending_the_mask_back_keeps_the_stored_secret(session):
settings_service.apply_patch(
session, {"sources": {"todoist": {"api_token": "super-secret"}}}
)
settings_service.apply_patch(
session,
{"sources": {"todoist": {"api_token": SECRET_MASK, "enabled": True}}},
)
settings = settings_service.load_settings(session)
assert settings.sources.todoist.api_token == "super-secret"
assert settings.sources.todoist.enabled is True
def test_a_real_new_secret_replaces_the_old_one(session):
settings_service.apply_patch(session, {"sources": {"todoist": {"api_token": "old"}}})
settings_service.apply_patch(session, {"sources": {"todoist": {"api_token": "new"}}})
assert settings_service.load_settings(session).sources.todoist.api_token == "new"
def test_environment_seeds_the_sources(monkeypatch):
monkeypatch.setenv("JIRA_BASE_URL", "https://example.atlassian.net/")
monkeypatch.setenv("JIRA_EMAIL", "me@example.com")
monkeypatch.setenv("JIRA_API_TOKEN", "token")
monkeypatch.setenv("TODOIST_API_TOKEN", "todoist-token")
monkeypatch.setenv("TODOIST_PROJECTS", "Inbox, Учёба")
from app.config import env_settings
env_settings.cache_clear()
try:
settings = settings_service.settings_from_env()
finally:
env_settings.cache_clear()
assert settings.sources.jira.enabled
assert settings.sources.jira.base_url == "https://example.atlassian.net"
assert settings.sources.todoist.projects == ["Inbox", "Учёба"]
def test_listeners_fire_on_save(session):
seen = []
settings_service.on_change(lambda new: seen.append(new.pomodoro.work_minutes))
settings_service.apply_patch(session, {"pomodoro": {"work_minutes": 33}})
assert 33 in seen
+100
View File
@@ -0,0 +1,100 @@
"""Source mapping: the parts that work without a network."""
from __future__ import annotations
from datetime import date
from app.settings_schema import JiraSourceSettings, LaundrySourceSettings
from app.sources.jira import JiraSource, start_date_clause
from app.sources.laundry import parse_cookie_header, parse_my_slots
JIRA_CONFIG = JiraSourceSettings(
enabled=True,
base_url="https://example.atlassian.net",
email="me@example.com",
api_token="token",
)
def test_start_date_clause_handles_custom_fields_and_names():
assert start_date_clause("customfield_10015") == "cf[10015]"
assert start_date_clause("Start date") == '"Start date"'
def test_jira_jql_includes_the_extra_filter():
source = JiraSource(JIRA_CONFIG.model_copy(update={"extra_jql": "assignee = currentUser()"}))
jql = source._jql()
assert "statusCategory != Done" in jql
assert "assignee = currentUser()" in jql
source.close()
def test_jira_issue_maps_to_a_todo_item():
source = JiraSource(JIRA_CONFIG)
item = source._to_item(
{
"key": "ABC-1",
"fields": {
"summary": "Fix the thing",
"duedate": "2026-10-20",
"customfield_10015": "2026-10-15",
"status": {"name": "In Progress", "statusCategory": {"key": "indeterminate"}},
"priority": {"name": "High"},
"project": {"name": "Platform"},
"labels": ["backend"],
},
}
)
source.close()
assert item.external_id == "ABC-1"
assert item.title == "Fix the thing"
assert item.url == "https://example.atlassian.net/browse/ABC-1"
assert item.due_date == date(2026, 10, 20)
assert item.start_date == date(2026, 10, 15)
assert item.priority == "High"
assert item.is_done is False
assert item.days_overdue(date(2026, 10, 22)) == 2
def test_cookie_header_accepts_both_forms():
assert parse_cookie_header("abc123") == {"sessionid": "abc123"}
parsed = parse_cookie_header("csrftoken=x; sessionid=abc123")
assert parsed["sessionid"] == "abc123"
LAUNDRY_HTML = """
<div class="date-machines" id="Чт, 15 окт.">
<div class="machines">
<div class="floor-title">Этаж 1</div>
<div class="machine">
<div class="machine-title">Машинка 3</div>
<div class="period my finished"><div>09:00-12:00</div></div>
<div class="period my current"><div>18:00-21:00</div></div>
<div class="period"><div>21:00-00:00</div></div>
</div>
</div>
</div>
"""
def test_laundry_grid_yields_only_my_upcoming_slots():
label, slots = parse_my_slots(LAUNDRY_HTML)
assert label == "Чт, 15 окт."
assert len(slots) == 2
upcoming = [slot for slot in slots if slot.is_upcoming]
assert len(upcoming) == 1
assert upcoming[0].time_range == "18:00-21:00"
assert upcoming[0].machine == "Машинка 3"
assert upcoming[0].location == "Этаж 1"
def test_laundry_config_requires_a_session_cookie():
from app.sources.base import SourceError
try:
from app.sources.laundry import LaundrySource
LaundrySource(LaundrySourceSettings(enabled=True, session_cookie=""))
except SourceError:
return
raise AssertionError("expected a SourceError")
+71
View File
@@ -0,0 +1,71 @@
"""The Telegram commands — the chat is a remote control, so it needs testing."""
from __future__ import annotations
from app.services import settings_service, tasks_service, telegram_bot
def test_unknown_input_is_ignored(session):
assert telegram_bot.handle_text("hello there") is None
assert telegram_bot.handle_text("/nonsense") is None
def test_help_lists_the_verbs(session):
reply = telegram_bot.handle_text("/help")
assert "/start_work" in reply and "/report" in reply
def test_start_pause_resume_and_status(session):
assert "started" in telegram_bot.handle_text("/start_work 10").lower()
status = telegram_bot.handle_text("/status")
assert "work" in status.lower() or "focus" in status.lower()
assert "Paused" in telegram_bot.handle_text("/pause")
assert "Resumed" in telegram_bot.handle_text("/resume")
assert "Stopped" in telegram_bot.handle_text("/stop")
def test_stop_without_a_timer_explains_itself(session):
assert "No interval is running" in telegram_bot.handle_text("/stop")
def test_today_numbers_tasks_and_done_closes_them(session):
settings_service.load_settings(session)
first = tasks_service.create_local_task(session, title="Alpha task")
tasks_service.create_local_task(session, title="Beta task")
listing = telegram_bot.handle_text("/today")
assert "Alpha task" in listing and "<b>1.</b>" in listing
reply = telegram_bot.handle_text("/done 1")
assert "Alpha task" in reply
session.expire_all()
assert session.get(type(first), first.id).is_done is True
assert "No task with that number" in telegram_bot.handle_text("/done 99")
def test_done_without_a_listing_asks_for_one(session):
telegram_bot._INDEX.clear()
assert "/today" in telegram_bot.handle_text("/done 1")
def test_report_and_streak_render(session):
assert "Daily report" in telegram_bot.handle_text("/report")
assert "Streak" in telegram_bot.handle_text("/streak")
assert "Weekly review" in telegram_bot.handle_text("/week")
def test_block_command_describes_the_policy(session):
settings = settings_service.load_settings(session)
settings.blocker.enabled = True
settings.blocker.blocked_domains = ["youtube.com"]
settings_service.save_settings(session, settings)
reply = telegram_bot.handle_text("/block")
assert "Blocker" in reply and "Domains" in reply
def test_russian_replies_when_configured(session):
settings = settings_service.load_settings(session)
settings.notifications.report.language = "ru"
settings_service.save_settings(session, settings)
assert "помидорку" in telegram_bot.handle_text("/help")