init
This commit is contained in:
121 files changed
+17641
No files matched your search
@@ -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
|
||||
@@ -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"
|
||||
@@ -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}
|
||||
@@ -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"
|
||||
@@ -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 <b>&</b> 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
|
||||
@@ -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
|
||||
@@ -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")
|
||||
@@ -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")
|
||||
Reference in new issue
Block a user