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>
203 lines
6.3 KiB
Python
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"
|