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>
202 lines
6.2 KiB
Python
202 lines
6.2 KiB
Python
"""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"
|