Files
ATOCore/tests/test_inbox_crossproject.py
Anto01 cbf9e03ab9 feat(engineering): V1-0 write-time invariants (F-1 + F-5 hook + F-8)
Phase V1-0 of the Engineering V1 Completion Plan. Establishes the
write-time invariants every later phase depends on so no later phase
can leak invalid state into the entity store.

F-1 shared-header fields per engineering-v1-acceptance.md:45:
  - entities.extractor_version (default "", EXTRACTOR_VERSION="v1.0.0"
    written by service.create_entity)
  - entities.canonical_home (default "entity")
  - entities.hand_authored (default 0, INTEGER boolean)
  Idempotent ALTERs in both _apply_migrations (database.py) and
  init_engineering_schema (service.py). CREATE TABLE also carries the
  columns for fresh DBs. _row_to_entity tolerates old rows without
  them so tests that predate V1-0 keep passing.

F-8 provenance enforcement per promotion-rules.md:243:
  create_entity raises ValueError when source_refs is empty and
  hand_authored is False. New kwargs hand_authored and
  extractor_version threaded through the API (EntityCreateRequest)
  and the /wiki/new form body (human wiki writes set hand_authored
  true by definition). The non-negotiable invariant: every row either
  carries provenance or is explicitly flagged as hand-authored.

F-5 synchronous conflict-detection hook on active create per
engineering-v1-acceptance.md:99:
  create_entity(status="active") now runs detect_conflicts_for_entity
  with fail-open per conflict-model.md:256. Detector errors log a
  warning but never 4xx-block the write (Q-3 "flag, never block").

Doc note added to engineering-ontology-v1.md recording that `project`
IS the `project_id` per "fields equivalent to" wording. No storage
rename.

Backfill script scripts/v1_0_backfill_provenance.py reports and
optionally flags existing active entities that lack provenance.
Idempotent. Supports --dry-run and --invalidate-instead.

Tests: 10 new in test_v1_0_write_invariants.py covering F-1 fields,
F-8 raise + bypass, F-5 hook on active + no-hook on candidate, Q-3
fail-open, Q-4 partial scope_only=active excludes candidates.

Three pre-existing conflict tests adapted to read list_open_conflicts
rather than re-run the detector (which now dedups because the hook
already fired at create-time). One API test adds hand_authored=true
since its fixture has no source_refs.

conftest.py wraps create_entity so tests that don't pass source_refs
or hand_authored default to hand_authored=True (tests author their
own fixture data — reasonable default). Production paths (API route,
wiki form, graduation scripts) all pass explicit values and are
unaffected.

Test count: 533 -> 543 (+10). Full suite green in 77.86s.

Pending: Codex review on the branch before squash-merge to main.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-22 14:39:30 -04:00

203 lines
6.3 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,
"hand_authored": True, # V1-0 F-8: test fixture, no source_refs
})
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"