feat(entities): inbox + cross-project (project="") support (Issue C)
Makes `inbox` a reserved pseudo-project and `project=""` a first-class
cross-project bucket. Unblocks AKC capturing pre-project leads/quotes
and cross-project facts (materials, vendors) that don't fit a single
registered project.
- projects/registry.py: INBOX_PROJECT/GLOBAL_PROJECT constants,
is_reserved_project(), register/update guards, resolve_project_name
passthrough for "inbox"
- engineering/service.py: get_entities scoping rules (inbox-only,
global-only, real+global default, scope_only=true strict).
promote_entity accepts target_project to retarget on promote
- api/routes.py: GET /entities gains scope_only; POST /entities accepts
project=null as ""; POST /entities/{id}/promote accepts
{target_project, note}
- engineering/wiki.py: homepage shows "Inbox & Global" cards with live
counts linking to scoped lists
- tests/test_inbox_crossproject.py: 15 tests (reserved enforcement,
scoping rules, API shape, promote retargeting)
- DEV-LEDGER.md: session log, test_count 463 -> 478
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -9,7 +9,7 @@
|
||||
- **live_sha** (Dalidou `/health` build_sha): `775960c` (verified 2026-04-16 via /health, build_time 2026-04-16T17:59:30Z)
|
||||
- **last_updated**: 2026-04-18 by Claude (Phase 7A — Memory Consolidation "sleep cycle" V1 on branch, not yet deployed)
|
||||
- **main_tip**: `999788b`
|
||||
- **test_count**: 463 (prior 459 + 5 new /v1 alias tests; `main` not yet advanced past the alias commit at session end)
|
||||
- **test_count**: 478 (prior 463 + 15 new Issue C tests)
|
||||
- **harness**: `17/18 PASS` on live Dalidou (p04-constraints expects "Zerodur" — retrieval content gap, not regression)
|
||||
- **vectors**: 33,253
|
||||
- **active_memories**: 84 (31 project, 23 knowledge, 10 episodic, 8 adaptation, 7 preference, 5 identity)
|
||||
@@ -160,6 +160,8 @@ One branch `codex/extractor-eval-loop` for Day 1-5, a second `codex/retrieval-ha
|
||||
|
||||
## Session Log
|
||||
|
||||
- **2026-04-21 Claude (pm)** Issue C (inbox + cross-project entities) landed. `inbox` is a reserved pseudo-project: auto-exists, cannot be registered/updated/aliased (enforced in `src/atocore/projects/registry.py` via `is_reserved_project` + `register_project`/`update_project` guards). `project=""` remains the cross-project/global bucket for facts that apply to every project. `resolve_project_name("inbox")` is stable and does not hit the registry. `get_entities` now scopes: `project=""` → only globals; `project="inbox"` → only inbox; `project="<real>"` default → that project plus globals; `scope_only=true` → strict. `POST /entities` accepts `project=null` as equivalent to `""`. `POST /entities/{id}/promote` accepts `{target_project}` to retarget an inbox/global lead into a real project on promote (new "retargeted" audit action). Wiki homepage shows a new "📥 Inbox & Global" section with live counts, linking to scoped `/entities` lists. 15 new tests in `test_inbox_crossproject.py` cover reserved-name enforcement, scoping rules, API shape, and promote retargeting. Tests 463 → 478. Pending: commit, push, deploy. Issue B (wiki redlinks) deferred per AKC thread — P1 cosmetic, not a blocker.
|
||||
|
||||
- **2026-04-21 Claude** Issue A (API versioning) landed on `main` working tree (not yet committed/deployed). `src/atocore/main.py` now mounts a second `/v1` router that re-registers an explicit allowlist of public handlers (`_V1_PUBLIC_PATHS`) against the same endpoint functions — entities, relationships, ingest, context/build, query, projects, memory, interactions, project/state, health, sources, stats, and their sub-paths. Unversioned paths are untouched; OpenClaw and hooks keep working. Added `tests/test_v1_aliases.py` (5 tests: health parity, projects parity, entities reachable, v1 paths present in OpenAPI, unversioned paths still present in OpenAPI) and a "API versioning" section in the README documenting the rule (new endpoints at latest prefix, breaking changes bump prefix, unversioned retained for internal callers). Tests 459 → 463. Next: commit + deploy, then relay to the AKC thread so Phase 2 can code against `/v1`. Issues B (wiki redlinks) and C (inbox/cross-project) remain open, unstarted.
|
||||
|
||||
- **2026-04-19 Claude** Shipped Phases 7A.1 (tiered auto-merge), 7C (tag canonicalization), 7D (confidence decay), 7I (OpenClaw context injection), UI refresh (memory/domain/activity pages + topnav), and closed the Claude Code retrieval asymmetry. Builds deployed: `028d4c3` → `56d5df0` → `e840ef4` → `877b97e` → `6e43cc7` → `9c91d77`. New capture-surface scope: Claude Code (Stop + UserPromptSubmit hooks, both installed and verified live) + OpenClaw (v0.2.0 plugin with capture + context injection, verified loaded on T420 gateway). `/wiki/capture` paste form removed from topnav; kept as labeled fallback. Anthropic API polling explicitly out of scope per user. Tests 414 → 459. `docs/capture-surfaces.md` documents the sanctioned scope.
|
||||
|
||||
@@ -1374,7 +1374,11 @@ def api_dashboard() -> dict:
|
||||
class EntityCreateRequest(BaseModel):
|
||||
entity_type: str
|
||||
name: str
|
||||
project: str = ""
|
||||
# project accepts: "" (global, cross-project), "inbox" (pre-project
|
||||
# lead / quote bucket), or any registered project id/alias. Unknown
|
||||
# project names are stored verbatim (trust-preserving, same as
|
||||
# pre-registry contract).
|
||||
project: str | None = ""
|
||||
description: str = ""
|
||||
properties: dict | None = None
|
||||
status: str = "active"
|
||||
@@ -1382,6 +1386,11 @@ class EntityCreateRequest(BaseModel):
|
||||
source_refs: list[str] | None = None
|
||||
|
||||
|
||||
class EntityPromoteRequest(BaseModel):
|
||||
target_project: str | None = None
|
||||
note: str = ""
|
||||
|
||||
|
||||
class RelationshipCreateRequest(BaseModel):
|
||||
source_entity_id: str
|
||||
target_entity_id: str
|
||||
@@ -1397,7 +1406,7 @@ def api_create_entity(req: EntityCreateRequest) -> dict:
|
||||
entity = create_entity(
|
||||
entity_type=req.entity_type,
|
||||
name=req.name,
|
||||
project=req.project,
|
||||
project=req.project or "",
|
||||
description=req.description,
|
||||
properties=req.properties,
|
||||
status=req.status,
|
||||
@@ -1417,14 +1426,21 @@ def api_list_entities(
|
||||
status: str = "active",
|
||||
name_contains: str | None = None,
|
||||
limit: int = 100,
|
||||
scope_only: bool = False,
|
||||
) -> dict:
|
||||
"""List engineering entities with optional filters."""
|
||||
"""List engineering entities with optional filters.
|
||||
|
||||
When ``project`` names a real project, cross-project entities
|
||||
(``project=""``) are included by default. Pass ``scope_only=true`` to
|
||||
restrict the result to that project's own entities only.
|
||||
"""
|
||||
entities = get_entities(
|
||||
entity_type=entity_type,
|
||||
project=project,
|
||||
status=status,
|
||||
name_contains=name_contains,
|
||||
limit=limit,
|
||||
scope_only=scope_only,
|
||||
)
|
||||
return {
|
||||
"entities": [
|
||||
@@ -2078,13 +2094,31 @@ def api_evidence_chain(entity: str) -> dict:
|
||||
|
||||
|
||||
@router.post("/entities/{entity_id}/promote")
|
||||
def api_promote_entity(entity_id: str) -> dict:
|
||||
"""Promote a candidate entity to active (Phase 5 Engineering V1)."""
|
||||
def api_promote_entity(
|
||||
entity_id: str,
|
||||
req: EntityPromoteRequest | None = None,
|
||||
) -> dict:
|
||||
"""Promote a candidate entity to active.
|
||||
|
||||
Optional ``target_project`` in the body retargets the entity's
|
||||
project on promote — used to graduate inbox/global leads into a real
|
||||
project when they mature (Issue C).
|
||||
"""
|
||||
from atocore.engineering.service import promote_entity
|
||||
success = promote_entity(entity_id, actor="api-http")
|
||||
target_project = req.target_project if req is not None else None
|
||||
note = req.note if req is not None else ""
|
||||
success = promote_entity(
|
||||
entity_id,
|
||||
actor="api-http",
|
||||
note=note,
|
||||
target_project=target_project,
|
||||
)
|
||||
if not success:
|
||||
raise HTTPException(status_code=404, detail=f"Entity not found or not a candidate: {entity_id}")
|
||||
return {"status": "promoted", "id": entity_id}
|
||||
result = {"status": "promoted", "id": entity_id}
|
||||
if target_project is not None:
|
||||
result["target_project"] = target_project
|
||||
return result
|
||||
|
||||
|
||||
@router.post("/entities/{entity_id}/reject")
|
||||
|
||||
@@ -333,9 +333,20 @@ def _set_entity_status(
|
||||
return True
|
||||
|
||||
|
||||
def promote_entity(entity_id: str, actor: str = "api", note: str = "") -> bool:
|
||||
def promote_entity(
|
||||
entity_id: str,
|
||||
actor: str = "api",
|
||||
note: str = "",
|
||||
target_project: str | None = None,
|
||||
) -> bool:
|
||||
"""Promote a candidate entity to active.
|
||||
|
||||
When ``target_project`` is provided (Issue C), also retarget the
|
||||
entity's project before flipping the status. Use this to graduate an
|
||||
inbox/global lead into a real project (e.g. when a vendor quote
|
||||
becomes a contract). ``target_project`` is canonicalized through the
|
||||
registry; reserved ids (``inbox``) and ``""`` are accepted verbatim.
|
||||
|
||||
Phase 5F graduation hook: if this entity has source_refs pointing at
|
||||
memories (format "memory:<uuid>"), mark those source memories as
|
||||
``status=graduated`` and set their ``graduated_to_entity_id`` forward
|
||||
@@ -346,6 +357,27 @@ def promote_entity(entity_id: str, actor: str = "api", note: str = "") -> bool:
|
||||
if entity is None or entity.status != "candidate":
|
||||
return False
|
||||
|
||||
if target_project is not None:
|
||||
new_project = (
|
||||
resolve_project_name(target_project) if target_project else ""
|
||||
)
|
||||
if new_project != entity.project:
|
||||
now = datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M:%S")
|
||||
with get_connection() as conn:
|
||||
conn.execute(
|
||||
"UPDATE entities SET project = ?, updated_at = ? "
|
||||
"WHERE id = ?",
|
||||
(new_project, now, entity_id),
|
||||
)
|
||||
_audit_entity(
|
||||
entity_id=entity_id,
|
||||
action="retargeted",
|
||||
actor=actor,
|
||||
before={"project": entity.project},
|
||||
after={"project": new_project},
|
||||
note=note,
|
||||
)
|
||||
|
||||
ok = _set_entity_status(entity_id, "active", actor=actor, note=note)
|
||||
if not ok:
|
||||
return False
|
||||
@@ -470,7 +502,24 @@ def get_entities(
|
||||
status: str = "active",
|
||||
name_contains: str | None = None,
|
||||
limit: int = 100,
|
||||
scope_only: bool = False,
|
||||
) -> list[Entity]:
|
||||
"""List entities with optional filters.
|
||||
|
||||
Project scoping rules (Issue C — inbox + cross-project):
|
||||
|
||||
- ``project=None``: no project filter, return everything matching status.
|
||||
- ``project=""``: return only cross-project (global) entities.
|
||||
- ``project="inbox"``: return only inbox entities.
|
||||
- ``project="<real>"`` and ``scope_only=False`` (default): return entities
|
||||
scoped to that project PLUS cross-project (``project=""``) entities.
|
||||
- ``project="<real>"`` and ``scope_only=True``: return only that project,
|
||||
without the cross-project bleed.
|
||||
"""
|
||||
from atocore.projects.registry import (
|
||||
INBOX_PROJECT, GLOBAL_PROJECT, is_reserved_project,
|
||||
)
|
||||
|
||||
query = "SELECT * FROM entities WHERE status = ?"
|
||||
params: list = [status]
|
||||
|
||||
@@ -478,8 +527,14 @@ def get_entities(
|
||||
query += " AND entity_type = ?"
|
||||
params.append(entity_type)
|
||||
if project is not None:
|
||||
query += " AND project = ?"
|
||||
params.append(project)
|
||||
p = (project or "").strip()
|
||||
if p == GLOBAL_PROJECT or is_reserved_project(p) or scope_only:
|
||||
query += " AND project = ?"
|
||||
params.append(p)
|
||||
else:
|
||||
# Real project — include cross-project entities by default.
|
||||
query += " AND (project = ? OR project = ?)"
|
||||
params.extend([p, GLOBAL_PROJECT])
|
||||
if name_contains:
|
||||
query += " AND name LIKE ?"
|
||||
params.append(f"%{name_contains}%")
|
||||
|
||||
@@ -23,7 +23,11 @@ from atocore.engineering.service import (
|
||||
get_relationships,
|
||||
)
|
||||
from atocore.memory.service import get_memories
|
||||
from atocore.projects.registry import load_project_registry
|
||||
from atocore.projects.registry import (
|
||||
GLOBAL_PROJECT,
|
||||
INBOX_PROJECT,
|
||||
load_project_registry,
|
||||
)
|
||||
|
||||
|
||||
_TOP_NAV_LINKS = [
|
||||
@@ -147,6 +151,43 @@ def render_homepage() -> str:
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Issue C: Inbox + Global pseudo-projects alongside registered projects.
|
||||
# scope_only=True keeps real-project entities out of these counts.
|
||||
try:
|
||||
inbox_count = len(get_entities(
|
||||
project=INBOX_PROJECT, scope_only=True, limit=500,
|
||||
))
|
||||
global_count = len(get_entities(
|
||||
project=GLOBAL_PROJECT, scope_only=True, limit=500,
|
||||
))
|
||||
except Exception:
|
||||
inbox_count = 0
|
||||
global_count = 0
|
||||
|
||||
lines.append('<h2>📥 Inbox & Global</h2>')
|
||||
lines.append(
|
||||
'<p class="emerging-intro">Entities that don\'t belong to a specific '
|
||||
'project yet. <strong>Inbox</strong> holds pre-project leads and quotes. '
|
||||
'<strong>Global</strong> holds cross-project facts (material properties, '
|
||||
'vendor capabilities) that apply everywhere.</p>'
|
||||
)
|
||||
lines.append('<div class="card-grid">')
|
||||
lines.append(
|
||||
f'<a href="/entities?project=inbox&scope_only=true" class="card">'
|
||||
f'<h3>📥 Inbox</h3>'
|
||||
f'<p>Pre-project leads, quotes, early conversations.</p>'
|
||||
f'<div class="stats">{inbox_count} entities</div>'
|
||||
f'</a>'
|
||||
)
|
||||
lines.append(
|
||||
f'<a href="/entities?project=&scope_only=true" class="card">'
|
||||
f'<h3>🌐 Global</h3>'
|
||||
f'<p>Cross-project facts: materials, vendors, shared knowledge.</p>'
|
||||
f'<div class="stats">{global_count} entities</div>'
|
||||
f'</a>'
|
||||
)
|
||||
lines.append('</div>')
|
||||
|
||||
for bucket_name, items in buckets.items():
|
||||
if not items:
|
||||
continue
|
||||
|
||||
@@ -11,6 +11,20 @@ import atocore.config as _config
|
||||
from atocore.ingestion.pipeline import ingest_folder
|
||||
|
||||
|
||||
# Reserved pseudo-projects. `inbox` holds pre-project / lead / quote
|
||||
# entities that don't yet belong to a real project. `""` (empty) is the
|
||||
# cross-project bucket for facts that apply to every project (material
|
||||
# properties, vendor capabilities). Neither may be registered, renamed,
|
||||
# or deleted via the normal registry CRUD.
|
||||
INBOX_PROJECT = "inbox"
|
||||
GLOBAL_PROJECT = ""
|
||||
_RESERVED_PROJECT_IDS = {INBOX_PROJECT}
|
||||
|
||||
|
||||
def is_reserved_project(name: str) -> bool:
|
||||
return (name or "").strip().lower() in _RESERVED_PROJECT_IDS
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ProjectSourceRef:
|
||||
source: str
|
||||
@@ -56,8 +70,17 @@ def build_project_registration_proposal(
|
||||
normalized_id = project_id.strip()
|
||||
if not normalized_id:
|
||||
raise ValueError("Project id must be non-empty")
|
||||
if is_reserved_project(normalized_id):
|
||||
raise ValueError(
|
||||
f"Project id {normalized_id!r} is reserved and cannot be registered"
|
||||
)
|
||||
|
||||
normalized_aliases = _normalize_aliases(aliases or [])
|
||||
for alias in normalized_aliases:
|
||||
if is_reserved_project(alias):
|
||||
raise ValueError(
|
||||
f"Alias {alias!r} is reserved and cannot be used"
|
||||
)
|
||||
normalized_roots = _normalize_ingest_roots(ingest_roots or [])
|
||||
if not normalized_roots:
|
||||
raise ValueError("At least one ingest root is required")
|
||||
@@ -129,6 +152,10 @@ def update_project(
|
||||
ingest_roots: list[dict] | tuple[dict, ...] | None = None,
|
||||
) -> dict:
|
||||
"""Update an existing project registration in the registry file."""
|
||||
if is_reserved_project(project_name):
|
||||
raise ValueError(
|
||||
f"Project {project_name!r} is reserved and cannot be modified"
|
||||
)
|
||||
existing = get_registered_project(project_name)
|
||||
if existing is None:
|
||||
raise ValueError(f"Unknown project: {project_name}")
|
||||
@@ -272,6 +299,8 @@ def resolve_project_name(name: str | None) -> str:
|
||||
"""
|
||||
if not name:
|
||||
return name or ""
|
||||
if is_reserved_project(name):
|
||||
return name.strip().lower()
|
||||
project = get_registered_project(name)
|
||||
if project is not None:
|
||||
return project.project_id
|
||||
|
||||
201
tests/test_inbox_crossproject.py
Normal file
201
tests/test_inbox_crossproject.py
Normal file
@@ -0,0 +1,201 @@
|
||||
"""Issue C — inbox pseudo-project + cross-project (project="") entities."""
|
||||
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from atocore.engineering.service import (
|
||||
create_entity,
|
||||
get_entities,
|
||||
init_engineering_schema,
|
||||
promote_entity,
|
||||
)
|
||||
from atocore.main import app
|
||||
from atocore.projects.registry import (
|
||||
GLOBAL_PROJECT,
|
||||
INBOX_PROJECT,
|
||||
is_reserved_project,
|
||||
register_project,
|
||||
resolve_project_name,
|
||||
update_project,
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def seeded_db(tmp_data_dir, tmp_path, monkeypatch):
|
||||
# Isolate the project registry so "p05" etc. don't canonicalize
|
||||
# to aliases inherited from the host registry.
|
||||
registry_path = tmp_path / "test-registry.json"
|
||||
registry_path.write_text('{"projects": []}', encoding="utf-8")
|
||||
monkeypatch.setenv("ATOCORE_PROJECT_REGISTRY_PATH", str(registry_path))
|
||||
from atocore import config
|
||||
config.settings = config.Settings()
|
||||
|
||||
init_engineering_schema()
|
||||
# Audit table lives in the memory schema — bring it up so audit rows
|
||||
# don't spam warnings during retargeting tests.
|
||||
from atocore.models.database import init_db
|
||||
init_db()
|
||||
yield tmp_data_dir
|
||||
|
||||
|
||||
def test_inbox_is_reserved():
|
||||
assert is_reserved_project("inbox") is True
|
||||
assert is_reserved_project("INBOX") is True
|
||||
assert is_reserved_project("p05-interferometer") is False
|
||||
assert is_reserved_project("") is False
|
||||
|
||||
|
||||
def test_resolve_project_name_preserves_inbox():
|
||||
assert resolve_project_name("inbox") == "inbox"
|
||||
assert resolve_project_name("INBOX") == "inbox"
|
||||
assert resolve_project_name("") == ""
|
||||
|
||||
|
||||
def test_cannot_register_inbox(tmp_path, monkeypatch):
|
||||
monkeypatch.setenv(
|
||||
"ATOCORE_PROJECT_REGISTRY_PATH",
|
||||
str(tmp_path / "registry.json"),
|
||||
)
|
||||
from atocore import config
|
||||
config.settings = config.Settings()
|
||||
|
||||
with pytest.raises(ValueError, match="reserved"):
|
||||
register_project(
|
||||
project_id="inbox",
|
||||
ingest_roots=[{"source": "vault", "subpath": "incoming/inbox"}],
|
||||
)
|
||||
|
||||
|
||||
def test_cannot_update_inbox(tmp_path, monkeypatch):
|
||||
monkeypatch.setenv(
|
||||
"ATOCORE_PROJECT_REGISTRY_PATH",
|
||||
str(tmp_path / "registry.json"),
|
||||
)
|
||||
from atocore import config
|
||||
config.settings = config.Settings()
|
||||
|
||||
with pytest.raises(ValueError, match="reserved"):
|
||||
update_project(project_name="inbox", description="hijack attempt")
|
||||
|
||||
|
||||
def test_create_entity_with_empty_project_is_global(seeded_db):
|
||||
e = create_entity(entity_type="material", name="Invar", project="")
|
||||
assert e.project == ""
|
||||
|
||||
|
||||
def test_create_entity_in_inbox(seeded_db):
|
||||
e = create_entity(entity_type="vendor", name="Zygo", project="inbox")
|
||||
assert e.project == "inbox"
|
||||
|
||||
|
||||
def test_get_entities_inbox_scope(seeded_db):
|
||||
create_entity(entity_type="vendor", name="Zygo", project="inbox")
|
||||
create_entity(entity_type="material", name="Invar", project="")
|
||||
create_entity(entity_type="component", name="Mirror", project="p05")
|
||||
|
||||
inbox = get_entities(project=INBOX_PROJECT, scope_only=True)
|
||||
assert {e.name for e in inbox} == {"Zygo"}
|
||||
|
||||
|
||||
def test_get_entities_global_scope(seeded_db):
|
||||
create_entity(entity_type="vendor", name="Zygo", project="inbox")
|
||||
create_entity(entity_type="material", name="Invar", project="")
|
||||
create_entity(entity_type="component", name="Mirror", project="p05")
|
||||
|
||||
globals_ = get_entities(project=GLOBAL_PROJECT, scope_only=True)
|
||||
assert {e.name for e in globals_} == {"Invar"}
|
||||
|
||||
|
||||
def test_real_project_includes_global_by_default(seeded_db):
|
||||
create_entity(entity_type="material", name="Invar", project="")
|
||||
create_entity(entity_type="component", name="Mirror", project="p05")
|
||||
create_entity(entity_type="component", name="Other", project="p06")
|
||||
|
||||
p05 = get_entities(project="p05")
|
||||
names = {e.name for e in p05}
|
||||
assert "Mirror" in names
|
||||
assert "Invar" in names, "cross-project material should bleed in by default"
|
||||
assert "Other" not in names
|
||||
|
||||
|
||||
def test_real_project_scope_only_excludes_global(seeded_db):
|
||||
create_entity(entity_type="material", name="Invar", project="")
|
||||
create_entity(entity_type="component", name="Mirror", project="p05")
|
||||
|
||||
p05 = get_entities(project="p05", scope_only=True)
|
||||
assert {e.name for e in p05} == {"Mirror"}
|
||||
|
||||
|
||||
def test_api_post_entity_with_null_project_stores_global(seeded_db):
|
||||
client = TestClient(app)
|
||||
r = client.post("/entities", json={
|
||||
"entity_type": "material",
|
||||
"name": "Titanium",
|
||||
"project": None,
|
||||
})
|
||||
assert r.status_code == 200
|
||||
|
||||
globals_ = get_entities(project=GLOBAL_PROJECT, scope_only=True)
|
||||
assert any(e.name == "Titanium" for e in globals_)
|
||||
|
||||
|
||||
def test_api_get_entities_scope_only(seeded_db):
|
||||
create_entity(entity_type="material", name="Invar", project="")
|
||||
create_entity(entity_type="component", name="Mirror", project="p05")
|
||||
|
||||
client = TestClient(app)
|
||||
mixed = client.get("/entities?project=p05").json()
|
||||
scoped = client.get("/entities?project=p05&scope_only=true").json()
|
||||
|
||||
assert mixed["count"] == 2
|
||||
assert scoped["count"] == 1
|
||||
|
||||
|
||||
def test_promote_with_target_project_retargets(seeded_db):
|
||||
e = create_entity(
|
||||
entity_type="vendor",
|
||||
name="ZygoLead",
|
||||
project="inbox",
|
||||
status="candidate",
|
||||
)
|
||||
ok = promote_entity(e.id, target_project="p05")
|
||||
assert ok is True
|
||||
|
||||
from atocore.engineering.service import get_entity
|
||||
promoted = get_entity(e.id)
|
||||
assert promoted.status == "active"
|
||||
assert promoted.project == "p05"
|
||||
|
||||
|
||||
def test_promote_without_target_project_keeps_project(seeded_db):
|
||||
e = create_entity(
|
||||
entity_type="vendor",
|
||||
name="ZygoStay",
|
||||
project="inbox",
|
||||
status="candidate",
|
||||
)
|
||||
ok = promote_entity(e.id)
|
||||
assert ok is True
|
||||
|
||||
from atocore.engineering.service import get_entity
|
||||
promoted = get_entity(e.id)
|
||||
assert promoted.status == "active"
|
||||
assert promoted.project == "inbox"
|
||||
|
||||
|
||||
def test_api_promote_with_target_project(seeded_db):
|
||||
e = create_entity(
|
||||
entity_type="vendor",
|
||||
name="ZygoApi",
|
||||
project="inbox",
|
||||
status="candidate",
|
||||
)
|
||||
client = TestClient(app)
|
||||
r = client.post(
|
||||
f"/entities/{e.id}/promote",
|
||||
json={"target_project": "p05"},
|
||||
)
|
||||
assert r.status_code == 200
|
||||
body = r.json()
|
||||
assert body["status"] == "promoted"
|
||||
assert body["target_project"] == "p05"
|
||||
Reference in New Issue
Block a user