T05: Calendar plugin backend — appointments + tasks + kanban + ICS + resources + recurrence — 69 tests, 86.87% coverage

This commit is contained in:
leocrm-bot
2026-06-30 01:12:33 +02:00
parent 1dccb64bb4
commit 7fbeeda678
14 changed files with 3675 additions and 92 deletions
+66 -1
View File
@@ -36,6 +36,17 @@ from app.models.role import Role
from app.models.tenant import Tenant
from app.models.user import User, UserTenant
from app.models.workflow import Workflow, WorkflowInstance, WorkflowStepHistory # noqa: F401
from app.plugins.builtins.calendar import CalendarPlugin # noqa: F401
from app.plugins.builtins.calendar.models import ( # noqa: F401
Calendar,
CalendarEntry,
CalendarEntryLink,
CalendarShare,
Resource,
ResourceBooking,
Subtask,
UserCalendarVisibility,
)
from app.plugins.builtins.dms import DmsPlugin # noqa: F401
from app.plugins.builtins.dms.models import File as DmsFile # noqa: F401
from app.plugins.builtins.dms.models import Folder # noqa: F401
@@ -103,7 +114,7 @@ def clean_tables(db_setup):
# TRUNCATE all tables with CASCADE — fast and reliable isolation
conn.execute(
text(
"TRUNCATE TABLE files, folders, entity_links, share_links, permissions, tag_assignments, tags, workflow_step_history, workflow_instances, workflows, ai_messages, ai_conversations, plugin_migrations, plugins, company_contacts, contacts, api_tokens, password_reset_tokens, notifications, deletion_log, audit_log, sessions, roles, companies, user_tenants, users, tenants CASCADE;"
"TRUNCATE TABLE resource_bookings, resources, subtasks, user_calendar_visibility, calendar_shares, calendar_entry_links, calendar_entries, calendars, files, folders, entity_links, share_links, permissions, tag_assignments, tags, workflow_step_history, workflow_instances, workflows, ai_messages, ai_conversations, plugin_migrations, plugins, company_contacts, contacts, api_tokens, password_reset_tokens, notifications, deletion_log, audit_log, sessions, roles, companies, user_tenants, users, tenants CASCADE;"
)
)
conn.commit()
@@ -349,3 +360,57 @@ async def authed_client(
assert resp.status_code == 200, f"DMS activate failed: {resp.text}"
return dms_client, seed
# ─── Calendar Fixtures ───
@pytest_asyncio.fixture
async def calendar_app(engine: AsyncEngine, redis_client):
"""FastAPI app with Calendar plugin registered, installed, and activated."""
reset_engine_for_testing(engine)
app = create_app()
registry = reset_registry_for_testing()
registry.initialize(engine, app)
container = get_container()
await container.initialize()
registry.register_plugin(CalendarPlugin())
reset_plugin_service_for_testing(registry)
# Pre-install and activate the plugin so routes are registered
# (tests that use viewer accounts can't install/activate — require_admin blocks them)
_sf = async_sessionmaker(bind=engine, expire_on_commit=False, class_=AsyncSession)
async with _sf() as session:
await registry.install(session, "calendar")
await registry.activate(session, "calendar")
await session.commit()
yield app
await close_engine()
@pytest_asyncio.fixture
async def calendar_client(calendar_app) -> AsyncClient:
transport = ASGITransport(app=calendar_app)
async with AsyncClient(transport=transport, base_url="http://test") as c:
yield c
@pytest_asyncio.fixture
async def calendar_authed_client(
calendar_client: AsyncClient, db_session: AsyncSession
) -> tuple[AsyncClient, dict]:
"""Authenticated admin client with seeded data and calendar plugin activated."""
seed = await seed_tenant_and_users(db_session)
await login_client(calendar_client, "admin@tenanta.com")
# Install + activate calendar plugin
resp = await calendar_client.post("/api/v1/plugins/calendar/install", headers=ORIGIN_HEADER)
assert resp.status_code == 200, f"Calendar install failed: {resp.text}"
resp = await calendar_client.post("/api/v1/plugins/calendar/activate", headers=ORIGIN_HEADER)
assert resp.status_code == 200, f"Calendar activate failed: {resp.text}"
return calendar_client, seed
File diff suppressed because it is too large Load Diff
+227
View File
@@ -0,0 +1,227 @@
"""Unit tests for recurrence engine - daily/weekly/monthly/yearly/custom + exceptions."""
from datetime import UTC, date, datetime
from app.plugins.builtins.calendar.recurrence import (
_days_in_month,
_extract_bydays,
_extract_int,
_extract_setpos,
_nth_weekday_of_month,
_parse_date,
_parse_datetime,
generate_occurrences,
)
class TestParseDate:
def test_parse_date_from_date(self):
d = date(2026, 6, 15)
assert _parse_date(d) == d
def test_parse_date_from_datetime(self):
dt = datetime(2026, 6, 15, 10, 30, tzinfo=UTC)
assert _parse_date(dt) == date(2026, 6, 15)
def test_parse_date_from_iso_string(self):
assert _parse_date("2026-06-15") == date(2026, 6, 15)
assert _parse_date("2026-06-15T10:30:00") == date(2026, 6, 15)
def test_parse_date_from_invalid_string(self):
assert _parse_date("not-a-date") is None
def test_parse_date_none(self):
assert _parse_date(None) is None
class TestParseDatetime:
def test_parse_datetime_from_datetime(self):
dt = datetime(2026, 6, 15, 10, 30, tzinfo=UTC)
assert _parse_datetime(dt) == dt
def test_parse_datetime_from_date(self):
d = date(2026, 6, 15)
result = _parse_datetime(d)
assert result == datetime(2026, 6, 15)
def test_parse_datetime_from_iso_string(self):
result = _parse_datetime("2026-06-15T10:30:00")
assert result == datetime(2026, 6, 15, 10, 30)
def test_parse_datetime_from_invalid_string(self):
assert _parse_datetime("not-a-datetime") is None
def test_parse_datetime_none(self):
assert _parse_datetime(None) is None
class TestGenerateOccurrencesDaily:
def test_daily_basic(self):
base = datetime(2026, 6, 1, 9, 0, tzinfo=UTC)
rec = {"pattern": "daily"}
occs = generate_occurrences(rec, base, date(2026, 6, 1), date(2026, 6, 5))
assert len(occs) == 5
assert occs[0].date() == date(2026, 6, 1)
assert occs[-1].date() == date(2026, 6, 5)
def test_daily_with_interval(self):
base = datetime(2026, 6, 1, 9, 0, tzinfo=UTC)
rec = {"pattern": "daily", "custom_rule": "INTERVAL=2"}
occs = generate_occurrences(rec, base, date(2026, 6, 1), date(2026, 6, 7))
assert len(occs) == 4
assert occs[0].date() == date(2026, 6, 1)
assert occs[1].date() == date(2026, 6, 3)
def test_daily_with_exception(self):
base = datetime(2026, 6, 1, 9, 0, tzinfo=UTC)
rec = {"pattern": "daily", "exceptions": ["2026-06-02", "2026-06-04"]}
occs = generate_occurrences(rec, base, date(2026, 6, 1), date(2026, 6, 5))
assert len(occs) == 3
dates = [o.date() for o in occs]
assert date(2026, 6, 2) not in dates
assert date(2026, 6, 4) not in dates
def test_daily_with_end_date(self):
base = datetime(2026, 6, 1, 9, 0, tzinfo=UTC)
rec = {"pattern": "daily", "end_date": "2026-06-03"}
occs = generate_occurrences(rec, base, date(2026, 6, 1), date(2026, 6, 10))
assert len(occs) == 3
def test_no_recurrence(self):
base = datetime(2026, 6, 1, 9, 0, tzinfo=UTC)
occs = generate_occurrences(None, base, date(2026, 6, 1), date(2026, 6, 5))
assert len(occs) == 1
assert occs[0].date() == date(2026, 6, 1)
def test_no_base_start(self):
occs = generate_occurrences({"pattern": "daily"}, None, date(2026, 6, 1), date(2026, 6, 5))
assert occs == []
class TestGenerateOccurrencesWeekly:
def test_weekly_basic(self):
base = datetime(2026, 6, 2, 9, 0, tzinfo=UTC)
rec = {"pattern": "weekly"}
occs = generate_occurrences(rec, base, date(2026, 6, 1), date(2026, 6, 22))
assert len(occs) == 3
assert all(o.weekday() == 1 for o in occs)
def test_weekly_with_bydays(self):
base = datetime(2026, 6, 1, 9, 0, tzinfo=UTC)
rec = {"pattern": "weekly", "custom_rule": "BYDAY=MO,WE,FR"}
occs = generate_occurrences(rec, base, date(2026, 6, 1), date(2026, 6, 7))
assert len(occs) == 3
def test_weekly_with_interval(self):
base = datetime(2026, 6, 2, 9, 0, tzinfo=UTC)
rec = {"pattern": "weekly", "custom_rule": "INTERVAL=2"}
occs = generate_occurrences(rec, base, date(2026, 6, 1), date(2026, 6, 30))
assert len(occs) == 3
class TestGenerateOccurrencesMonthly:
def test_monthly_basic(self):
base = datetime(2026, 6, 15, 10, 0, tzinfo=UTC)
rec = {"pattern": "monthly"}
occs = generate_occurrences(rec, base, date(2026, 6, 1), date(2026, 9, 30))
assert len(occs) == 4
assert all(o.day == 15 for o in occs)
def test_monthly_with_interval(self):
base = datetime(2026, 6, 15, 10, 0, tzinfo=UTC)
rec = {"pattern": "monthly", "custom_rule": "INTERVAL=2"}
occs = generate_occurrences(rec, base, date(2026, 6, 1), date(2026, 12, 31))
assert len(occs) == 4
def test_monthly_with_bysetpos(self):
base = datetime(2026, 6, 1, 10, 0, tzinfo=UTC)
rec = {"pattern": "monthly", "custom_rule": "BYSETPOS=2"}
occs = generate_occurrences(rec, base, date(2026, 6, 1), date(2026, 7, 31))
assert len(occs) == 2
class TestGenerateOccurrencesYearly:
def test_yearly_basic(self):
base = datetime(2026, 6, 15, 10, 0, tzinfo=UTC)
rec = {"pattern": "yearly"}
occs = generate_occurrences(rec, base, date(2026, 1, 1), date(2028, 12, 31))
assert len(occs) == 3
assert all(o.month == 6 for o in occs)
assert all(o.day == 15 for o in occs)
def test_yearly_with_interval(self):
base = datetime(2026, 6, 15, 10, 0, tzinfo=UTC)
rec = {"pattern": "yearly", "custom_rule": "INTERVAL=2"}
occs = generate_occurrences(rec, base, date(2026, 1, 1), date(2030, 12, 31))
assert len(occs) == 2 # 2026, 2028 (2030 beyond 2-year cap)
class TestGenerateOccurrencesCustom:
def test_custom_with_bydays(self):
base = datetime(2026, 6, 1, 9, 0, tzinfo=UTC)
rec = {"pattern": "custom", "custom_rule": "INTERVAL=1;BYDAY=TU,TH"}
occs = generate_occurrences(rec, base, date(2026, 6, 1), date(2026, 6, 7))
assert len(occs) == 2
class TestExtractHelpers:
def test_extract_int(self):
assert _extract_int("INTERVAL=2;BYDAY=MO", "INTERVAL") == 2
assert _extract_int("BYDAY=MO", "INTERVAL") == 1
def test_extract_bydays(self):
assert _extract_bydays("BYDAY=MO,WE,FR") == ["MO", "WE", "FR"]
assert _extract_bydays("INTERVAL=2") is None
def test_extract_setpos(self):
assert _extract_setpos("BYSETPOS=2") == [2]
assert _extract_setpos("BYSETPOS=1,3") == [1, 3]
assert _extract_setpos("INTERVAL=2") is None
class TestDateHelpers:
def test_days_in_month(self):
assert _days_in_month(2026, 2) == 28
assert _days_in_month(2024, 2) == 29
assert _days_in_month(2026, 12) == 31
assert _days_in_month(2026, 4) == 30
def test_nth_weekday_of_month_positive(self):
first_mon = _nth_weekday_of_month(2026, 6, 1, 0)
assert first_mon == date(2026, 6, 1)
second_mon = _nth_weekday_of_month(2026, 6, 2, 0)
assert second_mon == date(2026, 6, 8)
def test_nth_weekday_of_month_negative(self):
last_mon = _nth_weekday_of_month(2026, 6, -1, 0)
assert last_mon == date(2026, 6, 29)
def test_nth_weekday_of_month_invalid(self):
result = _nth_weekday_of_month(2026, 2, 5, 0)
assert result is None
class TestEdgeCases:
def test_range_before_base_start(self):
base = datetime(2026, 6, 15, 9, 0, tzinfo=UTC)
rec = {"pattern": "daily"}
occs = generate_occurrences(rec, base, date(2026, 6, 1), date(2026, 6, 10))
assert occs == []
def test_range_after_end_date(self):
base = datetime(2026, 6, 1, 9, 0, tzinfo=UTC)
rec = {"pattern": "daily", "end_date": "2026-06-05"}
occs = generate_occurrences(rec, base, date(2026, 6, 10), date(2026, 6, 20))
assert occs == []
def test_unknown_pattern(self):
base = datetime(2026, 6, 1, 9, 0, tzinfo=UTC)
rec = {"pattern": "unknown"}
occs = generate_occurrences(rec, base, date(2026, 6, 1), date(2026, 6, 5))
assert occs == []
def test_max_2_years_forward(self):
base = datetime(2026, 6, 1, 9, 0, tzinfo=UTC)
rec = {"pattern": "daily"}
occs = generate_occurrences(rec, base, date(2026, 6, 1), date(2030, 6, 1))
assert len(occs) <= 731