Files
odysseus/tests/test_caldav_bidirectional_sync.py
T
Tal.Yuan fc1351d0f8 refactor(tools): split tool_implementations.py into src/tools/ package (#4423)
* test(tools): add shim protection test for tool_implementations split

Covers all 48 top-level functions (33 do_* + 15 _helpers) extracted from
the original module. Guards the upcoming split: the shim must re-export
every symbol so existing 'from src.tool_implementations import X' imports
keep working. Passes on baseline (pre-split).

* refactor(tools): add src/tools/ package with shared _common

Slice 1 Task 2 (#4082/#4071). Adds the package skeleton and moves the
shared _parse_tool_args helper into src/tools/_common.py. Domain modules
will import from here. tool_implementations.py is untouched at this step.

* refactor(tools): extract system domain into src/tools/system.py

Slice 1 (#4082/#4071), Task 3: move the system-domain tool functions
(do_manage_skills/_skill_dump/do_manage_tasks/do_manage_endpoints/
do_manage_mcp/do_manage_webhooks/do_manage_tokens/do_manage_settings/
do_api_call/do_app_api) and the app_api blocklist constants out of
tool_implementations.py into a new src/tools/system.py module.

tool_implementations.py re-imports all of them so it stays a working
backward-compatible facade (shim test stays green).

- do_manage_mcp resolves get_mcp_manager via a function-local import
  from tool_implementations so the test that patches
  src.tool_implementations.get_mcp_manager still applies post-move.
- do_app_api imports _internal_headers and _INTERNAL_BASE (still in
  tool_implementations) function-locally to avoid a circular import.
- Repoint test_context_budget introspection assertion to the moved
  code's new home in src/tools/system.py.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* refactor(tools): extract cookbook domain into src/tools/cookbook.py

Moves the model-serving (cookbook) tool domain out of tool_implementations.py
into src/tools/cookbook.py as part of slice 1 (#4082/#4071):

- 13 do_* tools: download/serve/list/stop/tail/search/adopt/cached models,
  list downloads/cancel, list cookbook servers, serve presets
- 9 private helpers: _cookbook_servers, _resolve_cookbook_host,
  _cookbook_env_for_host, _infer_serve_{port,host}, _ensure_served_endpoint,
  _cookbook_register_task, _cookbook_apply_retry_suggestion,
  _scan_running_model_processes, _cookbook_kill_session
- _MODEL_PROCESS_PATTERNS constant (used only by _scan_running_model_processes)

tool_implementations.py stays a backward-compatible facade via a re-import
from src.tools.cookbook; src/tools/__init__ re-exports the same symbols.

_internal_headers and _INTERNAL_BASE stay in tool_implementations.py (shared
by system.py's do_app_api and many cookbook funcs). Each cookbook function
that needs them does a function-local import to avoid a top-level circular
dependency, matching the system-domain split.

Verified: compileall clean; shim test green; cookbook-touching suite
(652 passed, 1 skipped); full suite 3587 passed, 2 failed
(pre-existing test_api_chat_security, unrelated).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* refactor(tools): extract search domain into src/tools/search.py

* refactor(tools): extract notes domain into src/tools/notes.py

* refactor(tools): extract calendar domain into src/tools/calendar.py

Repoints tests/test_caldav_bidirectional_sync.py source-introspection
to src/tools/calendar.py (do_manage_calendar moved there).

* refactor(tools): extract image domain into src/tools/image.py

* refactor(tools): extract research domain into src/tools/research.py

* refactor(tools): extract contacts domain into src/tools/contacts.py

* refactor(tools): extract vault domain into src/tools/vault.py

Repoints tests/test_vault_password_not_in_argv.py source-introspection
to src/tools/vault.py (the vault do_* helpers moved there).

* refactor(tools): collapse tool_implementations to clean re-export shim

Move shared _INTERNAL_BASE/_internal_headers to src/tools/_common.py and
drop the duplicate _parse_tool_args (already in _common). tool_implementations.py
is now a pure re-export facade (+ 3 pre-existing email-context helpers, out of
scope). Domain files' function-local imports of these names still resolve via
the facade re-export.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix(tools): port upstream cookbook workflow changes to split module

Rebase onto dev dropped c504214 ("Cookbook model workflow fixes") edits
to do_serve_model / do_tail_serve_output: the extraction commit moved
the pre-edit bodies into src/tools/cookbook.py and git auto-accepted the
deletion from tool_implementations.py, losing dev's changes. Restore them
in their post-split home:

- do_serve_model: add where/log_path/next_tools and the expanded
  "Next required check" output message
- do_tail_serve_output: empty-output fallback message replacing
  "(empty pane)"

(do_manage_settings web_fetch alias edit was already applied to
src/tools/system.py during the system-extract conflict resolution.)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix(tools): break admin_tools circular import in split facade

After rebasing onto dev (#3629 moved the admin manage_* tools into
src/agent_tools/admin_tools), the facade re-exported them via a top-level
`from src.agent_tools.admin_tools import ...`. But src.agent_tools.__init__
imports this facade at top level, so the eager import re-entered the
partially-initialized agent_tools package and broke collection.

Re-export the admin symbols (do_manage_endpoints/mcp/webhooks/tokens/
settings, _MCP_DENIED_COMMANDS, _validate_mcp_command) lazily through
module __getattr__ instead, and drop them from src/tools/__init__ (they
no longer live in the src.tools package). system.py now holds only the
skills/tasks/api bridges; admin tools live solely in admin_tools.py,
matching upstream.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix(tools): re-export dropped helpers through the split shim

Address review finding from #4423: the compatibility facade claimed to
preserve every original top-level symbol but omitted three helpers the
old src.tool_implementations exposed. Re-export them and pin them in
the shim protection test:

- _string_arg, _validate_cookbook_ssh_target <- src/tools/cookbook.py
- _mcp_allowed_commands <- src/agent_tools/admin_tools.py (lazily via
  __getattr__, to keep the agent_tools.__init__ <-> facade import acyclic
  after the #3629 admin-tools migration)

All three added to tests/test_tool_implementations_shim.py _EXPECTED so
the test contract now matches its "every original top-level function"
comment.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* test(tools): self-verify shim re-exports every domain do_*

The hand-maintained _EXPECTED list in the shim protection test can drift
silently when a new tool is added to a domain module but not re-exported
by the facade — exactly the omission a reviewer flagged post-split.
Add an auto-discovering test that enumerates every do_* from the domain
modules (incl. admin_tools) and asserts reachability through the shim,
so a forgotten re-export fails the build automatically.

Uses hasattr (not dir(ti)) because the admin symbols are re-exported
lazily via module __getattr__ and don't appear in dir(ti).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* test(tools): self-verify every in-repo facade import resolves

RaresKeY's P3 on the shim test was a claim-vs-reality gap: the docstring
said it protected "every from src.tool_implementations import X" but the
hand-maintained _EXPECTED list omitted three underscore helpers, so the
claim wasn't enforced. Re-exporting the three (cf1f5e3) fixed the known
gap; this closes the structural one.

Add test_every_facade_import_in_repo_resolves: ast-enumerate every
`from src.tool_implementations import X` site in src/ and tests/ and
assert hasattr(ti, X) for each. A forgotten re-export that anything in
the repo imports now fails the build automatically — including underscore
helpers, which the do_* discovery test does not cover.

Together with test_shim_reexports_every_domain_do_function, the shim
contract is now self-verifying. Demote _EXPECTED in the docstring to the
curated historical/downstream surface (the three helpers have no in-repo
consumer, so they stay manual by necessity) instead of "ground truth".

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix(tools): dedupe _parse_tool_args + align shim guard with route consumers

Addresses two P3s from review (RaresKeY, 2026-06-26):

1. maintainability — _common carried a full copy of _parse_tool_args
   alongside the canonical src.tool_utils one; future parser fixes could
   diverge. The two bodies were byte-identical in logic, so _common now
   re-exports from tool_utils (a leaf module, no circular-import risk).
   The single-source test is extended to assert _common._parse_tool_args
   and tool_implementations._parse_tool_args are the same object as
   tool_utils._parse_tool_args.

2. test — the shim guard's import-site scan only walked src/ and tests/,
   missing routes/chat_routes.py's clear_active_email/set_active_email
   imports, and _EXPECTED omitted the active-email facade helpers. The
   scan now walks every first-party Python dir (pruning venvs/caches/data
   in-place), and set/get/clear_active_email are added to _EXPECTED
   (get_active_email has no in-repo importer, so the scan alone can't see
   it).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

---------

Co-authored-by: yuandonghao <yuandonghao@cohl.com>
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-26 15:40:04 +01:00

170 lines
6.1 KiB
Python

"""Regression coverage for bidirectional CalDAV sync plumbing.
These tests avoid a live CalDAV server. They pin the local invariants that keep
Odysseus-created CalDAV events from being pruned before they can be pushed.
"""
from datetime import datetime
import importlib.util
from pathlib import Path
import sys
from src.caldav_writeback import build_event_ical
def test_event_to_ical_serializes_core_fields_and_rrule():
ical = build_event_ical({
"uid": "evt-123",
"summary": "Planning",
"description": "Bring notes",
"location": "HQ",
"dtstart": datetime(2026, 6, 5, 9, 0),
"dtend": datetime(2026, 6, 5, 10, 0),
"all_day": False,
"is_utc": False,
"rrule": "FREQ=WEEKLY;COUNT=2",
})
assert "UID:evt-123" in ical
assert "SUMMARY:Planning" in ical
assert "DESCRIPTION:Bring notes" in ical
assert "LOCATION:HQ" in ical
assert "RRULE:FREQ=WEEKLY;COUNT=2" in ical
def test_caldav_pull_prune_skips_unsynced_or_pending_local_rows():
source = Path("src/caldav_sync.py").read_text()
assert 'existing.caldav_sync_pending in {"create", "update"}' in source
assert "CalendarEvent.remote_href.isnot(None)" in source
assert "CalendarEvent.caldav_sync_pending.is_(None)" in source
def test_http_calendar_writes_mark_pending_and_push_after_commit():
source = Path("routes/calendar_routes.py").read_text()
assert 'caldav_sync_pending="create" if cal.source == "caldav" else None' in source
assert 'ev.caldav_sync_pending = "update"' in source
assert 'await _push_caldav_event_after_commit(owner, uid, "create")' in source
assert 'await _push_caldav_event_after_commit(owner, base_uid, "update")' in source
assert 'await _push_caldav_event_after_commit(owner, base_uid, "delete")' in source
assert "_record_caldav_delete_tombstone(db, ev, owner)" in source
assert 'not result.get("ok")' in source
def test_agent_calendar_writes_share_caldav_push_path():
source = Path("src/tools/calendar.py").read_text()
assert "_push_caldav_event_after_commit" in source
assert 'caldav_sync_pending="create" if cal.source == "caldav" else None' in source
assert 'ev.caldav_sync_pending = "update"' in source
assert 'await _push_caldav_event_after_commit(owner, uid, "create")' in source
assert 'await _push_caldav_event_after_commit(owner, base_uid, "update")' in source
assert 'await _push_caldav_event_after_commit(owner, base_uid, "delete")' in source
assert "_record_caldav_delete_tombstone(db, ev, owner)" in source
def test_database_declares_and_migrates_caldav_remote_metadata():
source = Path("core/database.py").read_text()
for needle in [
"class CalendarDeletedEvent",
"remote_href = Column(String, nullable=True)",
"remote_etag = Column(String, nullable=True)",
"caldav_sync_pending = Column(String, nullable=True)",
"caldav_base_url = Column(String, nullable=True)",
"ALTER TABLE calendar_events ADD COLUMN remote_href TEXT",
"ALTER TABLE calendar_events ADD COLUMN remote_etag TEXT",
"ALTER TABLE calendar_events ADD COLUMN caldav_sync_pending TEXT",
"ALTER TABLE calendars ADD COLUMN caldav_base_url TEXT",
"_migrate_add_caldav_sync_columns()",
]:
assert needle in source
def test_failed_remote_delete_leaves_tombstone_and_later_retry_cleans_up(tmp_path, monkeypatch):
import src.caldav_writeback as writeback
monkeypatch.setenv("DATABASE_URL", f"sqlite:///{tmp_path / 'calendar.db'}")
spec = importlib.util.spec_from_file_location("core.database", Path("core/database.py"))
dbmod = importlib.util.module_from_spec(spec)
monkeypatch.setitem(sys.modules, "core.database", dbmod)
spec.loader.exec_module(dbmod)
CalendarCal = dbmod.CalendarCal
CalendarDeletedEvent = dbmod.CalendarDeletedEvent
CalendarEvent = dbmod.CalendarEvent
TestingSessionLocal = dbmod.SessionLocal
session = TestingSessionLocal()
try:
cal = CalendarCal(
id="caldav-test",
owner="alice",
name="Remote",
source="caldav",
caldav_base_url="https://caldav.example/calendars/alice/main/",
)
ev = CalendarEvent(
uid="evt-delete",
calendar_id=cal.id,
summary="Delete me",
dtstart=datetime(2026, 6, 5, 9, 0),
dtend=datetime(2026, 6, 5, 10, 0),
remote_href="https://caldav.example/calendars/alice/main/evt-delete.ics",
)
session.add(cal)
session.add(ev)
session.commit()
tombstone = CalendarDeletedEvent(
uid=ev.uid,
owner="alice",
calendar_id=ev.calendar_id,
remote_href=ev.remote_href,
remote_etag=ev.remote_etag,
caldav_base_url=cal.caldav_base_url,
summary=ev.summary,
)
session.add(tombstone)
session.delete(ev)
session.commit()
assert session.query(CalendarEvent).filter_by(uid="evt-delete").first() is None
tombstone = session.query(CalendarDeletedEvent).filter_by(uid="evt-delete").first()
assert tombstone is not None
assert tombstone.remote_href.endswith("evt-delete.ics")
finally:
session.close()
writeback._persist_writeback_result(
"alice",
"caldav-test",
"evt-delete",
{"ok": False, "error": "temporary remote delete failure"},
delete=True,
)
session = TestingSessionLocal()
try:
tombstone = session.query(CalendarDeletedEvent).filter_by(uid="evt-delete").first()
assert tombstone is not None
assert "temporary remote delete failure" in tombstone.last_error
finally:
session.close()
writeback._persist_writeback_result(
"alice",
"caldav-test",
"evt-delete",
{"ok": True},
delete=True,
)
session = TestingSessionLocal()
try:
assert session.query(CalendarDeletedEvent).filter_by(uid="evt-delete").first() is None
assert session.query(CalendarEvent).filter_by(uid="evt-delete").first() is None
finally:
session.close()