Files
FabledGrimoire/tests/test_integration_placement.py
bvandeusenandClaude Opus 5.5 0f5ef0be63
CI & Build / Python lint (push) Successful in 3s
CI & Build / Plugin hooks (push) Successful in 12s
CI & Build / TypeScript typecheck (push) Successful in 56s
CI & Build / integration (push) Successful in 1m4s
CI & Build / Python tests (push) Successful in 1m55s
CI & Build / Build & push image (push) Successful in 44s
rename(547): the server is grimoire — package, image, DB defaults, backups
Step 2 of the Scribe -> Grimoire rename.

- src/scribe -> src/grimoire, and every import, patch target, module path,
  hypercorn entry point and `python -m grimoire.restore` with it; pyproject,
  uv.lock and the frontend package are named grimoire.
- Image git.fabledsword.com/bvandeusen/fabledgrimoire; CI's Postgres service
  runs as grimoire / grimoire_test.
- Compose files default the DB user, database and (prod) volume to grimoire,
  each overridable from .env, so an install created as Scribe keeps its data
  by setting POSTGRES_USER, POSTGRES_DB and PGDATA_VOLUME.
- Backups are written as grimoire-<ts>.tar with format grimoire-backup-tar.
  Archives named scribe-* with the scribe-backup-tar manifest still restore
  and are still counted at the destination (folder and S3), with tests.
- Exports, the MCP auth realm, the ASGI scope key and the persistent logger
  carry the new name.

Kept on purpose: the secret-sealing key label stays "scribe.secrets.v1",
because every stored secret was derived under it.

Plugin identity (plugin name, hooks, env vars, .scribe marker, skills) is
step 3.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-22 10:50:45 -04:00

97 lines
4.2 KiB
Python

"""Real-Postgres tests for task placement.
What a mock cannot show: that step order is creation order rather than update
order, that position and progress are computed over the steps the CALLER may
read, and that a collaborator holding one shared task is not shown the titles
of steps they cannot open.
"""
import pytest
import pytest_asyncio
from grimoire.models import async_session
from grimoire.models.note import Note
from grimoire.models.project import Project
from grimoire.models.share import NoteShare, ProjectShare
from grimoire.services import notes as notes_svc
from grimoire.services.placement import task_placement
from grimoire.services.record_batch import BatchItem, BatchMilestone, create_batch
from tests.helpers import ensure_user
pytestmark = [pytest.mark.integration, pytest.mark.usefixtures("_dispose_engine", "_no_embedding")]
@pytest_asyncio.fixture
async def plan():
"""Owner, a collaborator, a project and a four-step milestone."""
async with async_session() as s:
owner = await ensure_user(s, "placement_owner")
other = await ensure_user(s, "placement_collaborator")
project = Project(user_id=owner.id, title="Placement target")
s.add(project)
await s.flush()
ids = {"owner": owner.id, "other": other.id, "pid": project.id}
await s.commit()
ms, steps = await create_batch(ids["owner"], [
BatchItem(title="Step 1", status="done"),
BatchItem(title="Step 2", status="in_progress"),
BatchItem(title="Step 3"),
BatchItem(title="Step 4"),
], project_id=ids["pid"], milestone=BatchMilestone(title="The plan", body="b"))
ids.update({"mid": ms.id, "steps": steps})
return ids
async def test_the_owner_sees_the_whole_placement(plan):
step2 = plan["steps"][1]
out = await task_placement(plan["owner"], step2)
assert out["project"] == {"id": plan["pid"], "title": "Placement target"}
assert out["milestone"] == {"id": plan["mid"], "title": "The plan", "status": "active"}
assert out["position"] == {"step": 2, "of": 4}
assert out["progress"] == {"completed": 1, "total": 4, "pct": 25.0}
assert out["next"] == {"id": plan["steps"][2].id, "title": "Step 3", "status": "todo"}
async def test_step_order_is_creation_order_not_last_update(plan):
"""Touching step 1 must not move it to the end of the list."""
first = plan["steps"][0]
updated = await notes_svc.update_note(plan["owner"], first.id, body="edited later")
out = await task_placement(plan["owner"], updated)
assert out["position"] == {"step": 1, "of": 4}
async def test_the_last_step_points_back_at_the_earliest_open_one(plan):
last = await notes_svc.update_note(plan["owner"], plan["steps"][3].id, status="done")
out = await task_placement(plan["owner"], last)
assert out["next"]["title"] == "Step 2"
assert out["progress"]["completed"] == 2
async def test_a_task_outside_any_milestone_gets_its_project_alone(plan):
loose = await notes_svc.create_note(plan["owner"], title="Loose task", status="todo",
project_id=plan["pid"])
out = await task_placement(plan["owner"], loose)
assert out == {"project": {"id": plan["pid"], "title": "Placement target"}}
async def test_a_collaborator_holding_one_shared_task_sees_no_plan(plan):
"""A note share opens one task. It does not open the project, so neither the
milestone nor its other steps' titles may leak through the placement."""
step3 = plan["steps"][2]
async with async_session() as s:
s.add(NoteShare(note_id=step3.id, shared_with_user_id=plan["other"],
permission="editor", invited_by=plan["owner"]))
await s.commit()
assert await task_placement(plan["other"], step3) is None
async def test_a_project_collaborator_sees_the_plan(plan):
async with async_session() as s:
s.add(ProjectShare(project_id=plan["pid"], shared_with_user_id=plan["other"],
permission="viewer", invited_by=plan["owner"]))
await s.commit()
step3 = await s.get(Note, plan["steps"][2].id)
out = await task_placement(plan["other"], step3)
assert out["milestone"]["title"] == "The plan"
assert out["position"] == {"step": 3, "of": 4}
assert out["next"]["title"] == "Step 4"