mirror of
https://github.com/bytedance/deer-flow.git
synced 2026-08-01 19:06:01 +00:00
- command-ify the write use cases (RateRun / RetractRunRating; queries keep plain parameters, commands stay dumb data) - split the domain errors into exceptions.py, a peer of model.py (PEP 8 Error suffixes, AWS-style module name) - unify the aggregate->row mapping as _apply(row, feedback) so one explicit field list serves both the insert and the update path - drop the unused feedback.message_id column (migration 0011): feedback is bound to a run, nothing ever wrote or read the field - pin remove_for_run's equality semantics for user_id=None in the contract suite and fix the port docstring that contradicted both implementations
105 lines
4.2 KiB
Python
105 lines
4.2 KiB
Python
"""FeedbackService tests with injected fakes — zero IO, no engine.
|
|
|
|
The seam the ports create is what makes these tests possible: the service
|
|
is exercised end-to-end against dict-backed doubles. State-changing use
|
|
cases go through command objects (RateRun / RetractRunRating); queries
|
|
keep plain parameters.
|
|
"""
|
|
|
|
import pytest
|
|
from feedback_fakes import FakeRunLookup, InMemoryFeedbackRepository
|
|
|
|
from deerflow.domain.feedback import FeedbackService, InvalidRatingError, RateRun, RetractRunRating, RunNotFoundError
|
|
|
|
|
|
def _service(runs: dict[str, str] | None = None) -> FeedbackService:
|
|
return FeedbackService(
|
|
repository=InMemoryFeedbackRepository(),
|
|
runs=FakeRunLookup(runs if runs is not None else {"r1": "t1"}),
|
|
)
|
|
|
|
|
|
def _rate(thread_id: str = "t1", run_id: str = "r1", *, rating: int = 1, user_id: str | None = "u1", comment: str | None = None, tags: tuple[str, ...] = ()) -> RateRun:
|
|
return RateRun(thread_id=thread_id, run_id=run_id, rating=rating, user_id=user_id, comment=comment, tags=tags)
|
|
|
|
|
|
class TestRateRun:
|
|
@pytest.mark.anyio
|
|
async def test_stores_rating(self):
|
|
svc = _service()
|
|
fb = await svc.rate_run(_rate(rating=1))
|
|
assert fb.rating == 1
|
|
assert fb.user_id == "u1"
|
|
|
|
@pytest.mark.anyio
|
|
async def test_idempotent_upsert_keeps_identity(self):
|
|
svc = _service()
|
|
first = await svc.rate_run(_rate(rating=1))
|
|
second = await svc.rate_run(_rate(rating=-1, comment="meh"))
|
|
assert second.feedback_id == first.feedback_id
|
|
assert second.rating == -1
|
|
|
|
@pytest.mark.anyio
|
|
async def test_enrich_with_tags(self):
|
|
svc = _service()
|
|
await svc.rate_run(_rate(rating=-1))
|
|
fb = await svc.rate_run(_rate(rating=-1, comment="wrong", tags=("incorrect",)))
|
|
assert fb.tags == ("incorrect",)
|
|
|
|
@pytest.mark.anyio
|
|
async def test_unknown_run_rejected(self):
|
|
svc = _service({})
|
|
with pytest.raises(RunNotFoundError):
|
|
await svc.rate_run(_rate())
|
|
|
|
@pytest.mark.anyio
|
|
async def test_cross_thread_run_rejected(self):
|
|
"""A run id belonging to another thread must not be ratable through
|
|
this thread's URL (ownership check)."""
|
|
svc = _service({"r1": "other-thread"})
|
|
with pytest.raises(RunNotFoundError):
|
|
await svc.rate_run(_rate())
|
|
|
|
@pytest.mark.anyio
|
|
async def test_invalid_rating_rejected_before_run_lookup(self):
|
|
# "unknown-run" is absent from the lookup, so a rating validated after
|
|
# the RunLookup port call would surface RunNotFoundError instead.
|
|
# Expecting InvalidRatingError is what pins validation ahead of I/O.
|
|
svc = _service()
|
|
with pytest.raises(InvalidRatingError):
|
|
await svc.rate_run(_rate(run_id="unknown-run", rating=0))
|
|
|
|
def test_command_is_dumb_data(self):
|
|
# The command carries intent without validating it: business rules
|
|
# stay on the aggregate, so error attribution (invalid rating before
|
|
# unknown run) is owned by the handler's construction order, not by
|
|
# the command's own constructor.
|
|
cmd = _rate(rating=0)
|
|
assert cmd.rating == 0
|
|
|
|
|
|
class TestRetractAndReads:
|
|
@pytest.mark.anyio
|
|
async def test_retract(self):
|
|
svc = _service()
|
|
await svc.rate_run(_rate(rating=1))
|
|
retract = RetractRunRating(thread_id="t1", run_id="r1", user_id="u1")
|
|
assert await svc.retract_run_rating(retract) is True
|
|
assert await svc.retract_run_rating(retract) is False
|
|
|
|
@pytest.mark.anyio
|
|
async def test_latest_per_run_in_thread(self):
|
|
svc = _service({"r1": "t1", "r2": "t1"})
|
|
await svc.rate_run(_rate(run_id="r1", rating=1))
|
|
await svc.rate_run(_rate(run_id="r2", rating=-1))
|
|
grouped = await svc.latest_per_run_in_thread("t1", user_id="u1")
|
|
assert {run_id: fb.rating for run_id, fb in grouped.items()} == {"r1": 1, "r2": -1}
|
|
|
|
@pytest.mark.anyio
|
|
async def test_latest_for_runs(self):
|
|
svc = _service({"r1": "t1", "r2": "t1"})
|
|
await svc.rate_run(_rate(run_id="r1", rating=1))
|
|
await svc.rate_run(_rate(run_id="r2", rating=-1))
|
|
grouped = await svc.latest_for_runs("t1", {"r2"}, user_id="u1")
|
|
assert set(grouped) == {"r2"}
|