deer-flow/backend/tests/test_schedule_thread_lookup.py
rayhpeng 6f84a4094d refactor(schedule): fill the ports with adapters and delete the old path
The outer ring for the domain added in #4597: SQL repositories, the run
launcher, the thread lookup, and the run-completion listener implementing
the ports it declared, plus the HTTP router and the poller that drive
them. All of it is instantiated in one composition root, so no route or
lifespan hook builds an adapter of its own.

With the ports filled, the pre-hexagonal implementation is deleted rather
than left alongside: `app/scheduler/service.py` and its router mixed
policy, persistence, and HTTP into one class, which is why its rules were
only reachable through a live database. Keeping both would leave two
implementations of the same rules writing to the same table.

Three of the domain's contracts needed real work on this side rather than
a straight port of the pre-#4597 adapters:

- The launcher now distinguishes certain failure from doubt. Only a 4xx
  is certain enough to raise LaunchFailedError, which releases the task's
  single active slot; a 5xx, an arbitrary exception, or a reply whose
  identity will not decode all raise LaunchIndeterminateError and keep
  the slot held. Guessing "failed" after the launch request was sent is
  what re-opens #4452's duplicate execution.

- The task repository implements the optimistic token. `save` is a
  conditional UPDATE on `version` rather than read-check-write, because
  the latter lets two savers observe the same version and both commit;
  every other committed write increments it. This needs a column, so it
  ships with migration 0011 -- the only schema change in the slice, and
  the reason the alembic head pins move.

- The router builds commands with plain `None` for "not supplied", and
  maps ConcurrentUpdateError onto a retryable 409.

The concurrency invariants are pinned by contract suites that run each
port against both the in-memory double and real sqlite -- including a new
TestOptimisticConcurrency covering what invalidates an earlier read --
plus the dispatch-race tests against a real database.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-01 13:01:32 +08:00

72 lines
3.1 KiB
Python

"""Contract tests for the thread-lookup anti-corruption layer.
The port asks one question -- "does this thread exist AND may this user use
it?" -- and deliberately answers both halves with a single bool, so a caller
cannot probe for the existence of threads they cannot see. These tests pin that
the adapter does not accidentally widen it back into two answers.
"""
from __future__ import annotations
import pytest
from app.adapters.schedule.thread_lookup import ThreadStoreThreadLookup
class _RecordingThreadStore:
"""Stands in for `ThreadMetaStore`, recording how it was asked."""
def __init__(self, owners: dict[str, str] | None = None) -> None:
self._owners = dict(owners or {})
self.calls: list[tuple[str, str, bool]] = []
async def check_access(self, thread_id: str, user_id: str, *, require_existing: bool = False) -> bool:
self.calls.append((thread_id, user_id, require_existing))
owner = self._owners.get(thread_id)
if owner is None:
# Mirrors the real store: absent rows pass a non-strict check.
return not require_existing
return owner == user_id
class TestTheOneQuestion:
@pytest.mark.asyncio
async def test_an_owned_thread_exists_for_its_user(self):
lookup = ThreadStoreThreadLookup(_RecordingThreadStore({"thread-1": "user-1"}))
assert await lookup.exists_for_user("thread-1", "user-1") is True
@pytest.mark.asyncio
async def test_someone_elses_thread_does_not(self):
lookup = ThreadStoreThreadLookup(_RecordingThreadStore({"thread-1": "user-1"}))
assert await lookup.exists_for_user("thread-1", "user-2") is False
@pytest.mark.asyncio
async def test_a_missing_thread_does_not(self):
"""This is the half `require_existing` buys: without it the store
treats an absent row as accessible, and a task could be bound to a
thread that does not exist."""
lookup = ThreadStoreThreadLookup(_RecordingThreadStore())
assert await lookup.exists_for_user("thread-nope", "user-1") is False
@pytest.mark.asyncio
async def test_missing_and_forbidden_are_indistinguishable(self):
lookup = ThreadStoreThreadLookup(_RecordingThreadStore({"thread-1": "user-1"}))
missing = await lookup.exists_for_user("thread-nope", "user-2")
forbidden = await lookup.exists_for_user("thread-1", "user-2")
assert missing == forbidden is False
class TestHowTheStoreIsAsked:
@pytest.mark.asyncio
async def test_require_existing_is_always_set(self):
store = _RecordingThreadStore({"thread-1": "user-1"})
await ThreadStoreThreadLookup(store).exists_for_user("thread-1", "user-1")
assert store.calls == [("thread-1", "user-1", True)]
@pytest.mark.asyncio
async def test_the_result_is_a_real_bool(self):
"""The port is typed `bool`; a truthy row object leaking through would
satisfy the domain's `if` and still be the wrong contract."""
store = _RecordingThreadStore({"thread-1": "user-1"})
assert await ThreadStoreThreadLookup(store).exists_for_user("thread-1", "user-1") is True