"""FlowDeck — v5.8.0 Calendrier & Rappels. Covers: the recurrence engine (pure), migrations 11/12, meta-key validation on the rows API, the server-expanded calendar endpoint, per-user timezones, the reminder scanner (deterministic, injected clock), assignment notifications and the enriched Meeting notes template. """ from __future__ import annotations import datetime as dt import json import os import secrets import tempfile import pytest from fastapi.testclient import TestClient @pytest.fixture def client(): db_file = tempfile.NamedTemporaryFile(suffix=".db", delete=False) db_path = db_file.name db_file.close() os.environ["DATABASE_URL"] = f"sqlite:///{db_path}" os.environ["APP_SECRET_KEY"] = "test-secret-for-tests" os.environ["RATE_LIMIT_ENABLED"] = "false" os.environ["PUBLIC_API_INSECURE_OK"] = "true" from app.config import settings settings.database_url = f"sqlite:///{db_path}" settings.rate_limit_enabled = False settings.public_api_insecure_ok = True from app.db import init_db from app.main import app init_db() yield TestClient(app) try: os.unlink(db_path) except PermissionError: pass def _login(client): from app.auth.session import SessionManager from app.db import get_conn with get_conn() as conn: login = f"v58u_{secrets.token_hex(4)}" conn.execute( "INSERT INTO users (login, full_name, email, password_hash, is_admin) " "VALUES (?, 'V58 User', ?, ?, 1)", (login, f"{login}@test.com", ""), ) uid = conn.execute("SELECT id FROM users WHERE login=?", (login,)).fetchone()["id"] conn.commit() session = SessionManager.create_session({"id": uid, "login": login, "is_admin": 1}) csrf = client.get("/api/csrf-token", cookies={"flowdeck_session": session}).json()["csrf_token"] return session, csrf, uid def _add_collection(client, name="Cal"): return client.post("/db/api", json={"name": name}).json()["id"] def _add_prop(client, cid, name, ptype, **extra): body = {"name": name, "prop_type": ptype} body.update(extra) r = client.post(f"/db/{cid}/properties/api", json=body) assert r.status_code == 200, r.text return r.json()["id"] # ── Recurrence engine (pure) ── def test_expand_rule_daily_weekly_monthly(): from app.services.recurrence import expand_rule w = (dt.date(2026, 9, 1), dt.date(2026, 9, 30)) assert expand_rule("2026-09-05", {"freq": "daily"}, *w)[:2] == ["2026-09-05", "2026-09-06"] assert expand_rule("2026-09-07", {"freq": "weekly"}, *w) == \ ["2026-09-07", "2026-09-14", "2026-09-21", "2026-09-28"] assert expand_rule("2026-09-07", {"freq": "weekly", "interval": 2}, *w) == \ ["2026-09-07", "2026-09-21"] # Monthly anchored on the 31st clamps to shorter months. months = expand_rule("2026-01-31", {"freq": "monthly"}, dt.date(2026, 1, 1), dt.date(2026, 4, 30)) assert months == ["2026-01-31", "2026-02-28", "2026-03-31", "2026-04-30"] def test_expand_rule_count_until_window(): from app.services.recurrence import expand_rule assert expand_rule("2026-09-05", {"freq": "daily", "count": 3}, dt.date(2026, 9, 1), dt.date(2026, 9, 30)) == \ ["2026-09-05", "2026-09-06", "2026-09-07"] assert expand_rule("2026-08-01", {"freq": "daily", "count": 10}, dt.date(2026, 9, 1), dt.date(2026, 9, 30)) == [] assert expand_rule("2026-09-05", {"freq": "daily", "until": "2026-09-08"}, dt.date(2026, 9, 1), dt.date(2026, 9, 30)) == \ ["2026-09-05", "2026-09-06", "2026-09-07", "2026-09-08"] def test_validate_rule_errors(): from app.services.recurrence import validate_rule assert validate_rule({"freq": "yearly"})[0] is False assert validate_rule({"freq": "daily", "count": 2, "until": "2026-01-01"})[0] is False assert validate_rule({"freq": "weekly", "byweekday": [9]})[0] is False assert validate_rule({"freq": "daily", "timezone": "Mars/Olympus"})[0] is False assert validate_rule({"freq": "weekly", "byweekday": [0, 4], "timezone": "Europe/Paris"})[0] is True assert validate_rule(None)[0] is True # ── Schema / migrations ── def test_migration_v58_schema(client): from app.db import get_conn with get_conn() as conn: tables = {r[0] for r in conn.execute("SELECT name FROM sqlite_master WHERE type='table'")} ucols = {r[1] for r in conn.execute("PRAGMA table_info(users)").fetchall()} version = conn.execute("SELECT MAX(version) FROM schema_version").fetchone()[0] assert "reminder_log" in tables assert "timezone" in ucols assert version >= 12 def test_meeting_template_enriched(client): from app.db import get_conn with get_conn() as conn: row = conn.execute( "SELECT schema_json, description FROM database_templates WHERE name='Meeting notes'" ).fetchone() schema = json.loads(row["schema_json"]) names = [p["name"] for p in schema] assert "Agenda" in names and "Notes" in names assert "agenda" in row["description"].lower() # ── Meta-key validation on the rows API ── def test_recurrence_meta_roundtrip_and_rejects(client): cid = _add_collection(client) did = _add_prop(client, cid, "When", "date") rid = client.post(f"/db/{cid}/pages/api", json={ "title": "Standup", "properties": { str(did): "2026-09-15", "__recurrence__": {str(did): {"freq": "daily"}}, }, }).json()["id"] page = client.get(f"/db/pages/{rid}/api").json() pv = json.loads(page["property_values_json"]) assert pv["__recurrence__"][str(did)]["freq"] == "daily" # Bad freq → 400 r = client.put(f"/db/pages/{rid}/api", json={"properties": { "__recurrence__": {str(did): {"freq": "fortnightly"}}, }}) assert r.status_code == 400 # Reminder on a non-date property → 400 tid = _add_prop(client, cid, "Text", "text") r = client.put(f"/db/pages/{rid}/api", json={"properties": { "__reminder__": {str(tid): {"value": 10, "unit": "minutes"}}, }}) assert r.status_code == 400 # Unknown timezone → 400 r = client.put(f"/db/pages/{rid}/api", json={"properties": { "__timezone__": {str(did): "Mars/Olympus"}, }}) assert r.status_code == 400 def test_reminder_meta_roundtrip(client): cid = _add_collection(client) did = _add_prop(client, cid, "When", "date") rid = client.post(f"/db/{cid}/pages/api", json={ "title": "Row", "properties": { str(did): "2026-10-01T09:00", "__reminder__": {str(did): {"value": 2, "unit": "hours"}}, }, }).json()["id"] pv = json.loads(client.get(f"/db/pages/{rid}/api").json()["property_values_json"]) assert pv["__reminder__"][str(did)] == {"value": 2, "unit": "hours"} # ── Calendar API (server expansion) ── def test_calendar_api_expands_recurrences(client): cid = _add_collection(client) did = _add_prop(client, cid, "When", "date") client.post(f"/db/{cid}/pages/api", json={ "title": "Weekly sync", "properties": { str(did): "2026-09-07", "__recurrence__": {str(did): {"freq": "weekly"}}, }, }) client.post(f"/db/{cid}/pages/api", json={ # plain one-off event "title": "Party", "properties": {str(did): "2026-09-20T18:30"}, }) r = client.get(f"/db/{cid}/calendar/api", params={"start": "2026-09-01", "end": "2026-09-30"}) assert r.status_code == 200 data = r.json() evs = data["events"] syncs = [e for e in evs if e["title"] == "Weekly sync"] assert [e["date"] for e in syncs] == ["2026-09-07", "2026-09-14", "2026-09-21", "2026-09-28"] assert all(e["recurring"] for e in syncs) party = [e for e in evs if e["title"] == "Party"][0] assert party["date"] == "2026-09-20" and party["time"] == "18:30" and not party["recurring"] def test_calendar_api_param_errors(client): cid = _add_collection(client) _add_prop(client, cid, "When", "date") assert client.get(f"/db/{cid}/calendar/api", params={"start": "bad"}).status_code == 400 assert client.get(f"/db/{cid}/calendar/api", params={"start": "2026-09-01", "end": "2028-01-01"}).status_code == 400 assert client.get("/db/999999/calendar/api", params={"start": "2026-09-01", "end": "2026-09-30"}).status_code == 404 cid2 = client.post("/db/api", json={"name": "NoDate"}).json()["id"] r = client.get(f"/db/{cid2}/calendar/api", params={"start": "2026-09-01", "end": "2026-09-30"}) assert r.status_code == 200 and r.json()["events"] == [] def test_calendar_api_row_timezone(client): cid = _add_collection(client) did = _add_prop(client, cid, "When", "date") client.post(f"/db/{cid}/pages/api", json={ "title": "TZ row", "properties": { str(did): "2026-09-10", "__timezone__": {str(did): "Europe/Paris"}, "__recurrence__": {str(did): {"freq": "weekly", "timezone": "Europe/Paris"}}, }, }) evs = client.get(f"/db/{cid}/calendar/api", params={"start": "2026-09-01", "end": "2026-09-30"}).json()["events"] assert evs and all(e["timezone"] == "Europe/Paris" for e in evs) # ── Timezone settings endpoints ── def test_user_timezone_endpoints(client): session, csrf, uid = _login(client) r = client.get("/api/notifications/timezone", cookies={"flowdeck_session": session}) assert r.status_code == 200 data = r.json() assert data["timezone"] == "" assert "Europe/Paris" in data["zones"] r = client.post("/api/notifications/timezone", json={"timezone": "Europe/Paris"}, cookies={"flowdeck_session": session}, headers={"X-CSRF-Token": csrf}) assert r.status_code == 200 assert client.get("/api/notifications/timezone", cookies={"flowdeck_session": session}).json()["timezone"] == "Europe/Paris" r = client.post("/api/notifications/timezone", json={"timezone": "Mars/Olympus"}, cookies={"flowdeck_session": session}, headers={"X-CSRF-Token": csrf}) assert r.status_code == 400 def test_notifications_unauth(client): assert client.get("/api/notifications/timezone").status_code == 401 # ── Reminder scanner (deterministic clock) ── def _mk_row_with_reminder(client, reminder, base="2026-09-15T09:00", freq=None): cid = _add_collection(client) did = _add_prop(client, cid, "When", "date") props = {str(did): base, "__reminder__": {str(did): reminder}} if freq: props["__recurrence__"] = {str(did): freq} page = client.post(f"/db/{cid}/pages/api", json={"title": "Event", "properties": props}) assert page.status_code == 200, page.text return cid, page.json()["id"] def test_reminder_scan_fires_once(client): from app.db import get_conn from app.services.reminders import scan_and_fire _login(client) # an admin user must exist to receive the notification _mk_row_with_reminder(client, {"value": 30, "unit": "minutes"}) now = dt.datetime(2026, 9, 15, 8, 35, tzinfo=dt.UTC) # event 09:00 - 30min = 08:30 fired = scan_and_fire(now=now) assert fired >= 1 with get_conn() as conn: n = conn.execute("SELECT COUNT(*) c FROM notifications WHERE ntype='reminder'").fetchone()["c"] log = conn.execute("SELECT COUNT(*) c FROM reminder_log").fetchone()["c"] assert n >= 1 and log >= 1 # Dedup: a second scan for the same occurrence fires nothing. assert scan_and_fire(now=now + dt.timedelta(minutes=5)) == 0 def test_reminder_scan_not_yet_due(client): from app.db import get_conn from app.services.reminders import scan_and_fire _mk_row_with_reminder(client, {"value": 30, "unit": "minutes"}) now = dt.datetime(2026, 9, 15, 8, 0, tzinfo=dt.UTC) # fire_at = 08:30 assert scan_and_fire(now=now) == 0 with get_conn() as conn: assert conn.execute("SELECT COUNT(*) c FROM notifications WHERE ntype='reminder'").fetchone()["c"] == 0 def test_reminder_recurring_occurrence(client): from app.db import get_conn from app.services.reminders import scan_and_fire _login(client) _mk_row_with_reminder( client, {"value": 1, "unit": "days"}, base="2026-09-01T09:00", freq={"freq": "weekly"}, ) # Weekly anchored on 2026-09-01 (Tuesday): occurrences 09-01, 09-08, 09-15… # 1-day reminder for the 09-15 occurrence fires on 09-14 at/after 09:00. now = dt.datetime(2026, 9, 14, 9, 5, tzinfo=dt.UTC) assert scan_and_fire(now=now) >= 1 with get_conn() as conn: row = conn.execute( "SELECT message FROM notifications WHERE ntype='reminder' ORDER BY id DESC LIMIT 1" ).fetchone() assert "2026-09-15" in row["message"] def test_reminder_targets_assigned_person(client): from app.db import get_conn from app.services.reminders import scan_and_fire session, csrf, uid = _login(client) cid = _add_collection(client) did = _add_prop(client, cid, "When", "date") pid = _add_prop(client, cid, "Owner", "person") client.post(f"/db/{cid}/pages/api", json={"title": "Mine", "properties": { str(did): "2026-09-20T10:00", str(pid): [{"id": uid, "login": "x"}], "__reminder__": {str(did): {"value": 15, "unit": "minutes"}}, }}) now = dt.datetime(2026, 9, 20, 9, 46, tzinfo=dt.UTC) # fire_at 09:45 assert scan_and_fire(now=now) >= 1 with get_conn() as conn: row = conn.execute( "SELECT user_id FROM notifications WHERE ntype='reminder' AND is_read=0" ).fetchone() assert row["user_id"] == uid def test_parse_lead_units(): from app.services.reminders import parse_lead assert parse_lead({"value": 30, "unit": "minutes"}) == 1800 assert parse_lead({"value": 2, "unit": "hours"}) == 7200 assert parse_lead({"value": 1, "unit": "days"}) == 86400 assert parse_lead({"unit": "none"}) is None assert parse_lead({"value": 0, "unit": "minutes"}) is None assert parse_lead(None) is None # ── Assignment notifications ── def test_assignment_notification_on_update(client): from app.db import get_conn session, csrf, actor = _login(client) with get_conn() as conn: login = f"v58b_{secrets.token_hex(4)}" conn.execute("INSERT INTO users (login, full_name, email) VALUES (?, 'Bob', 'bob@x.com')", (login,)) bob = conn.execute("SELECT id FROM users WHERE login=?", (login,)).fetchone()["id"] conn.commit() cid = _add_collection(client) pid = _add_prop(client, cid, "Owner", "person") rid = client.post(f"/db/{cid}/pages/api", json={"title": "Task", "properties": {}}).json()["id"] # Assign Bob. r = client.put(f"/db/pages/{rid}/api", json={"properties": { str(pid): [{"id": bob, "login": "bob"}], }}, cookies={"flowdeck_session": session}) assert r.status_code == 200 with get_conn() as conn: note = conn.execute( "SELECT * FROM notifications WHERE user_id=? AND ntype='assignment'", (bob,) ).fetchone() assert note is not None and note["title"] == "Assigned to you" and note["resource_id"] == rid # Re-assigning the same person does not duplicate. client.put(f"/db/pages/{rid}/api", json={"properties": { str(pid): [{"id": bob, "login": "bob"}], }}, cookies={"flowdeck_session": session}) with get_conn() as conn: n = conn.execute( "SELECT COUNT(*) c FROM notifications WHERE user_id=? AND ntype='assignment'", (bob,) ).fetchone()["c"] assert n == 1 def test_notification_prefs_extended_keys(client): session, csrf, uid = _login(client) r = client.get("/api/notifications/prefs", cookies={"flowdeck_session": session}) prefs = r.json()["prefs"] assert prefs["reminders"] is True and prefs["assignments"] is True r = client.post("/api/notifications/prefs", json={"reminders": False}, cookies={"flowdeck_session": session}, headers={"X-CSRF-Token": csrf}) assert r.json()["prefs"]["reminders"] is False assert r.json()["prefs"]["assignments"] is True # ── Front-end wiring (rendered template markers) ── def test_calendar_front_end_wired(client): import pathlib src = pathlib.Path("app/templates/_database_table_scripts.html").read_text(encoding="utf-8") assert "calendar/api" in src assert "data-cal-mode" in src and "refreshCalendar" in src assert "openEventEditor" in src and "__recurrence__" in src and "__reminder__" in src