Files
sfera/services/api-server/tests/test_api.py
T
m 1da745c52e
CI / python (push) Has been cancelled
CI / rust (push) Has been cancelled
Add HTML5 object UI context
2026-05-17 00:07:30 +03:00

4488 lines
185 KiB
Python

from pathlib import Path
from uuid import uuid4
import time
import zipfile
from fastapi.testclient import TestClient
from api_server import main
from api_server.main import app
from one_c_normalizer import ConfigurationRoot, MetadataGroup, MetadataObject, Module, NormalizedProject
def create_authoring_session(client: TestClient, project_id: str, task_id: str, session_id: str, user_id: str = "dev.ivan") -> None:
user = client.post("/collaboration/users", json={"user_id": user_id, "display_name": user_id})
assert user.status_code == 200
grant = client.post(f"/security/users/{user_id}/roles/developer")
assert grant.status_code == 200
task = client.post(
"/collaboration/tasks",
json={"task_id": task_id, "project_id": project_id, "title": f"Authoring {task_id}", "assignee_user_id": user_id},
)
assert task.status_code == 200
session = client.post(
"/collaboration/sessions",
json={"session": {"session_id": session_id, "task_id": task_id, "user_id": user_id}},
)
assert session.status_code == 200
def test_cors_allows_lan_panel_origin():
client = TestClient(app)
response = client.options(
"/health",
headers={
"Origin": "http://192.168.200.60:3000",
"Access-Control-Request-Method": "GET",
},
)
assert response.status_code == 200
assert response.headers["access-control-allow-origin"] == "http://192.168.200.60:3000"
def test_metadata_catalog_exposes_1c_tree_structure():
client = TestClient(app)
response = client.get("/metadata/catalog")
assert response.status_code == 200
payload = response.json()
assert "8.3" in payload["platform_family"]
assert "Общие модули" in payload["common_branch_children"]
document = next(item for item in payload["types"] if item["code"] == "DOCUMENT")
common_module = next(item for item in payload["types"] if item["code"] == "COMMON_MODULE")
accumulation_register = next(item for item in payload["types"] if item["code"] == "ACCUMULATION_REGISTER")
http_service = next(item for item in payload["types"] if item["code"] == "HTTP_SERVICE")
report = next(item for item in payload["types"] if item["code"] == "REPORT")
catalog = next(item for item in payload["types"] if item["code"] == "CATALOG")
url_template = next(item for item in payload["child_object_types"] if item["code"] == "URL_TEMPLATE")
data_composition_schema = next(item for item in payload["child_object_types"] if item["code"] == "DATA_COMPOSITION_SCHEMA")
assert document["tree_branch"] == "Документы"
assert "Реквизиты" in document["child_groups"]
assert "Табличные части" in document["child_groups"]
assert "Движения" in document["child_groups"]
assert "Проведение" in document["properties"]
assert "Открыть модуль объекта" in document["context_actions"]
assert common_module["tree_branch"] == "Общие модули"
assert "Экспортные методы" in common_module["child_groups"]
assert "Клиент" in common_module["properties"]
assert "Найти вызовы" in common_module["context_actions"]
assert accumulation_register["module_kinds"] == ["Модуль набора записей", "Модуль менеджера"]
assert "Ресурсы" in accumulation_register["child_groups"]
assert "Показать чтение/запись" in accumulation_register["context_actions"]
assert http_service["child_groups"] == ["Шаблоны URL", "Методы", "Модуль"]
assert "HTTP-методы" in http_service["properties"]
assert "Показать URL-шаблоны" in http_service["context_actions"]
assert "СКД" in report["child_groups"]
assert "Табличные документы" in report["child_groups"]
assert "Варианты отчета" in report["child_groups"]
assert "Настройки" in report["child_groups"]
assert "Справочник" in catalog["description"]
assert "HTTP-сервис" in http_service["description"]
assert "Шаблоны URL" in url_template["parent_groups"]
assert data_composition_schema["parent_groups"] == ["СКД"]
def test_html5_server_rendered_project_editor(tmp_path: Path):
client = TestClient(app)
project_id = f"html5-editor-{uuid4()}"
module = tmp_path / "demo_module.bsl"
module.write_text(
"Процедура Проверить()\n"
" Сообщить(\"HTML5\");\n"
"КонецПроцедуры\n",
encoding="utf-8",
)
indexed = client.post("/projects/index", json={"path": str(tmp_path), "project_id": project_id})
assert indexed.status_code == 200
index = client.get("/html5")
assert index.status_code == 200
assert "text/html" in index.headers["content-type"]
assert 'data-html5-page="projects"' in index.text
assert project_id in index.text
assert "__next" not in index.text
editor = client.get(f"/html5/projects/{project_id}/editor", params={"q": "Проверить"})
assert editor.status_code == 200
assert 'data-html5-page="editor"' in editor.text
assert "data-html5-editor" in editor.text
assert "data-html5-symbol-results" in editor.text
assert "data-html5-symbol-detail" in editor.text
assert "data-html5-project-report" in editor.text
assert f'hx-get="/html5/projects/{project_id}/report"' in editor.text
assert "data-html5-review" in editor.text
assert f'hx-get="/html5/projects/{project_id}/review"' in editor.text
assert "data-html5-authoring-preview" in editor.text
assert "data-html5-authoring-preview-form" in editor.text
assert f'hx-post="/html5/projects/{project_id}/authoring/completion-preview"' in editor.text
assert "data-html5-authoring-diff-form" in editor.text
assert f'hx-post="/html5/projects/{project_id}/authoring/semantic-diff-preview"' in editor.text
assert "data-html5-metadata-authoring" in editor.text
assert "data-html5-metadata-preview-form" in editor.text
assert f'hx-post="/html5/projects/{project_id}/authoring/metadata-object-preview"' in editor.text
assert "data-html5-authoring-changes" in editor.text
assert f'hx-get="/html5/projects/{project_id}/authoring/changes"' in editor.text
assert 'hx-get="/html5/projects/' in editor.text
assert 'hx-target="[data-html5-symbol-results]"' in editor.text
assert 'hx-target="[data-html5-symbol-detail]"' in editor.text
assert 'hx-target="[data-html5-source]"' in editor.text
assert 'hx-swap="outerHTML"' in editor.text
assert "hx-ext=\"sse\"" in editor.text
assert f"sse-connect=\"/html5/projects/{project_id}/events\"" in editor.text
assert "htmx.org" in editor.text
assert "htmx-ext-sse" in editor.text
assert "client-js: htmx+sse only" in editor.text
assert "Проверить" in editor.text
assert "__next" not in editor.text
with client.stream("GET", f"/html5/projects/{project_id}/events?once=1") as events:
first_chunk = next(events.iter_text())
assert "event: status" in first_chunk
assert "data:" in first_chunk
assert project_id in first_chunk
symbols = client.get(f"/html5/projects/{project_id}/symbols", params={"q": "Проверить"})
assert symbols.status_code == 200
assert "text/html" in symbols.headers["content-type"]
assert 'data-html5-symbol' in symbols.text
assert 'data-html5-lineage-id' in symbols.text
assert 'hx-target="[data-html5-symbol-detail]"' in symbols.text
assert "Проверить" in symbols.text
assert "<html" not in symbols.text
snapshot = client.get(f"/projects/{project_id}/snapshot/export").json()
module_node = next(node for node in snapshot["nodes"] if node["kind"] == "MODULE")
procedure_node = next(node for node in snapshot["nodes"] if node["kind"] == "PROCEDURE")
source = client.get(f"/html5/projects/{project_id}/source/{module_node['lineage_id']}")
assert source.status_code == 200
assert "text/html" in source.headers["content-type"]
assert "data-html5-source" in source.text
assert "data-html5-source-name" in source.text
assert "data-html5-lineage-id" in source.text
assert "Lines" in source.text
assert "Проверить" in source.text
assert "<html" not in source.text
detail = client.get(f"/html5/projects/{project_id}/symbols/{procedure_node['lineage_id']}/detail")
assert detail.status_code == 200
assert "text/html" in detail.headers["content-type"]
assert "data-html5-symbol-detail" in detail.text
assert "Проверить" in detail.text
assert "<html" not in detail.text
report = client.get(f"/html5/projects/{project_id}/report")
assert report.status_code == 200
assert "text/html" in report.headers["content-type"]
assert "data-html5-project-report" in report.text
assert "Objects" in report.text
assert "<html" not in report.text
review = client.get(f"/html5/projects/{project_id}/review")
assert review.status_code == 200
assert "text/html" in review.headers["content-type"]
assert "data-html5-review" in review.text
assert "<html" not in review.text
authoring = client.get(f"/html5/projects/{project_id}/authoring/changes")
assert authoring.status_code == 200
assert "text/html" in authoring.headers["content-type"]
assert "data-html5-authoring-changes" in authoring.text
assert "data-html5-authoring-detail" in authoring.text
assert "Изменений пока нет" in authoring.text
assert "<html" not in authoring.text
def test_html5_project_index_creates_project_with_fragment():
client = TestClient(app)
project_id = f"html5-created-{uuid4()}"
index = client.get("/html5")
assert index.status_code == 200
assert 'data-html5-project-create' in index.text
assert 'data-html5-projects-body' in index.text
assert 'hx-post="/html5/projects"' in index.text
assert 'href="/html5/operations"' in index.text
created = client.post("/html5/projects", data={"project_id": project_id, "name": "HTML5 Created"})
assert created.status_code == 200
assert "text/html" in created.headers["content-type"]
assert f'data-html5-project="{project_id}"' in created.text
assert "HTML5 Created" in created.text
assert f"/html5/projects/{project_id}/setup" in created.text
assert f'hx-post="/html5/projects/{project_id}/delete"' in created.text
assert "<html" not in created.text
setup = client.get(f"/html5/projects/{project_id}/setup")
assert setup.status_code == 200
assert "HTML5 Created" in setup.text
deleted = client.post(f"/html5/projects/{project_id}/delete", data={"confirmation": project_id})
assert deleted.status_code == 200
assert "text/html" in deleted.headers["content-type"]
assert f'data-html5-project="{project_id}"' not in deleted.text
assert "<html" not in deleted.text
deleted_setup = client.get(f"/projects/{project_id}/setup")
assert deleted_setup.status_code == 200
assert deleted_setup.json()["status"] == "NOT_CONFIGURED"
def test_html5_object_context_fragment(tmp_path: Path):
project_id = f"html5-object-context-{uuid4()}"
(tmp_path / "metadata.xml").write_text(
"""
<Configuration>
<Document name="ЗаказПокупателя" qualifiedName="Документ.ЗаказПокупателя">
<Attribute name="Контрагент" qualifiedName="Документ.ЗаказПокупателя.Контрагент" />
<TabularSection name="Товары" qualifiedName="Документ.ЗаказПокупателя.Товары">
<Attribute name="Количество" qualifiedName="Документ.ЗаказПокупателя.Товары.Количество" />
</TabularSection>
<Form name="ФормаДокумента" qualifiedName="Документ.ЗаказПокупателя.ФормаДокумента">
<Command name="Провести" qualifiedName="Документ.ЗаказПокупателя.ФормаДокумента.Провести" action="ПровестиКоманда" />
</Form>
</Document>
<Role name="Менеджер" qualifiedName="Роль.Менеджер">
<Right object="Документ.ЗаказПокупателя" read="true" write="true" post="true" />
</Role>
</Configuration>
""",
encoding="utf-8",
)
module = tmp_path / "Documents" / "ЗаказПокупателя" / "Ext" / "ObjectModule.bsl"
module.parent.mkdir(parents=True)
module.write_text("Процедура ПровестиКоманда()\nКонецПроцедуры\n", encoding="utf-8")
client = TestClient(app)
indexed = client.post("/projects/index", json={"path": str(tmp_path), "project_id": project_id})
assert indexed.status_code == 200
editor = client.get(f"/html5/projects/{project_id}/editor")
assert editor.status_code == 200
assert "data-html5-object-context" in editor.text
assert f'hx-get="/html5/projects/{project_id}/objects/context/%D0%94%D0%BE%D0%BA%D1%83%D0%BC%D0%B5%D0%BD%D1%82.%D0%97%D0%B0%D0%BA%D0%B0%D0%B7%D0%9F%D0%BE%D0%BA%D1%83%D0%BF%D0%B0%D1%82%D0%B5%D0%BB%D1%8F"' in editor.text
assert 'hx-target="[data-html5-object-context]"' in editor.text
context = client.get(f"/html5/projects/{project_id}/objects/context/Документ.ЗаказПокупателя")
assert context.status_code == 200
assert "text/html" in context.headers["content-type"]
assert "data-html5-object-context" in context.text
assert "Документ.ЗаказПокупателя" in context.text
assert "Контрагент" in context.text
assert "Товары" in context.text
assert "ФормаДокумента" in context.text
assert "Провести" in context.text
assert "ПровестиКоманда" in context.text
assert "Роль.Менеджер" in context.text
assert "read, write, post" in context.text or "post, read, write" in context.text
assert "<html" not in context.text
def test_html5_project_setup_renders_server_fragments():
client = TestClient(app)
project_id = f"html5-setup-{uuid4()}"
saved = client.post(
f"/projects/{project_id}/settings",
json={
"name": "HTML5 Setup Demo",
"structure_source": "XML_DUMP",
"platform_version": "8.3.24",
"compatibility_mode": "8.3.20",
},
)
assert saved.status_code == 200
imported = client.post(
f"/projects/{project_id}/imports/XML_DUMP",
json={
"source": "XML_DUMP",
"metadata": {"platform_version": "8.3.24", "compatibility_mode": "8.3.20"},
},
)
assert imported.status_code == 200
setup = client.get(f"/html5/projects/{project_id}/setup")
assert setup.status_code == 200
assert "text/html" in setup.headers["content-type"]
assert 'data-html5-page="setup"' in setup.text
assert "HTML5 Setup Demo" in setup.text
assert "data-html5-settings-panel" in setup.text
assert "data-html5-setup-summary" in setup.text
assert f'hx-get="/html5/projects/{project_id}/setup/summary"' in setup.text
assert f'hx-post="/html5/projects/{project_id}/setup/settings"' in setup.text
assert f'hx-post="/html5/projects/{project_id}/setup/source"' in setup.text
assert f'hx-post="/html5/projects/{project_id}/setup/check"' in setup.text
assert f'hx-post="/html5/projects/{project_id}/setup/import-job"' in setup.text
assert f'hx-post="/html5/projects/{project_id}/setup/import"' in setup.text
assert f'hx-post="/html5/projects/{project_id}/setup/reindex"' in setup.text
assert "data-html5-import-check" in setup.text
assert "data-html5-import-job" in setup.text
assert "XML_DUMP" in setup.text
assert "__next" not in setup.text
settings = client.post(
f"/html5/projects/{project_id}/setup/settings",
data={
"name": "HTML5 Renamed",
"platform_version": "8.3.25",
"compatibility_mode": "8.3.21",
},
)
assert settings.status_code == 200
assert "data-html5-settings-panel" in settings.text
assert "HTML5 Renamed" in settings.text
assert "8.3.25" in settings.text
assert "Сохранено" in settings.text
assert "<html" not in settings.text
saved_setup = client.get(f"/projects/{project_id}/setup").json()
assert saved_setup["settings"]["name"] == "HTML5 Renamed"
assert saved_setup["settings"]["platform_version"] == "8.3.25"
assert saved_setup["settings"]["compatibility_mode"] == "8.3.21"
source = client.post(f"/html5/projects/{project_id}/setup/source", data={"source": "EDT_PROJECT"})
assert source.status_code == 200
assert "data-html5-setup-summary" in source.text
assert "EDT_PROJECT" in source.text
assert "<html" not in source.text
check = client.post(f"/html5/projects/{project_id}/setup/check")
assert check.status_code == 200
assert "data-html5-import-check" in check.text
assert "data-html5-preflight-check" in check.text
assert "WARNING" in check.text
assert "<html" not in check.text
import_job = client.post(f"/html5/projects/{project_id}/setup/import-job")
assert import_job.status_code == 200
assert "data-html5-import-job" in import_job.text
assert "SERVER_IMPORT" not in import_job.text
assert "hx-get" in import_job.text
assert "<html" not in import_job.text
jobs = client.get("/operations/jobs", params={"project_id": project_id, "kind": "SERVER_IMPORT"})
assert jobs.status_code == 200
job_id = jobs.json()[0]["job_id"]
job_fragment = client.get(f"/html5/projects/{project_id}/setup/jobs/{job_id}")
assert job_fragment.status_code == 200
assert "data-html5-import-job" in job_fragment.text
assert job_id in job_fragment.text
assert "<html" not in job_fragment.text
html5_import = client.post(f"/html5/projects/{project_id}/setup/import")
assert html5_import.status_code == 200
assert "data-html5-setup-summary" in html5_import.text
assert "mock_indexed" in html5_import.text
assert "<html" not in html5_import.text
reindex = client.post(f"/html5/projects/{project_id}/setup/reindex")
assert reindex.status_code == 200
assert "data-html5-setup-summary" in reindex.text
assert "reindexed" in reindex.text
assert "<html" not in reindex.text
summary = client.get(f"/html5/projects/{project_id}/setup/summary")
assert summary.status_code == 200
assert "text/html" in summary.headers["content-type"]
assert "data-html5-setup-summary" in summary.text
assert "INDEXED" in summary.text
assert "mock_indexed" in summary.text
assert "<html" not in summary.text
def test_html5_operations_renders_job_monitor_fragments():
client = TestClient(app)
project_id = f"html5-ops-{uuid4()}"
saved = client.post(
f"/projects/{project_id}/settings",
json={"name": "HTML5 Ops", "structure_source": "XML_DUMP"},
)
assert saved.status_code == 200
job = client.post(f"/html5/projects/{project_id}/setup/import-job")
assert job.status_code == 200
page = client.get("/html5/operations")
assert page.status_code == 200
assert "text/html" in page.headers["content-type"]
assert 'data-html5-page="operations"' in page.text
assert "data-html5-operations-body" in page.text
assert "data-html5-operations-summary" in page.text
assert 'hx-get="/html5/operations/jobs"' in page.text
assert 'hx-get="/html5/operations/summary"' in page.text
assert project_id in page.text
assert "__next" not in page.text
rows = client.get("/html5/operations/jobs")
assert rows.status_code == 200
assert "text/html" in rows.headers["content-type"]
assert "data-html5-operation" in rows.text
assert project_id in rows.text
assert "<html" not in rows.text
summary = client.get("/html5/operations/summary")
assert summary.status_code == 200
assert "text/html" in summary.headers["content-type"]
assert "data-html5-operations-summary" in summary.text
assert "Всего" in summary.text
assert "<html" not in summary.text
def test_project_setup_mock_import_indexes_project():
client = TestClient(app)
project_id = f"setup-import-{uuid4()}"
initial = client.get(f"/projects/{project_id}/setup")
assert initial.status_code == 200
assert initial.json()["status"] == "NOT_CONFIGURED"
assert "Проект не проиндексирован" in initial.json()["message"]
settings = client.post(
f"/projects/{project_id}/settings",
json={
"name": "Demo",
"structure_source": "XML_DUMP",
"platform_version": "8.3.24",
"compatibility_mode": "8.3.20",
},
)
assert settings.status_code == 200
assert settings.json()["status"] == "IMPORT_REQUIRED"
assert settings.json()["settings"]["structure_source"] == "XML_DUMP"
assert settings.json()["settings"]["platform_version"] == "8.3.24"
imported = client.post(
f"/projects/{project_id}/imports/XML_DUMP",
json={
"source": "XML_DUMP",
"metadata": {"platform_version": "8.3.24", "compatibility_mode": "8.3.20"},
},
)
assert imported.status_code == 200
payload = imported.json()
assert payload["status"] == "mock_indexed"
assert payload["snapshot"]["project_id"] == project_id
assert payload["object_count"] >= 5
assert payload["form_count"] >= 2
assert payload["role_count"] >= 1
setup = client.get(f"/projects/{project_id}/setup")
assert setup.status_code == 200
assert setup.json()["status"] == "INDEXED"
assert setup.json()["last_import"]["source_path"]
assert setup.json()["import_history"][0]["status"] == "mock_indexed"
history = client.get(f"/projects/{project_id}/imports/history")
assert history.status_code == 200
assert history.json()[0]["source"] == "XML_DUMP"
tree = client.get(f"/projects/{project_id}/metadata/tree")
assert tree.status_code == 200
assert tree.json()["root"]["kind"] == "PROJECT"
reindexed = client.post(f"/projects/{project_id}/reindex")
assert reindexed.status_code == 200
assert reindexed.json()["status"] == "reindexed"
def test_windows_agent_import_job_protocol_applies_server_result(tmp_path: Path):
(tmp_path / "metadata.xml").write_text(
"""
<Configuration>
<Catalog name="Контрагенты" qualifiedName="Справочник.Контрагенты" />
</Configuration>
""",
encoding="utf-8",
)
client = TestClient(app)
project_id = f"agent-import-{uuid4()}"
agent_id = f"win-agent-{uuid4()}"
client.post("/agent/heartbeat", json={"agent_id": agent_id, "hostname": "test-host"})
created = client.post(
f"/projects/{project_id}/imports/EDT_PROJECT/agent-jobs",
json={
"agent_id": agent_id,
"source": "EDT_PROJECT",
"local_path": r"D:\edt\upo",
"bin_path": r"C:\Program Files\1cv8\8.3.24.0000\bin\1cv8.exe",
"mode": "FULL_REPLACE",
},
)
assert created.status_code == 200
job_id = created.json()["job_id"]
assert created.json()["status"] == "QUEUED"
claimed = client.get("/agent/jobs/next", params={"agent_id": agent_id})
assert claimed.status_code == 200
assert claimed.json()["job_id"] == job_id
assert claimed.json()["status"] == "RUNNING"
assert claimed.json()["local_path"] == r"D:\edt\upo"
completed = client.post(
f"/agent/jobs/{job_id}/result",
json={
"status": "SUCCEEDED",
"server_path": str(tmp_path),
"logs": ["EDT exported and uploaded."],
},
)
assert completed.status_code == 200
deadline = time.monotonic() + 10
payload = completed.json()
while time.monotonic() < deadline and payload["status"] == "RUNNING":
time.sleep(0.05)
jobs = client.get(f"/projects/{project_id}/imports/agent-jobs")
assert jobs.status_code == 200
payload = next(item for item in jobs.json() if item["job_id"] == job_id)
assert payload["status"] == "SUCCEEDED"
assert payload["import_summary"]["status"] == "structure_indexed"
assert payload["import_summary"]["object_count"] == 1
setup = client.get(f"/projects/{project_id}/setup")
assert setup.status_code == 200
assert setup.json()["last_import"]["source"] == "EDT_PROJECT"
def test_windows_agent_import_job_accepts_uploaded_zip(tmp_path: Path):
payload_root = tmp_path / "payload"
payload_root.mkdir()
(payload_root / "metadata.xml").write_text(
"""
<Configuration>
<Catalog name="Номенклатура" qualifiedName="Справочник.Номенклатура" />
</Configuration>
""",
encoding="utf-8",
)
archive_path = tmp_path / "edt.zip"
with zipfile.ZipFile(archive_path, "w") as archive:
archive.write(payload_root / "metadata.xml", "metadata.xml")
client = TestClient(app)
project_id = f"agent-upload-{uuid4()}"
agent_id = f"win-agent-{uuid4()}"
client.post("/agent/heartbeat", json={"agent_id": agent_id, "hostname": "test-host"})
created = client.post(
f"/projects/{project_id}/imports/EDT_PROJECT/agent-jobs",
json={"agent_id": agent_id, "source": "EDT_PROJECT", "local_path": r"D:\edt\upo", "mode": "FULL_REPLACE"},
)
assert created.status_code == 200
job_id = created.json()["job_id"]
uploaded = client.post(
f"/agent/jobs/{job_id}/upload",
params={"filename": "edt.zip"},
content=archive_path.read_bytes(),
headers={"Content-Type": "application/octet-stream"},
)
assert uploaded.status_code == 200
assert uploaded.json()["server_path"]
deadline = time.monotonic() + 10
payload = uploaded.json()
while time.monotonic() < deadline and payload["status"] == "RUNNING":
time.sleep(0.05)
jobs = client.get(f"/projects/{project_id}/imports/agent-jobs")
assert jobs.status_code == 200
payload = next(item for item in jobs.json() if item["job_id"] == job_id)
assert payload["status"] == "SUCCEEDED"
assert payload["import_summary"]["object_count"] == 1
def test_windows_agent_cf_export_job_accepts_infobase_settings_without_local_path():
client = TestClient(app)
project_id = f"agent-cf-{uuid4()}"
agent_id = f"win-agent-{uuid4()}"
client.post("/agent/heartbeat", json={"agent_id": agent_id, "hostname": "test-host"})
created = client.post(
f"/projects/{project_id}/imports/CF_FILE/agent-jobs",
json={
"agent_id": agent_id,
"source": "CF_FILE",
"mode": "FULL_REPLACE",
"metadata": {
"one_c_server": "192.168.200.95",
"one_c_infobase": "upo",
"one_c_user": "import-user",
"one_c_password": "secret",
},
},
)
assert created.status_code == 200
payload = created.json()
assert payload["source"] == "CF_FILE"
assert payload["local_path"] is None
assert payload["metadata"]["one_c_infobase"] == "upo"
def test_windows_agent_browse_request_protocol():
client = TestClient(app)
agent_id = f"win-agent-{uuid4()}"
created = client.post("/agent/browse-requests", json={"agent_id": agent_id, "path": r"D:\EDT"})
assert created.status_code == 200
request_id = created.json()["request_id"]
assert created.json()["status"] == "QUEUED"
claimed = client.get("/agent/browse/next", params={"agent_id": agent_id})
assert claimed.status_code == 200
assert claimed.json()["request_id"] == request_id
assert claimed.json()["status"] == "RUNNING"
completed = client.post(
f"/agent/browse/{request_id}/result",
json={
"status": "SUCCEEDED",
"parent_path": r"D:\\",
"entries": [{"name": "UPO", "path": r"D:\EDT\UPO", "is_directory": True}],
},
)
assert completed.status_code == 200
assert completed.json()["status"] == "SUCCEEDED"
assert completed.json()["entries"][0]["path"] == r"D:\EDT\UPO"
def test_windows_agent_heartbeat_status():
client = TestClient(app)
agent_id = f"win-agent-{uuid4()}"
offline = client.get(f"/agent/status/{agent_id}")
assert offline.status_code == 200
assert offline.json()["status"] == "offline"
heartbeat = client.post(
"/agent/heartbeat",
json={
"agent_id": agent_id,
"host": "workstation-1",
"user": "svc-sfera",
"version": "dev",
"network_roots": [r"\\server\share"],
},
)
assert heartbeat.status_code == 200
assert heartbeat.json()["status"] == "online"
online = client.get(f"/agent/status/{agent_id}")
assert online.status_code == 200
assert online.json()["status"] == "online"
assert online.json()["host"] == "workstation-1"
assert online.json()["network_roots"] == [r"\\server\share"]
def test_server_browse_lists_directories(tmp_path: Path):
(tmp_path / "edt").mkdir()
(tmp_path / "file.txt").write_text("not a dir", encoding="utf-8")
client = TestClient(app)
response = client.get("/server/browse", params={"path": str(tmp_path)})
assert response.status_code == 200
payload = response.json()
assert payload["path"]
assert payload["parent_path"]
assert payload["entries"] == [{"name": "edt", "path": (tmp_path / "edt").as_posix(), "is_directory": True}]
def test_server_smb_browse_validates_unc_path():
client = TestClient(app)
response = client.post("/server/smb/browse", json={"path": "192.168.200.200", "username": "user", "password": "secret"})
assert response.status_code == 200
assert "UNC путь" in response.json()["error"]
def test_project_setup_recovers_indexed_status_from_stored_snapshot(tmp_path: Path):
(tmp_path / "metadata.xml").write_text(
"""
<Configuration>
<Catalog name="Контрагенты" qualifiedName="Справочник.Контрагенты" />
</Configuration>
""",
encoding="utf-8",
)
client = TestClient(app)
project_id = f"setup-recover-{uuid4()}"
imported = client.post(
f"/projects/{project_id}/imports/XML_DUMP",
json={"source": "XML_DUMP", "path": str(tmp_path)},
)
assert imported.status_code == 200
main._snapshots.pop(project_id, None)
main._project_setup.pop(project_id, None)
recovered = client.get(f"/projects/{project_id}/setup")
assert recovered.status_code == 200
payload = recovered.json()
assert payload["status"] == "INDEXED"
assert payload["last_import"]["source"] == "XML_DUMP"
def test_project_create_and_delete_lifecycle():
client = TestClient(app)
project_id = f"lifecycle-{uuid4()}"
created = client.post("/projects", json={"project_id": project_id, "name": "Lifecycle"})
assert created.status_code == 200
assert created.json()["project_id"] == project_id
assert created.json()["settings"]["name"] == "Lifecycle"
duplicate = client.post("/projects", json={"project_id": project_id})
assert duplicate.status_code == 409
wrong_confirmation = client.request("DELETE", f"/projects/{project_id}", json={"confirmation": "wrong"})
assert wrong_confirmation.status_code == 400
deleted = client.request("DELETE", f"/projects/{project_id}", json={"confirmation": project_id})
assert deleted.status_code == 200
assert deleted.json()["project_id"] == project_id
def test_project_settings_persist_wizard_sections():
client = TestClient(app)
project_id = f"setup-sections-{uuid4()}"
settings = client.post(
f"/projects/{project_id}/settings",
json={
"name": "Wizard Sections",
"configuration_source": "EDT local project",
"structure_source": "EDT_PROJECT",
"platform_version": "8.5.1.1150",
"compatibility_mode": "8.3",
"extensions": ["UPO_Ext"],
"environments": {
"dev": {
"url": "http://192.168.200.95/upo/ru_RU/",
"infobase": 'Srvr="192.168.200.95";Ref="upo";',
"runtime": "mock",
},
"test": {"runtime": "remote_worker"},
},
"agent": {"mode": "mock", "endpoint": "https://agent.local/snapshot", "allow_snapshots": True},
"privacy_mode": "LOCAL_ONLY",
"knowledge_sources": ["wiki", "incidents"],
"task_session_policy": {
"mode": "required",
"default_task_id": "task.default",
"session_ttl": "8h",
"audit_privacy": True,
},
},
)
assert settings.status_code == 200
payload = settings.json()["settings"]
assert payload["environments"]["dev"]["runtime"] == "mock"
assert payload["agent"]["allow_snapshots"] is True
assert payload["knowledge_sources"] == ["wiki", "incidents"]
assert payload["task_session_policy"]["default_task_id"] == "task.default"
def test_import_source_check_reports_preflight_requirements(tmp_path: Path):
client = TestClient(app)
project_id = f"source-check-{uuid4()}"
xml_file = tmp_path / "metadata.xml"
xml_file.write_text("<Configuration />", encoding="utf-8")
xml_check = client.post(
f"/projects/{project_id}/imports/XML_DUMP/check",
json={"source": "XML_DUMP", "path": str(xml_file)},
)
assert xml_check.status_code == 200
assert xml_check.json()["ready"] is True
assert any(check["code"] == "path" and check["status"] == "OK" for check in xml_check.json()["checks"])
live_check = client.post(
f"/projects/{project_id}/imports/LIVE_INFOBASE/check",
json={"source": "LIVE_INFOBASE"},
)
assert live_check.status_code == 200
assert live_check.json()["ready"] is False
assert any(check["code"] == "credentials" and check["status"] == "BLOCKED" for check in live_check.json()["checks"])
def test_runtime_required_import_uses_api_mock_when_adapter_unavailable(monkeypatch):
monkeypatch.setenv("RUNTIME_ADAPTER_MODE", "mock")
monkeypatch.setenv("RUNTIME_ADAPTER_URL", "http://127.0.0.1:1")
client = TestClient(app)
project_id = f"runtime-import-{uuid4()}"
imported = client.post(
f"/projects/{project_id}/imports/CF_FILE",
json={"source": "CF_FILE", "metadata": {"platform_version": "8.3.24"}},
)
assert imported.status_code == 200
payload = imported.json()
assert payload["status"] == "mock_indexed"
assert payload["runtime_mode"] == "mock"
assert payload["runtime_diagnostics"]
assert payload["snapshot"]["project_id"] == project_id
platform = client.get("/runtime/platform")
assert platform.status_code == 200
assert platform.json()["mode"] == "mock"
assert platform.json()["platform_found"] is False
def test_runtime_required_import_does_not_index_cf_file_directly(monkeypatch, tmp_path: Path):
monkeypatch.setenv("RUNTIME_ADAPTER_MODE", "mock")
monkeypatch.setenv("RUNTIME_ADAPTER_URL", "http://127.0.0.1:1")
cf_file = tmp_path / "demo.cf"
cf_file.write_text("not xml", encoding="utf-8")
client = TestClient(app)
project_id = f"cf-import-{uuid4()}"
imported = client.post(
f"/projects/{project_id}/imports/CF_FILE",
json={"source": "CF_FILE", "path": str(cf_file), "metadata": {"platform_version": "8.3.24"}},
)
assert imported.status_code == 200
payload = imported.json()
assert payload["status"] == "mock_indexed"
assert payload["source_path"] != cf_file.as_posix()
assert payload["normalized_summary"]["object_count"] >= 1
def test_import_supports_structure_only_indexing(tmp_path: Path):
(tmp_path / "metadata.xml").write_text(
"""
<Configuration>
<HTTPService name="ПубличныйAPI" qualifiedName="HTTPСервис.ПубличныйAPI" />
<Subsystem name="Продажи" qualifiedName="Подсистема.Продажи" />
<Role name="Менеджер" qualifiedName="Роль.Менеджер">
<Right object="HTTPСервис.ПубличныйAPI" read="true" />
</Role>
</Configuration>
""",
encoding="utf-8",
)
client = TestClient(app)
project_id = f"structure-only-{uuid4()}"
imported = client.post(
f"/projects/{project_id}/imports/EDT_PROJECT",
json={"source": "EDT_PROJECT", "path": str(tmp_path), "structure_only": True},
)
assert imported.status_code == 200
payload = imported.json()
assert payload["status"] == "structure_indexed"
assert payload["snapshot"]["project_id"] == project_id
assert payload["object_count"] >= 2
assert payload["normalized_summary"]["group_count"] >= 3
assert payload["normalized_summary"]["rights_count"] == 1
setup = client.get(f"/projects/{project_id}/setup")
assert setup.status_code == 200
assert setup.json()["status"] == "STRUCTURE_INDEXED"
assert "Запустите индексацию" in setup.json()["message"]
summary = client.get(f"/projects/{project_id}/normalized/summary")
assert summary.status_code == 200
assert summary.json()["rights_count"] == 1
quality = client.get(f"/projects/{project_id}/imports/quality")
assert quality.status_code == 200
quality_payload = quality.json()
assert quality_payload["project_id"] == project_id
assert quality_payload["score"] > 0
assert any(check["code"] == "rights" and check["passed"] for check in quality_payload["checks"])
normalized = client.get(f"/projects/{project_id}/normalized")
assert normalized.status_code == 200
groups = {group["name"] for group in normalized.json()["configuration"]["groups"]}
assert {"HTTP-сервисы", "Подсистемы", "Роли"}.issubset(groups)
detail = client.get(
f"/projects/{project_id}/normalized/object",
params={"qualified_name": "Роль.Менеджер"},
)
assert detail.status_code == 200
assert detail.json()["group_name"] == "Роли"
assert detail.json()["object"]["rights"][0]["target"] == "HTTPСервис.ПубличныйAPI"
tree = client.get(f"/projects/{project_id}/metadata/tree")
assert tree.status_code == 200
root = tree.json()["root"]
assert root["label"] == "Проект"
assert [item["label"] for item in root["children"][:4]] == [
"Основная конфигурация",
"Расширение: <Имя>",
"SFERA",
"Среды",
]
main_configuration = root["children"][0]
common = next(item for item in main_configuration["children"] if item["label"] == "Общие")
assert any(item["label"] == "HTTP-сервисы" for item in common["children"])
def test_import_full_replace_replaces_current_normalized_project(tmp_path: Path):
first = tmp_path / "first"
second = tmp_path / "second"
first.mkdir()
second.mkdir()
(first / "Контрагенты.mdo").write_text(
"""
<mdclass:Catalog xmlns:mdclass="http://g5.1c.ru/v8/dt/metadata/mdclass">
<name>Контрагенты</name>
<synonym>Контрагенты</synonym>
</mdclass:Catalog>
""",
encoding="utf-8",
)
(second / "Номенклатура.mdo").write_text(
"""
<mdclass:Catalog xmlns:mdclass="http://g5.1c.ru/v8/dt/metadata/mdclass">
<name>Номенклатура</name>
<synonym>Номенклатура</synonym>
</mdclass:Catalog>
""",
encoding="utf-8",
)
client = TestClient(app)
project_id = f"full-replace-{uuid4()}"
imported = client.post(
f"/projects/{project_id}/imports/EDT_PROJECT",
json={"source": "EDT_PROJECT", "path": str(first), "mode": "FULL_REPLACE"},
)
assert imported.status_code == 200
assert imported.json()["mode"] == "FULL_REPLACE"
assert imported.json()["applied"] is True
replaced = client.post(
f"/projects/{project_id}/imports/EDT_PROJECT",
json={"source": "EDT_PROJECT", "path": str(second), "mode": "FULL_REPLACE"},
)
assert replaced.status_code == 200
assert replaced.json()["mode"] == "FULL_REPLACE"
assert replaced.json()["normalized_summary"]["object_count"] == 1
normalized = client.get(f"/projects/{project_id}/normalized")
assert normalized.status_code == 200
payload = normalized.json()
objects = [
item["qualified_name"]
for group in payload["configuration"]["groups"]
for item in group["objects"]
]
assert objects == ["Справочник.Номенклатура"]
def test_import_sync_preview_does_not_replace_current_project(tmp_path: Path):
current = tmp_path / "current"
incoming = tmp_path / "incoming"
current.mkdir()
incoming.mkdir()
(current / "Контрагенты.mdo").write_text(
"""
<mdclass:Catalog xmlns:mdclass="http://g5.1c.ru/v8/dt/metadata/mdclass">
<name>Контрагенты</name>
</mdclass:Catalog>
""",
encoding="utf-8",
)
(incoming / "Номенклатура.mdo").write_text(
"""
<mdclass:Catalog xmlns:mdclass="http://g5.1c.ru/v8/dt/metadata/mdclass">
<name>Номенклатура</name>
</mdclass:Catalog>
""",
encoding="utf-8",
)
client = TestClient(app)
project_id = f"sync-preview-{uuid4()}"
imported = client.post(
f"/projects/{project_id}/imports/EDT_PROJECT",
json={"source": "EDT_PROJECT", "path": str(current)},
)
assert imported.status_code == 200
preview = client.post(
f"/projects/{project_id}/imports/EDT_PROJECT",
json={"source": "EDT_PROJECT", "path": str(incoming), "mode": "SYNC_PREVIEW"},
)
assert preview.status_code == 200
payload = preview.json()
assert payload["status"] == "sync_preview"
assert payload["mode"] == "SYNC_PREVIEW"
assert payload["applied"] is False
assert payload["sync_preview"]["added_count"] == 1
assert payload["sync_preview"]["removed_count"] == 1
assert {item["change_kind"] for item in payload["sync_preview"]["items"]} == {"ADD", "REMOVE"}
normalized = client.get(f"/projects/{project_id}/normalized")
assert normalized.status_code == 200
objects = [
item["qualified_name"]
for group in normalized.json()["configuration"]["groups"]
for item in group["objects"]
]
assert objects == ["Справочник.Контрагенты"]
def test_import_full_replace_keeps_valid_edt_objects_when_one_xml_is_broken(tmp_path: Path):
(tmp_path / "valid.mdo").write_text(
"""
<mdclass:Catalog xmlns:mdclass="http://g5.1c.ru/v8/dt/metadata/mdclass">
<name>Контрагенты</name>
<synonym>Контрагенты</synonym>
</mdclass:Catalog>
""",
encoding="utf-8",
)
(tmp_path / "broken.mdo").write_text("<mdclass:Catalog xmlns:mdclass=", encoding="utf-8")
client = TestClient(app)
project_id = f"edt-broken-xml-{uuid4()}"
imported = client.post(
f"/projects/{project_id}/imports/EDT_PROJECT",
json={"source": "EDT_PROJECT", "path": str(tmp_path), "mode": "FULL_REPLACE"},
)
assert imported.status_code == 200
payload = imported.json()
assert payload["status"] == "indexed"
assert payload["object_count"] == 1
assert payload["diagnostics_count"] == 1
assert "XML_PARSE_ERROR" in payload["diagnostics"][0]
snapshot = client.get(f"/projects/{project_id}/snapshot")
assert snapshot.status_code == 200
assert snapshot.json()["diagnostics_count"] == 1
normalized = client.get(f"/projects/{project_id}/normalized")
assert normalized.status_code == 200
assert normalized.json()["configuration"]["groups"][0]["objects"][0]["qualified_name"] == "Справочник.Контрагенты"
def test_import_edt_full_replace_includes_object_bsl_modules_in_normalized_model(tmp_path: Path):
catalog_dir = tmp_path / "Catalogs" / "Контрагенты"
module_dir = catalog_dir / "Ext"
module_dir.mkdir(parents=True)
(catalog_dir / "Контрагенты.mdo").write_text(
"""
<mdclass:Catalog xmlns:mdclass="http://g5.1c.ru/v8/dt/metadata/mdclass">
<name>Контрагенты</name>
</mdclass:Catalog>
""",
encoding="utf-8",
)
(module_dir / "ObjectModule.bsl").write_text(
"""
Процедура ПроверитьКонтрагента() Экспорт
КонецПроцедуры
""",
encoding="utf-8",
)
(module_dir / "ManagerModule.bsl").write_text(
"""
Процедура Создать() Экспорт
КонецПроцедуры
""",
encoding="utf-8",
)
client = TestClient(app)
project_id = f"edt-modules-{uuid4()}"
imported = client.post(
f"/projects/{project_id}/imports/EDT_PROJECT",
json={"source": "EDT_PROJECT", "path": str(tmp_path), "mode": "FULL_REPLACE"},
)
assert imported.status_code == 200
assert imported.json()["normalized_summary"]["module_count"] == 2
detail = client.get(
f"/projects/{project_id}/normalized/object",
params={"qualified_name": "Справочник.Контрагенты"},
)
assert detail.status_code == 200
modules = detail.json()["object"]["modules"]
assert [module["module_kind"] for module in modules] == ["MANAGER_MODULE", "OBJECT_MODULE"]
assert all(module["attributes"]["source_hash"] for module in modules)
def test_import_edt_full_replace_keeps_tabular_section_columns_nested(tmp_path: Path):
(tmp_path / "ЗаказПокупателя.mdo").write_text(
"""
<mdclass:Document xmlns:mdclass="http://g5.1c.ru/v8/dt/metadata/mdclass">
<name>ЗаказПокупателя</name>
<tabularSections>
<name>Товары</name>
<attributes>
<name>Номенклатура</name>
</attributes>
<attributes>
<name>Количество</name>
</attributes>
</tabularSections>
</mdclass:Document>
""",
encoding="utf-8",
)
client = TestClient(app)
project_id = f"edt-tabular-{uuid4()}"
imported = client.post(
f"/projects/{project_id}/imports/EDT_PROJECT",
json={"source": "EDT_PROJECT", "path": str(tmp_path), "mode": "FULL_REPLACE"},
)
assert imported.status_code == 200
assert imported.json()["normalized_summary"]["attribute_count"] == 2
detail = client.get(
f"/projects/{project_id}/normalized/object",
params={"qualified_name": "Документ.ЗаказПокупателя"},
)
assert detail.status_code == 200
document = detail.json()["object"]
assert document["attributes"] == []
assert [child["name"] for child in document["tabular_sections"][0]["children"]] == ["Номенклатура", "Количество"]
def test_import_full_replace_preserves_configuration_root_metadata(tmp_path: Path):
(tmp_path / "configuration.xml").write_text(
"""
<Configuration name="УправлениеТорговлей" synonym="Управление торговлей" platformVersion="8.3.24" compatibilityMode="8.3.20">
<Catalog name="Контрагенты" qualifiedName="Справочник.Контрагенты" />
</Configuration>
""",
encoding="utf-8",
)
client = TestClient(app)
project_id = f"configuration-root-{uuid4()}"
imported = client.post(
f"/projects/{project_id}/imports/XML_DUMP",
json={"source": "XML_DUMP", "path": str(tmp_path), "mode": "FULL_REPLACE"},
)
assert imported.status_code == 200
assert imported.json()["normalized_summary"]["object_count"] == 1
normalized = client.get(f"/projects/{project_id}/normalized")
assert normalized.status_code == 200
configuration = normalized.json()["configuration"]
assert configuration["name"] == "УправлениеТорговлей"
assert configuration["metadata"]["synonym"] == "Управление торговлей"
assert configuration["metadata"]["platformVersion"] == "8.3.24"
def test_project_metadata_tree_uses_catalog_structure(tmp_path: Path):
(tmp_path / "metadata.xml").write_text(
"""
<Configuration>
<Catalog name="Контрагенты" qualifiedName="Справочник.Контрагенты">
<Attribute name="НаименованиеПолное" qualifiedName="Справочник.Контрагенты.НаименованиеПолное" />
</Catalog>
<Document name="ЗаказПокупателя" qualifiedName="Документ.ЗаказПокупателя" />
<MetadataObject type="AccumulationRegister">
<Name>ОстаткиТоваров</Name>
<QualifiedName>РегистрНакопления.ОстаткиТоваров</QualifiedName>
</MetadataObject>
</Configuration>
""",
encoding="utf-8",
)
client = TestClient(app)
project_id = f"metadata-tree-{uuid4()}"
indexed = client.post("/projects/index", json={"path": str(tmp_path), "project_id": project_id})
assert indexed.status_code == 200
response = client.get(f"/projects/{project_id}/metadata/tree")
assert response.status_code == 200
root = response.json()["root"]
main_configuration = next(item for item in root["children"] if item["label"] == "Основная конфигурация")
assert any(item["label"].startswith("Расширение:") for item in root["children"])
assert any(item["label"] == "SFERA" for item in root["children"])
assert any(item["label"] == "Среды" for item in root["children"])
common = next(item for item in main_configuration["children"] if item["label"] == "Общие")
catalogs = next(item for item in main_configuration["children"] if item["label"] == "Справочники")
documents = next(item for item in main_configuration["children"] if item["label"] == "Документы")
accumulation_registers = next(item for item in main_configuration["children"] if item["label"] == "Регистры накопления")
assert any(item["label"] == "Общие модули" for item in common["children"])
assert any(item["label"] == "Общие формы" for item in common["children"])
assert not any(item["label"] == "Общие формы" for item in main_configuration["children"])
assert catalogs["children"][0]["qualified_name"] == "Справочник.Контрагенты"
assert any(item["label"] == "Реквизиты" for item in catalogs["children"][0]["children"])
assert documents["children"][0]["qualified_name"] == "Документ.ЗаказПокупателя"
assert accumulation_registers["children"][0]["qualified_name"] == "РегистрНакопления.ОстаткиТоваров"
lazy = client.get(f"/projects/{project_id}/metadata/tree", params={"object_limit_per_branch": 0})
assert lazy.status_code == 200
lazy_main_configuration = next(item for item in lazy.json()["root"]["children"] if item["label"] == "Основная конфигурация")
lazy_catalogs = next(item for item in lazy_main_configuration["children"] if item["label"] == "Справочники")
assert lazy_catalogs["children"] == []
assert lazy_catalogs["has_more"] is True
assert lazy_catalogs["count"] == 1
children = client.get(
f"/projects/{project_id}/metadata/tree/children",
params={"node_id": "branch.CATALOG", "offset": 0, "limit": 1},
)
assert children.status_code == 200
assert children.json()["total"] == 1
assert children.json()["children"][0]["qualified_name"] == "Справочник.Контрагенты"
search = client.get(f"/projects/{project_id}/metadata/tree/search", params={"q": "Контраг", "limit": 5})
assert search.status_code == 200
assert search.json()["total"] >= 1
assert search.json()["results"][0]["qualified_name"] == "Справочник.Контрагенты"
path = client.get(
f"/projects/{project_id}/metadata/tree/path",
params={"node_id": search.json()["results"][0]["id"]},
)
assert path.status_code == 200
assert path.json()["path"] == ["main-configuration", "branch.CATALOG", search.json()["results"][0]["id"]]
assert path.json()["steps"][-1] == {
"parent_id": "branch.CATALOG",
"child_id": search.json()["results"][0]["id"],
"offset": 0,
}
def test_project_metadata_tree_loads_normalized_lazy_children(tmp_path: Path):
(tmp_path / "metadata.xml").write_text(
"""
<Configuration>
<Catalog name="Контрагенты" qualifiedName="Справочник.Контрагенты">
<Attribute name="ИНН" qualifiedName="Справочник.Контрагенты.ИНН" />
<Form name="ФормаЭлемента" qualifiedName="Справочник.Контрагенты.ФормаЭлемента" />
</Catalog>
</Configuration>
""",
encoding="utf-8",
)
client = TestClient(app)
project_id = f"normalized-tree-{uuid4()}"
imported = client.post(
f"/projects/{project_id}/imports/XML_DUMP",
json={"source": "XML_DUMP", "path": str(tmp_path), "mode": "FULL_REPLACE"},
)
assert imported.status_code == 200
lazy = client.get(f"/projects/{project_id}/metadata/tree", params={"object_limit_per_branch": 0})
assert lazy.status_code == 200
main_configuration = next(item for item in lazy.json()["root"]["children"] if item["label"] == "Основная конфигурация")
catalogs = next(item for item in main_configuration["children"] if item["label"] == "Справочники")
assert catalogs["id"].startswith("normalized.branch.")
assert catalogs["children"] == []
assert catalogs["has_more"] is True
objects = client.get(
f"/projects/{project_id}/metadata/tree/children",
params={"node_id": catalogs["id"], "offset": 0, "limit": 80},
)
assert objects.status_code == 200
catalog = objects.json()["children"][0]
assert catalog["qualified_name"] == "Справочник.Контрагенты"
attributes = next(item for item in catalog["children"] if item["label"] == "Реквизиты")
parts = client.get(
f"/projects/{project_id}/metadata/tree/children",
params={"node_id": attributes["id"], "offset": 0, "limit": 80},
)
assert parts.status_code == 200
assert parts.json()["children"][0]["qualified_name"] == "Справочник.Контрагенты.ИНН"
def test_project_metadata_tree_shows_http_service_documented_structure(tmp_path: Path):
(tmp_path / "ПубличныйAPI.mdo").write_text(
"""
<mdclass:HTTPService xmlns:mdclass="http://g5.1c.ru/v8/dt/metadata/mdclass">
<name>ПубличныйAPI</name>
<rootURL>api</rootURL>
<urlTemplates>
<name>Orders</name>
<template>/orders/{id}</template>
<methods>
<httpMethod>GET</httpMethod>
<handler>ПолучитьЗаказ</handler>
</methods>
</urlTemplates>
</mdclass:HTTPService>
""",
encoding="utf-8",
)
client = TestClient(app)
project_id = f"http-service-tree-{uuid4()}"
imported = client.post(
f"/projects/{project_id}/imports/EDT_PROJECT",
json={"source": "EDT_PROJECT", "path": str(tmp_path), "mode": "FULL_REPLACE"},
)
assert imported.status_code == 200
tree = client.get(f"/projects/{project_id}/metadata/tree")
assert tree.status_code == 200
main_configuration = next(item for item in tree.json()["root"]["children"] if item["label"] == "Основная конфигурация")
common = next(item for item in main_configuration["children"] if item["label"] == "Общие")
http_services = next(item for item in common["children"] if item["label"] == "HTTP-сервисы")
service = http_services["children"][0]
assert service["qualified_name"] == "HTTPСервис.ПубличныйAPI"
assert [item["label"] for item in service["children"]] == ["Шаблоны URL"]
templates = client.get(
f"/projects/{project_id}/metadata/tree/children",
params={"node_id": service["children"][0]["id"], "offset": 0, "limit": 80},
)
assert templates.status_code == 200
assert templates.json()["children"][0]["label"] == "Orders"
def test_project_metadata_tree_shows_report_documented_structure(tmp_path: Path):
(tmp_path / "АнализПродаж.mdo").write_text(
"""
<mdclass:Report xmlns:mdclass="http://g5.1c.ru/v8/dt/metadata/mdclass">
<name>АнализПродаж</name>
<attributes>
<name>Период</name>
</attributes>
<tabularSections>
<name>Показатели</name>
</tabularSections>
<forms>
<name>ФормаОтчета</name>
</forms>
<templates>
<name>ПечатнаяФорма</name>
</templates>
<tabularDocuments>
<name>ТабличныйДокумент</name>
</tabularDocuments>
<mainDataCompositionSchema>
<name>ОсновнаяСхемаКомпоновкиДанных</name>
</mainDataCompositionSchema>
<reportVariants>
<name>Основной</name>
</reportVariants>
<settings>
<name>НастройкиПоУмолчанию</name>
</settings>
</mdclass:Report>
""",
encoding="utf-8",
)
client = TestClient(app)
project_id = f"report-tree-{uuid4()}"
imported = client.post(
f"/projects/{project_id}/imports/EDT_PROJECT",
json={"source": "EDT_PROJECT", "path": str(tmp_path), "mode": "FULL_REPLACE"},
)
assert imported.status_code == 200
tree = client.get(f"/projects/{project_id}/metadata/tree")
assert tree.status_code == 200
main_configuration = next(item for item in tree.json()["root"]["children"] if item["label"] == "Основная конфигурация")
reports = next(item for item in main_configuration["children"] if item["label"] == "Отчеты")
report = reports["children"][0]
assert report["qualified_name"] == "Отчет.АнализПродаж"
assert [item["label"] for item in report["children"]] == [
"Реквизиты",
"Табличные части",
"Формы",
"Макеты",
"Табличные документы",
"СКД",
"Варианты отчета",
"Настройки",
]
dcs = next(item for item in report["children"] if item["label"] == "СКД")
dcs_children = client.get(
f"/projects/{project_id}/metadata/tree/children",
params={"node_id": dcs["id"], "offset": 0, "limit": 80},
)
assert dcs_children.status_code == 200
assert dcs_children.json()["children"][0]["label"] == "ОсновнаяСхемаКомпоновкиДанных"
def test_project_metadata_tree_shows_extension_as_configuration_structure(tmp_path: Path):
(tmp_path / "РасширениеCRM.mdo").write_text(
"""
<mdclass:ConfigurationExtension xmlns:mdclass="http://g5.1c.ru/v8/dt/metadata/mdclass/extension">
<name>CRM</name>
<version>1.0</version>
<catalogs>
<name>КонтрагентыCRM</name>
<attributes>
<name>ВнешнийКод</name>
</attributes>
</catalogs>
<commonModules>
<name>CRMСервер</name>
</commonModules>
</mdclass:ConfigurationExtension>
""",
encoding="utf-8",
)
client = TestClient(app)
project_id = f"extension-tree-{uuid4()}"
imported = client.post(
f"/projects/{project_id}/imports/EDT_PROJECT",
json={"source": "EDT_PROJECT", "path": str(tmp_path), "mode": "FULL_REPLACE"},
)
assert imported.status_code == 200
assert imported.json()["normalized_summary"]["extension_count"] == 1
tree = client.get(f"/projects/{project_id}/metadata/tree")
assert tree.status_code == 200
root_children = tree.json()["root"]["children"]
extension = next(item for item in root_children if item["label"] == "Расширение: CRM")
assert extension["kind"] == "EXTENSION"
assert [item["label"] for item in extension["children"][:2]] == ["Сведения (1.0)", "Общие"]
common = next(item for item in extension["children"] if item["label"] == "Общие")
common_modules = next(item for item in common["children"] if item["label"] == "Общие модули")
assert common_modules["children"][0]["label"] == "CRMСервер"
catalogs = next(item for item in extension["children"] if item["label"] == "Справочники")
catalog = catalogs["children"][0]
assert catalog["qualified_name"].endswith("Справочник.КонтрагентыCRM")
attributes = next(item for item in catalog["children"] if item["label"] == "Реквизиты")
parts = client.get(
f"/projects/{project_id}/metadata/tree/children",
params={"node_id": attributes["id"], "offset": 0, "limit": 80},
)
assert parts.status_code == 200
assert parts.json()["children"][0]["label"] == "ВнешнийКод"
lazy_tree = client.get(f"/projects/{project_id}/metadata/tree", params={"object_limit_per_branch": 0})
lazy_extension = next(item for item in lazy_tree.json()["root"]["children"] if item["label"] == "Расширение: CRM")
lazy_catalogs = next(item for item in lazy_extension["children"] if item["label"] == "Справочники")
lazy_catalog_children = client.get(
f"/projects/{project_id}/metadata/tree/children",
params={"node_id": lazy_catalogs["id"], "offset": 0, "limit": 80},
)
assert lazy_catalog_children.status_code == 200
assert lazy_catalog_children.json()["children"][0]["label"] == "КонтрагентыCRM"
def test_project_metadata_tree_adds_reference_configuration_root():
client = TestClient(app)
project_id = f"reference-tree-{uuid4()}"
imported = client.post(
f"/projects/{project_id}/imports/REFERENCE_CONFIGURATION",
json={
"source": "REFERENCE_CONFIGURATION",
"metadata": {"context_description": "Reference baseline for comparison"},
},
)
assert imported.status_code == 200
tree = client.get(f"/projects/{project_id}/metadata/tree")
assert tree.status_code == 200
root = tree.json()["root"]
reference = next(item for item in root["children"] if item["kind"] == "REFERENCE_CONFIGURATION")
assert reference["label"] == "Reference configuration"
assert [item["label"] for item in reference["children"][:3]] == ["Сведения", "Общие", "Константы"]
lazy_tree = client.get(f"/projects/{project_id}/metadata/tree", params={"object_limit_per_branch": 0})
assert lazy_tree.status_code == 200
lazy_root = lazy_tree.json()["root"]
lazy_reference = next(item for item in lazy_root["children"] if item["kind"] == "REFERENCE_CONFIGURATION")
assert lazy_reference["label"] == "Reference configuration"
def test_project_metadata_tree_adds_context_configuration_root():
client = TestClient(app)
project_id = f"context-tree-{uuid4()}"
imported = client.post(
f"/projects/{project_id}/imports/CONTEXT_ONLY",
json={
"source": "CONTEXT_ONLY",
"metadata": {"context_description": "Context-only outline"},
},
)
assert imported.status_code == 200
tree = client.get(f"/projects/{project_id}/metadata/tree")
assert tree.status_code == 200
root = tree.json()["root"]
context_root = next(item for item in root["children"] if item["kind"] == "CONTEXT_CONFIGURATION")
assert context_root["label"] == "Context-only configuration"
assert [item["label"] for item in context_root["children"][:3]] == ["Сведения", "Общие", "Константы"]
lazy_tree = client.get(f"/projects/{project_id}/metadata/tree", params={"object_limit_per_branch": 0})
assert lazy_tree.status_code == 200
lazy_root = lazy_tree.json()["root"]
lazy_context_root = next(item for item in lazy_root["children"] if item["kind"] == "CONTEXT_CONFIGURATION")
assert lazy_context_root["label"] == "Context-only configuration"
def test_project_metadata_tree_does_not_add_context_or_reference_roots_for_regular_sources():
client = TestClient(app)
project_id = f"regular-tree-{uuid4()}"
imported = client.post(
f"/projects/{project_id}/imports/XML_DUMP",
json={
"source": "XML_DUMP",
"metadata": {"platform_version": "8.3.24", "compatibility_mode": "8.3.20"},
},
)
assert imported.status_code == 200
tree = client.get(f"/projects/{project_id}/metadata/tree")
assert tree.status_code == 200
root = tree.json()["root"]
kinds = {item["kind"] for item in root["children"]}
assert "CONTEXT_CONFIGURATION" not in kinds
assert "REFERENCE_CONFIGURATION" not in kinds
lazy_tree = client.get(f"/projects/{project_id}/metadata/tree", params={"object_limit_per_branch": 0})
assert lazy_tree.status_code == 200
lazy_root = lazy_tree.json()["root"]
lazy_kinds = {item["kind"] for item in lazy_root["children"]}
assert "CONTEXT_CONFIGURATION" not in lazy_kinds
assert "REFERENCE_CONFIGURATION" not in lazy_kinds
def test_project_metadata_tree_places_conditional_configuration_roots_before_sfera():
client = TestClient(app)
context_project = f"context-order-{uuid4()}"
context_import = client.post(
f"/projects/{context_project}/imports/CONTEXT_ONLY",
json={"source": "CONTEXT_ONLY", "metadata": {"context_description": "Context order check"}},
)
assert context_import.status_code == 200
context_tree = client.get(f"/projects/{context_project}/metadata/tree")
assert context_tree.status_code == 200
context_children = context_tree.json()["root"]["children"]
context_index = next(i for i, item in enumerate(context_children) if item["kind"] == "CONTEXT_CONFIGURATION")
context_sfera_index = next(i for i, item in enumerate(context_children) if item["label"] == "SFERA")
assert context_index < context_sfera_index
context_lazy_tree = client.get(f"/projects/{context_project}/metadata/tree", params={"object_limit_per_branch": 0})
assert context_lazy_tree.status_code == 200
context_lazy_children = context_lazy_tree.json()["root"]["children"]
context_lazy_index = next(i for i, item in enumerate(context_lazy_children) if item["kind"] == "CONTEXT_CONFIGURATION")
context_lazy_sfera_index = next(i for i, item in enumerate(context_lazy_children) if item["label"] == "SFERA")
assert context_lazy_index < context_lazy_sfera_index
reference_project = f"reference-order-{uuid4()}"
reference_import = client.post(
f"/projects/{reference_project}/imports/REFERENCE_CONFIGURATION",
json={"source": "REFERENCE_CONFIGURATION", "metadata": {"context_description": "Reference order check"}},
)
assert reference_import.status_code == 200
reference_tree = client.get(f"/projects/{reference_project}/metadata/tree")
assert reference_tree.status_code == 200
reference_children = reference_tree.json()["root"]["children"]
reference_index = next(i for i, item in enumerate(reference_children) if item["kind"] == "REFERENCE_CONFIGURATION")
reference_sfera_index = next(i for i, item in enumerate(reference_children) if item["label"] == "SFERA")
assert reference_index < reference_sfera_index
reference_lazy_tree = client.get(f"/projects/{reference_project}/metadata/tree", params={"object_limit_per_branch": 0})
assert reference_lazy_tree.status_code == 200
reference_lazy_children = reference_lazy_tree.json()["root"]["children"]
reference_lazy_index = next(i for i, item in enumerate(reference_lazy_children) if item["kind"] == "REFERENCE_CONFIGURATION")
reference_lazy_sfera_index = next(i for i, item in enumerate(reference_lazy_children) if item["label"] == "SFERA")
assert reference_lazy_index < reference_lazy_sfera_index
def test_normalized_object_modules_returns_linked_bsl_source(tmp_path: Path):
catalog_dir = tmp_path / "Catalogs" / "Контрагенты"
catalog_dir.mkdir(parents=True)
(catalog_dir / "Контрагенты.mdo").write_text(
"""
<mdclass:Catalog xmlns:mdclass="http://g5.1c.ru/v8/dt/metadata/mdclass">
<name>Контрагенты</name>
</mdclass:Catalog>
""",
encoding="utf-8",
)
(catalog_dir / "ObjectModule.bsl").write_text(
"""
Процедура ПередЗаписью(Отказ)
ПроверитьКонтрагента();
Отказ = Ложь;
КонецПроцедуры
Процедура ПроверитьКонтрагента()
Запрос = Новый Запрос("ВЫБРАТЬ Контрагенты.Ссылка ИЗ Справочник.Контрагенты КАК Контрагенты");
КонецПроцедуры
""",
encoding="utf-8",
)
client = TestClient(app)
project_id = f"object-modules-{uuid4()}"
indexed = client.post("/projects/index", json={"path": str(tmp_path), "project_id": project_id})
assert indexed.status_code == 200
modules = client.get(
f"/projects/{project_id}/normalized/object/modules",
params={"qualified_name": "Справочник.Контрагенты"},
)
assert modules.status_code == 200
payload = modules.json()
assert payload[0]["module_role"] == "OBJECT_MODULE"
assert "ПередЗаписью" in payload[0]["source_text"]
assert payload[0]["routines_count"] == 2
assert payload[0]["routines"][0]["name"] == "ПередЗаписью"
assert payload[0]["routines"][0]["kind"] == "PROCEDURE"
assert payload[0]["routines"][0]["calls"] == ["ObjectModule.ПроверитьКонтрагента"]
assert payload[0]["routines"][1]["queries_count"] == 1
assert "Справочник.Контрагенты" in payload[0]["routines"][1]["queries"][0]
assert payload[0]["routines"][1]["impact_level"] == "MEDIUM"
assert "reads query tables" in payload[0]["routines"][1]["impact_reasons"]
tree = client.get(f"/projects/{project_id}/metadata/tree")
assert tree.status_code == 200
main_configuration = next(item for item in tree.json()["root"]["children"] if item["label"] == "Основная конфигурация")
catalogs = next(item for item in main_configuration["children"] if item["label"] == "Справочники")
catalog = catalogs["children"][0]
object_module_group = next(item for item in catalog["children"] if item["label"] == "Модуль объекта")
module_children = client.get(
f"/projects/{project_id}/metadata/tree/children",
params={"node_id": object_module_group["id"], "offset": 0, "limit": 80},
)
assert module_children.status_code == 200
assert module_children.json()["children"][0]["kind"] == "MODULE"
module_qname = module_children.json()["children"][0]["qualified_name"]
selected_module = client.get(
f"/projects/{project_id}/normalized/object/modules",
params={"qualified_name": module_qname},
)
assert selected_module.status_code == 200
assert selected_module.json()[0]["source_text"] == payload[0]["source_text"]
selected_normalized_module = client.get(
f"/projects/{project_id}/normalized/object/modules",
params={"qualified_name": "Справочник.Контрагенты.МодульОбъекта"},
)
assert selected_normalized_module.status_code == 200
assert selected_normalized_module.json()[0]["source_text"] == payload[0]["source_text"]
selected_routine = client.get(
f"/projects/{project_id}/normalized/object/modules",
params={"qualified_name": "ПередЗаписью"},
)
assert selected_routine.status_code == 200
assert selected_routine.json()[0]["source_text"] == payload[0]["source_text"]
def test_normalized_object_modules_resolves_common_module_code(tmp_path: Path):
common_module_dir = tmp_path / "CommonModules" / "CRMСервер"
common_module_dir.mkdir(parents=True)
(common_module_dir / "CRMСервер.mdo").write_text(
"""
<mdclass:CommonModule xmlns:mdclass="http://g5.1c.ru/v8/dt/metadata/mdclass">
<name>CRMСервер</name>
</mdclass:CommonModule>
""",
encoding="utf-8",
)
(common_module_dir / "Module.bsl").write_text(
"""
Процедура ЗагрузитьКонтрагентов() Экспорт
Сообщить("Готово");
КонецПроцедуры
""",
encoding="utf-8",
)
client = TestClient(app)
project_id = f"common-module-code-{uuid4()}"
indexed = client.post("/projects/index", json={"path": str(tmp_path), "project_id": project_id})
assert indexed.status_code == 200
common_module = client.get(
f"/projects/{project_id}/normalized/object/modules",
params={"qualified_name": "ОбщийМодуль.CRMСервер"},
)
assert common_module.status_code == 200
payload = common_module.json()
assert payload[0]["module_role"] == "MODULE"
assert "ЗагрузитьКонтрагентов" in payload[0]["source_text"]
normalized_module_node = client.get(
f"/projects/{project_id}/normalized/object/modules",
params={"qualified_name": "ОбщийМодуль.CRMСервер.Модуль"},
)
assert normalized_module_node.status_code == 200
assert normalized_module_node.json()[0]["source_text"] == payload[0]["source_text"]
def test_normalized_object_modules_reads_canonical_project_before_snapshot():
client = TestClient(app)
project_id = f"normalized-source-{uuid4()}"
source_text = "Процедура Выполнить() Экспорт\n Сообщить(\"ok\");\nКонецПроцедуры\n"
normalized = NormalizedProject(
project_id=project_id,
configuration=ConfigurationRoot(
groups=[
MetadataGroup(
name="Общие модули",
object_kinds=["COMMON_MODULE"],
objects=[
MetadataObject(
name="CRMСервер",
qualified_name="ОбщийМодуль.CRMСервер",
object_kind="COMMON_MODULE",
modules=[
Module(
name="Module",
qualified_name="ОбщийМодуль.CRMСервер.Модуль",
source_path="/edt/CommonModules/CRMСервер/Module.bsl",
module_kind="MODULE",
attributes={"source_text": source_text, "module_role": "MODULE"},
)
],
)
],
)
]
),
source_path="/edt",
)
main._save_normalized_project(project_id, normalized)
by_object = client.get(
f"/projects/{project_id}/normalized/object/modules",
params={"qualified_name": "ОбщийМодуль.CRMСервер"},
)
assert by_object.status_code == 200
assert by_object.json()[0]["source_text"] == source_text
assert by_object.json()[0]["routines"][0]["name"] == "Выполнить"
assert by_object.json()[0]["routines"][0]["export"] is True
by_module = client.get(
f"/projects/{project_id}/normalized/object/modules",
params={"qualified_name": "ОбщийМодуль.CRMСервер.Модуль"},
)
assert by_module.status_code == 200
assert by_module.json()[0]["source_text"] == source_text
def test_bsl_completions_returns_only_exported_common_module_routines():
client = TestClient(app)
project_id = f"bsl-completion-{uuid4()}"
source_text = "\n".join([
"Процедура Выполнить() Экспорт",
"КонецПроцедуры",
"",
"Функция ВнутреннийРасчет()",
" Возврат 1;",
"КонецФункции",
"",
])
normalized = NormalizedProject(
project_id=project_id,
configuration=ConfigurationRoot(
groups=[
MetadataGroup(
name="Общие модули",
object_kinds=["COMMON_MODULE"],
objects=[
MetadataObject(
name="CRMСервер",
qualified_name="ОбщийМодуль.CRMСервер",
object_kind="COMMON_MODULE",
modules=[
Module(
name="Module",
qualified_name="ОбщийМодуль.CRMСервер.Модуль",
source_path="/edt/CommonModules/CRMСервер/Module.bsl",
module_kind="MODULE",
attributes={"source_text": source_text},
)
],
)
],
)
]
),
)
main._save_normalized_project(project_id, normalized)
response = client.get(
f"/projects/{project_id}/bsl/completions",
params={"receiver": "CRMСервер"},
)
assert response.status_code == 200
labels = {item["label"] for item in response.json()}
assert "Выполнить" in labels
assert "ВнутреннийРасчет" not in labels
def test_project_flowchart_returns_overview_and_focus(tmp_path: Path):
catalog_dir = tmp_path / "Catalogs" / "Clients"
catalog_dir.mkdir(parents=True)
(catalog_dir / "Clients.mdo").write_text(
"""
<mdclass:Catalog xmlns:mdclass="http://g5.1c.ru/v8/dt/metadata/mdclass">
<name>Clients</name>
<attributes name="Code" />
</mdclass:Catalog>
""",
encoding="utf-8",
)
(catalog_dir / "ObjectModule.bsl").write_text(
"Процедура ПередЗаписью(Отказ)\n Отказ = Ложь;\nКонецПроцедуры\n",
encoding="utf-8",
)
client = TestClient(app)
project_id = f"flowchart-{uuid4()}"
indexed = client.post("/projects/index", json={"path": str(tmp_path), "project_id": project_id})
assert indexed.status_code == 200
overview = client.get(f"/projects/{project_id}/flowchart")
assert overview.status_code == 200
overview_payload = overview.json()
assert overview_payload["mode"] == "overview"
assert any(node["kind"] == "CATALOG" and node["count"] == 1 for node in overview_payload["nodes"])
assert all(node["kind"] not in {"FORM", "ATTRIBUTE", "TABULAR_SECTION"} for node in overview_payload["nodes"])
focus = client.get(
f"/projects/{project_id}/flowchart",
params={"focus": "Справочник.Clients", "depth": 2, "limit": 50},
)
assert focus.status_code == 200
focus_payload = focus.json()
assert focus_payload["mode"] == "focus"
assert any(node["qualified_name"] == "Справочник.Clients" for node in focus_payload["nodes"])
assert all(node["kind"] not in {"FORM", "ATTRIBUTE", "TABULAR_SECTION"} for node in focus_payload["nodes"])
assert focus_payload["edges"] == []
def test_project_flowchart_collapses_module_logic_to_object_links(tmp_path: Path):
catalog_dir = tmp_path / "Catalogs" / "Clients"
document_dir = tmp_path / "Documents" / "Order"
catalog_dir.mkdir(parents=True)
document_dir.mkdir(parents=True)
(catalog_dir / "Clients.mdo").write_text(
"""
<mdclass:Catalog xmlns:mdclass="http://g5.1c.ru/v8/dt/metadata/mdclass">
<name>Clients</name>
</mdclass:Catalog>
""",
encoding="utf-8",
)
(document_dir / "Order.mdo").write_text(
"""
<mdclass:Document xmlns:mdclass="http://g5.1c.ru/v8/dt/metadata/mdclass">
<name>Order</name>
</mdclass:Document>
""",
encoding="utf-8",
)
(document_dir / "ObjectModule.bsl").write_text(
"""
Процедура Проведение(Отказ)
Запрос = Новый Запрос("ВЫБРАТЬ Clients.Ссылка ИЗ Справочник.Clients КАК Clients");
КонецПроцедуры
""",
encoding="utf-8",
)
client = TestClient(app)
project_id = f"flowchart-logic-{uuid4()}"
indexed = client.post("/projects/index", json={"path": str(tmp_path), "project_id": project_id})
assert indexed.status_code == 200
focus = client.get(
f"/projects/{project_id}/flowchart",
params={"focus": "Документ.Order", "depth": 2, "limit": 50},
)
assert focus.status_code == 200
payload = focus.json()
assert any(node["qualified_name"] == "Документ.Order" for node in payload["nodes"])
assert any(node["qualified_name"] == "Справочник.Clients" for node in payload["nodes"])
assert any(edge["kind"] == "READS_TABLE" for edge in payload["edges"])
def test_index_project_and_query_impact(tmp_path: Path):
module = tmp_path / "demo_module.bsl"
module.write_text(
"""
Процедура Проведение()
ПроверитьОстатки();
Движения.ОстаткиТоваров.Записать();
КонецПроцедуры
Процедура ПроверитьОстатки()
КонецПроцедуры
""",
encoding="utf-8",
)
client = TestClient(app)
indexed = client.post(
"/projects/index",
json={"path": str(tmp_path), "project_id": "demo-api"},
)
assert indexed.status_code == 200
assert indexed.json()["snapshot"]["project_id"] == "demo-api"
assert client.get("/storage").json()["status"] == "configured"
assert any(item["project_id"] == "demo-api" for item in client.get("/storage/snapshots").json())
loaded = client.post("/projects/demo-api/load")
assert loaded.status_code == 200
assert loaded.json()["snapshot"]["project_id"] == "demo-api"
snapshot = client.get("/projects/demo-api/snapshot")
assert snapshot.status_code == 200
assert snapshot.json()["node_count"] >= 3
exported = client.get("/projects/demo-api/snapshot/export")
assert exported.status_code == 200
assert exported.json()["project_id"] == "demo-api"
impact = client.get("/projects/demo-api/impact/Проведение")
assert impact.status_code == 200
payload = impact.json()
assert [node["name"] for node in payload["callees"]] == ["ПроверитьОстатки"]
assert [node["name"] for node in payload["writes"]] == ["ОстаткиТоваров"]
search = client.get("/projects/demo-api/search", params={"q": "Пров", "kind": "PROCEDURE"})
assert search.status_code == 200
assert search.json()["results"][0]["name"] == "Проведение"
routine_lineage = search.json()["results"][0]["lineage_id"]
versions = client.get("/projects/demo-api/versions")
assert versions.status_code == 200
assert any(item["lineage_id"] == routine_lineage for item in versions.json())
lineage_history = client.get(f"/versions/{routine_lineage}")
assert lineage_history.status_code == 200
assert lineage_history.json()[0]["lineage_id"] == routine_lineage
usage = client.get("/projects/demo-api/tables/usage", params={"table": "ОстаткиТоваров"})
assert usage.status_code == 200
assert usage.json()[0]["writers"][0]["name"] == "Проведение"
writes = client.get(
"/projects/demo-api/transactions/writes",
params={"target": "ОстаткиТоваров"},
)
assert writes.status_code == 200
assert writes.json()[0]["routine"]["name"] == "Проведение"
module.write_text(
"""
Процедура Проведение()
ПроверитьОстатки();
КонецПроцедуры
Процедура ПроверитьОстатки()
КонецПроцедуры
""",
encoding="utf-8",
)
incremental = client.post(
"/projects/demo-api/incremental/file",
json={"path": str(module)},
)
assert incremental.status_code == 200
assert incremental.json()["removed_edges"] >= 1
assert client.get(
"/projects/demo-api/transactions/writes",
params={"target": "ОстаткиТоваров"},
).json() == []
signal = client.post(
"/projects/demo-api/runtime/signals",
json={
"signal": {
"signal_id": "signal.1",
"lineage_id": routine_lineage,
"kind": "ERROR",
"duration_ms": 50.0,
}
},
)
assert signal.status_code == 200
runtime = client.get("/projects/demo-api/runtime/summary")
assert runtime.status_code == 200
assert runtime.json()[0]["node"]["name"] == "Проведение"
assert runtime.json()[0]["error_count"] == 1
knowledge = client.post(
"/knowledge",
json={
"record_id": "knowledge.demo",
"scope": "PROJECT",
"title": "Проведение документов",
"body": "Правила проведения заказа.",
"related_lineages": [routine_lineage],
},
)
assert knowledge.status_code == 200
search_knowledge = client.get("/knowledge/search", params={"q": "заказа"})
assert search_knowledge.status_code == 200
assert any(item["record_id"] == "knowledge.demo" for item in search_knowledge.json()["results"])
coverage = client.get("/projects/demo-api/knowledge/coverage")
assert coverage.status_code == 200
assert any(item["record_count"] == 1 for item in coverage.json())
def test_project_symbol_navigation_endpoints(tmp_path: Path):
module = tmp_path / "demo_module.bsl"
module.write_text(
"""
Процедура Проведение()
ПроверитьОстатки();
КонецПроцедуры
Процедура ПроверитьОстатки()
КонецПроцедуры
""",
encoding="utf-8",
)
client = TestClient(app)
project_id = f"symbols-api-{uuid4()}"
indexed = client.post("/projects/index", json={"path": str(tmp_path), "project_id": project_id})
assert indexed.status_code == 200
symbols = client.get(f"/projects/{project_id}/symbols", params={"q": "Проверить", "kind": "PROCEDURE"})
assert symbols.status_code == 200
symbol_payload = symbols.json()
assert symbol_payload[0]["node"]["name"] == "ПроверитьОстатки"
assert symbol_payload[0]["source"]["source_path"].endswith("demo_module.bsl")
lineage_id = symbol_payload[0]["node"]["lineage_id"]
definition = client.get(f"/projects/{project_id}/symbols/definition", params={"lineage_id": lineage_id})
assert definition.status_code == 200
assert definition.json()["node"]["qualified_name"] == "demo_module.ПроверитьОстатки"
assert definition.json()["source"]["line_start"] is not None
references = client.get(f"/projects/{project_id}/symbols/references", params={"lineage_id": lineage_id})
assert references.status_code == 200
reference_payload = references.json()
assert reference_payload["symbol"]["node"]["name"] == "ПроверитьОстатки"
assert any(
reference["kind"] == "CALLS"
and reference["source"]["name"] == "Проведение"
and reference["direction"] == "incoming"
for reference in reference_payload["references"]
)
def test_persisted_project_endpoints_load_snapshot_after_memory_clear(tmp_path: Path):
project_id = f"persisted-api-{uuid4()}"
module = tmp_path / "persisted_module.bsl"
module.write_text(
"""
Процедура Проведение()
ПроверитьОстатки();
Движения.ОстаткиТоваров.Записать();
КонецПроцедуры
Процедура ПроверитьОстатки()
КонецПроцедуры
""",
encoding="utf-8",
)
client = TestClient(app)
indexed = client.post("/projects/index", json={"path": str(tmp_path), "project_id": project_id})
assert indexed.status_code == 200
main._snapshots.pop(project_id, None)
main._graphs.pop(project_id, None)
assert client.get(f"/projects/{project_id}/snapshot").status_code == 200
search = client.get(f"/projects/{project_id}/search", params={"q": "Пров", "kind": "PROCEDURE"})
assert search.status_code == 200
assert search.json()["results"][0]["name"] == "Проведение"
assert client.get(f"/projects/{project_id}/review").status_code == 200
report = client.get(f"/projects/{project_id}/report")
assert report.status_code == 200
assert report.json()["project_id"] == project_id
def test_xml_ui_forms_endpoint(tmp_path: Path):
(tmp_path / "form.xml").write_text(
"""
<Form name="ФормаДокумента" qualifiedName="Документ.Заказ.ФормаДокумента">
<Command name="Провести" qualifiedName="Документ.Заказ.ФормаДокумента.Провести" action="ПровестиКоманда" />
</Form>
""",
encoding="utf-8",
)
(tmp_path / "form_module.bsl").write_text(
"Процедура ПровестиКоманда()\nКонецПроцедуры\n",
encoding="utf-8",
)
client = TestClient(app)
indexed = client.post(
"/projects/index",
json={"path": str(tmp_path), "project_id": "demo-api-ui"},
)
assert indexed.status_code == 200
forms = client.get("/projects/demo-api-ui/ui/forms")
assert forms.status_code == 200
assert forms.json()[0]["commands"][0]["name"] == "Провести"
handlers = forms.json()[0]["command_handlers"]
assert next(iter(handlers.values()))["name"] == "ПровестиКоманда"
def test_object_ui_endpoint_filters_forms_by_1c_object(tmp_path: Path):
(tmp_path / "metadata.xml").write_text(
"""
<Configuration>
<Document name="ЗаказПокупателя" qualifiedName="Документ.ЗаказПокупателя">
<Attribute name="Контрагент" qualifiedName="Документ.ЗаказПокупателя.Контрагент" />
<TabularSection name="Товары" qualifiedName="Документ.ЗаказПокупателя.Товары">
<Attribute name="Номенклатура" qualifiedName="Документ.ЗаказПокупателя.Товары.Номенклатура" />
</TabularSection>
<Form name="ФормаДокумента" qualifiedName="Документ.ЗаказПокупателя.ФормаДокумента">
<Command name="Провести" qualifiedName="Документ.ЗаказПокупателя.ФормаДокумента.Провести" action="ПровестиКоманда" />
</Form>
</Document>
<Document name="СчетПокупателю" qualifiedName="Документ.СчетПокупателю">
<Form name="ФормаСчета" qualifiedName="Документ.СчетПокупателю.ФормаСчета" />
</Document>
</Configuration>
""",
encoding="utf-8",
)
module = tmp_path / "Documents" / "ЗаказПокупателя" / "Ext" / "ObjectModule.bsl"
module.parent.mkdir(parents=True)
module.write_text("Процедура ПровестиКоманда()\nКонецПроцедуры\n", encoding="utf-8")
client = TestClient(app)
indexed = client.post(
"/projects/index",
json={"path": str(tmp_path), "project_id": "demo-api-object-ui"},
)
assert indexed.status_code == 200
response = client.get("/projects/demo-api-object-ui/objects/ui/Документ.ЗаказПокупателя")
assert response.status_code == 200
payload = response.json()
assert payload["object"]["name"] == "ЗаказПокупателя"
assert [form["form"]["name"] for form in payload["forms"]] == ["ФормаДокумента"]
assert payload["forms"][0]["commands"][0]["name"] == "Провести"
assert next(iter(payload["forms"][0]["command_handlers"].values()))["name"] == "ПровестиКоманда"
def test_object_impact_endpoint(tmp_path: Path):
(tmp_path / "metadata.xml").write_text(
"""
<Configuration>
<Document name="ЗаказПокупателя" qualifiedName="Документ.ЗаказПокупателя">
<Attribute name="Контрагент" qualifiedName="Документ.ЗаказПокупателя.Контрагент" />
<TabularSection name="Товары" qualifiedName="Документ.ЗаказПокупателя.Товары">
<Attribute name="Номенклатура" qualifiedName="Документ.ЗаказПокупателя.Товары.Номенклатура" />
</TabularSection>
<Form name="ФормаДокумента" qualifiedName="Документ.ЗаказПокупателя.ФормаДокумента">
<Command name="Провести" qualifiedName="Документ.ЗаказПокупателя.ФормаДокумента.Провести" action="ПровестиКоманда" />
</Form>
</Document>
<Role name="Менеджер" qualifiedName="Роль.Менеджер">
<Right object="Документ.ЗаказПокупателя" read="true" write="true" post="true" />
</Role>
</Configuration>
""",
encoding="utf-8",
)
module = tmp_path / "Documents" / "ЗаказПокупателя" / "Ext" / "ObjectModule.bsl"
module.parent.mkdir(parents=True)
module.write_text(
"""
Процедура Проведение()
Движения.ОстаткиТоваров.Записать();
КонецПроцедуры
Процедура ПровестиКоманда()
КонецПроцедуры
""",
encoding="utf-8",
)
client = TestClient(app)
indexed = client.post(
"/projects/index",
json={"path": str(tmp_path), "project_id": "demo-object-impact"},
)
assert indexed.status_code == 200
response = client.get(
"/projects/demo-object-impact/objects/impact/Документ.ЗаказПокупателя"
)
assert response.status_code == 200
payload = response.json()
assert payload["object"]["name"] == "ЗаказПокупателя"
assert payload["modules"][0]["name"] == "ObjectModule"
assert [routine["name"] for routine in payload["routines"]] == ["ПровестиКоманда", "Проведение"]
assert payload["forms"][0]["name"] == "ФормаДокумента"
assert payload["commands"][0]["name"] == "Провести"
assert payload["attributes"][0]["name"] == "Контрагент"
assert payload["tabular_sections"][0]["name"] == "Товары"
assert next(iter(payload["tabular_section_columns"].values()))[0]["name"] == "Номенклатура"
assert payload["roles"][0]["name"] == "Менеджер"
assert payload["role_access"][0]["permissions"]["post"] == "true"
assert payload["writes"][0]["name"] == "ОстаткиТоваров"
object_access = client.get(
"/projects/demo-object-impact/access/objects/Документ.ЗаказПокупателя/roles"
)
assert object_access.status_code == 200
assert object_access.json()["grants"][0]["role"]["name"] == "Менеджер"
assert object_access.json()["grants"][0]["permissions"]["write"] == "true"
role_access = client.get("/projects/demo-object-impact/access/roles/Роль.Менеджер/objects")
assert role_access.status_code == 200
assert role_access.json()["objects"][0]["qualified_name"] == "Документ.ЗаказПокупателя"
assert role_access.json()["grants"][0]["permissions"]["post"] == "true"
def test_object_attributes_endpoint(tmp_path: Path):
(tmp_path / "metadata.xml").write_text(
"""
<Configuration>
<Document name="ЗаказПокупателя" qualifiedName="Документ.ЗаказПокупателя">
<Attribute name="Контрагент" qualifiedName="Документ.ЗаказПокупателя.Контрагент" />
<Attribute name="СуммаДокумента" qualifiedName="Документ.ЗаказПокупателя.СуммаДокумента" />
</Document>
</Configuration>
""",
encoding="utf-8",
)
client = TestClient(app)
indexed = client.post(
"/projects/index",
json={"path": str(tmp_path), "project_id": "demo-object-attributes"},
)
assert indexed.status_code == 200
response = client.get("/projects/demo-object-attributes/objects/attributes/Документ.ЗаказПокупателя")
assert response.status_code == 200
assert [row["name"] for row in response.json()["results"]] == ["Контрагент", "СуммаДокумента"]
def test_authoring_context_and_completion_preview(tmp_path: Path):
project_id = f"authoring-api-{uuid4()}"
(tmp_path / "metadata.xml").write_text(
"""
<Configuration>
<Document name="ЗаказПокупателя" qualifiedName="Документ.ЗаказПокупателя">
<Attribute name="Контрагент" qualifiedName="Документ.ЗаказПокупателя.Контрагент" />
<TabularSection name="Товары" qualifiedName="Документ.ЗаказПокупателя.Товары" />
</Document>
</Configuration>
""",
encoding="utf-8",
)
module = tmp_path / "Documents" / "ЗаказПокупателя" / "Ext" / "ObjectModule.bsl"
module.parent.mkdir(parents=True)
source_text = """
Процедура Проведение(Отказ, РежимПроведения)
Сумма = 0;
Движения.ОстаткиТоваров.Записать();
КонецПроцедуры
"""
module.write_text(source_text, encoding="utf-8")
client = TestClient(app)
indexed = client.post("/projects/index", json={"path": str(tmp_path), "project_id": project_id})
assert indexed.status_code == 200
create_authoring_session(client, project_id, "task.authoring", "session.authoring")
create_authoring_session(client, project_id, "task.rollback", "session.rollback")
context = client.post(
f"/projects/{project_id}/authoring/context",
json={
"object_name": "Документ.ЗаказПокупателя",
"routine_name": "Проведение",
"cursor_line": 3,
"source_text": source_text,
},
)
assert context.status_code == 200
payload = context.json()
assert payload["object"]["qualified_name"] == "Документ.ЗаказПокупателя"
assert payload["routine"]["name"] == "Проведение"
assert "Сумма" in payload["local_variables"]
assert "Отказ" in payload["parameters"]
assert payload["object_attributes"][0]["name"] == "Контрагент"
assert payload["tabular_sections"][0]["name"] == "Товары"
assert "ЗначениеЗаполнено" in payload["available_methods"]
preview = client.post(
f"/projects/{project_id}/authoring/completion-preview",
json={
"object_name": "Документ.ЗаказПокупателя",
"routine_name": "Проведение",
"cursor_line": 3,
"source_text": source_text,
"intent": "fill-check",
},
)
assert preview.status_code == 200
preview_payload = preview.json()
assert preview_payload["allowed"] is False
assert "ЗначениеЗаполнено(Контрагент)" in preview_payload["insert_text"]
assert any(check["name"] == "apply" and check["status"] == "BLOCKED" for check in preview_payload["checks"])
assert preview_payload["semantic_diff"][0]["kind"] == "ADD"
html5_preview = client.post(
f"/html5/projects/{project_id}/authoring/completion-preview",
data={
"object_name": "Документ.ЗаказПокупателя",
"routine_name": "Проведение",
"cursor_line": "3",
"source_text": source_text,
"intent": "fill-check",
},
)
assert html5_preview.status_code == 200
assert "text/html" in html5_preview.headers["content-type"]
assert "data-html5-authoring-preview-result" in html5_preview.text
assert "ЗначениеЗаполнено(Контрагент)" in html5_preview.text
assert "BLOCKED" in html5_preview.text
assert "ADD" in html5_preview.text
assert "<html" not in html5_preview.text
html5_diff_preview = client.post(
f"/html5/projects/{project_id}/authoring/semantic-diff-preview",
data={
"routine_name": "Проведение",
"source_path": str(module),
"original_text": source_text,
"proposed_text": source_text.replace(
" Сумма = 0;",
" Сумма = 0;\n Если Отказ Тогда\n Возврат;\n КонецЕсли;",
),
"task_id": "task.authoring",
"session_id": "session.authoring",
"user_id": "dev.ivan",
},
)
assert html5_diff_preview.status_code == 200
assert "text/html" in html5_diff_preview.headers["content-type"]
assert "data-html5-authoring-diff-result" in html5_diff_preview.text
assert "data-html5-authoring-apply-form" in html5_diff_preview.text
assert f'hx-post="/html5/projects/{project_id}/authoring/apply-change-set"' in html5_diff_preview.text
assert "data-html5-authoring-apply-result" in html5_diff_preview.text
assert "Diff preview" in html5_diff_preview.text
assert "Если Отказ Тогда" in html5_diff_preview.text
assert "task-session" in html5_diff_preview.text
assert "BLOCKED" in html5_diff_preview.text
assert "<html" not in html5_diff_preview.text
html5_blocked_change_apply = client.post(
f"/html5/projects/{project_id}/authoring/apply-change-set",
data={
"routine_name": "Проведение",
"source_path": str(module),
"original_text": source_text,
"proposed_text": source_text.replace(
" Сумма = 0;",
" Сумма = 0;\n Если Отказ Тогда\n Возврат;\n КонецЕсли;",
),
"task_id": "task.authoring",
"session_id": "session.authoring",
"user_id": "dev.ivan",
"expected_next_version_id": "wrong-version",
},
)
assert html5_blocked_change_apply.status_code == 200
assert "text/html" in html5_blocked_change_apply.headers["content-type"]
assert "data-html5-authoring-apply-result" in html5_blocked_change_apply.text
assert "Expected version id does not match current preview" in html5_blocked_change_apply.text
assert "<html" not in html5_blocked_change_apply.text
diff_preview = client.post(
f"/projects/{project_id}/authoring/semantic-diff-preview",
json={
"routine_name": "Проведение",
"source_path": str(module),
"original_text": source_text,
"proposed_text": source_text.replace(
" Сумма = 0;",
" Сумма = 0;\n Если Отказ Тогда\n Возврат;\n КонецЕсли;",
),
"task_id": "task.authoring",
"session_id": "session.authoring",
"user_id": "dev.ivan",
},
)
assert diff_preview.status_code == 200
diff_payload = diff_preview.json()
assert diff_payload["changed"] is True
assert diff_payload["added_lines"] == 3
assert diff_payload["removed_lines"] == 0
assert diff_payload["target"]["name"] == "Проведение"
assert diff_payload["version_preview"]["task_id"] == "task.authoring"
assert diff_payload["version_preview"]["apply_available"] is False
assert any(row["name"] == "task-session" and row["status"] == "OK" for row in diff_payload["checks"])
assert any(row["name"] == "rbac" and row["status"] == "OK" for row in diff_payload["checks"])
assert any(row["name"] == "apply" and row["status"] == "BLOCKED" for row in diff_payload["checks"])
apply_response = client.post(
f"/projects/{project_id}/authoring/apply-change-set",
json={
"routine_name": "Проведение",
"source_path": str(module),
"original_text": source_text,
"proposed_text": source_text.replace(
" Сумма = 0;",
" Сумма = 0;\n Если Отказ Тогда\n Возврат;\n КонецЕсли;",
),
"task_id": "task.authoring",
"session_id": "session.authoring",
"user_id": "dev.ivan",
"expected_next_version_id": diff_payload["version_preview"]["next_version_id"],
"approved_by": "dev.ivan",
"approval_note": "preview checked",
},
)
assert apply_response.status_code == 200
apply_payload = apply_response.json()
assert apply_payload["status"] == "APPLIED_TO_WORKSPACE"
assert apply_payload["production_applied"] is False
assert apply_payload["version"]["version_id"] == diff_payload["version_preview"]["next_version_id"]
assert apply_payload["version"]["payload"]["kind"] == "AUTHORING_CHANGE_SET"
assert "Если Отказ Тогда" in apply_payload["version"]["payload"]["proposed_text"]
assert client.get(f"/versions/{apply_payload['version']['lineage_id']}").status_code == 200
changes = client.get(f"/projects/{project_id}/authoring/changes")
assert changes.status_code == 200
assert changes.json()[0]["change_id"] == apply_payload["change_id"]
assert changes.json()[0]["added_lines"] == 3
assert changes.json()[0]["production_applied"] is False
html5_changes = client.get(f"/html5/projects/{project_id}/authoring/changes")
assert html5_changes.status_code == 200
assert "text/html" in html5_changes.headers["content-type"]
assert "data-html5-authoring-changes" in html5_changes.text
assert "data-html5-authoring-detail" in html5_changes.text
assert 'hx-target="[data-html5-authoring-detail]"' in html5_changes.text
assert apply_payload["change_id"] in html5_changes.text
assert "<html" not in html5_changes.text
rollback = client.get(
f"/projects/{project_id}/authoring/changes/{apply_payload['change_id']}/rollback-preview"
)
assert rollback.status_code == 200
rollback_payload = rollback.json()
assert rollback_payload["original_version_id"] == apply_payload["version"]["version_id"]
assert rollback_payload["apply_available"] is True
assert any(line["kind"] == "REMOVE" for line in rollback_payload["semantic_diff"])
assert any(check["name"] == "apply" and check["status"] == "READY" for check in rollback_payload["checks"])
html5_detail = client.get(f"/html5/projects/{project_id}/authoring/changes/{apply_payload['change_id']}")
assert html5_detail.status_code == 200
assert "text/html" in html5_detail.headers["content-type"]
assert "data-html5-authoring-detail" in html5_detail.text
assert "Rollback preview" in html5_detail.text
assert "data-html5-authoring-rollback-form" in html5_detail.text
assert f'hx-post="/html5/projects/{project_id}/authoring/changes/{apply_payload["change_id"]}/apply-rollback"' in html5_detail.text
assert "data-html5-authoring-result" in html5_detail.text
assert "READY" in html5_detail.text
assert "REMOVE" in html5_detail.text
assert apply_payload["change_id"] in html5_detail.text
assert "<html" not in html5_detail.text
html5_blocked_apply = client.post(
f"/html5/projects/{project_id}/authoring/changes/{apply_payload['change_id']}/apply-rollback",
data={
"expected_rollback_version_id": rollback_payload["rollback_version_id"],
"approved_by": "dev.ivan",
},
)
assert html5_blocked_apply.status_code == 200
assert "text/html" in html5_blocked_apply.headers["content-type"]
assert "data-html5-authoring-result" in html5_blocked_apply.text
assert "Task id is required" in html5_blocked_apply.text
assert "<html" not in html5_blocked_apply.text
rollback_apply = client.post(
f"/projects/{project_id}/authoring/changes/{apply_payload['change_id']}/apply-rollback",
json={
"expected_rollback_version_id": rollback_payload["rollback_version_id"],
"approved_by": "dev.ivan",
"approval_note": "rollback preview checked",
"task_id": "task.rollback",
"session_id": "session.rollback",
},
)
assert rollback_apply.status_code == 200
rollback_apply_payload = rollback_apply.json()
assert rollback_apply_payload["status"] == "ROLLED_BACK_TO_WORKSPACE"
assert rollback_apply_payload["production_applied"] is False
assert rollback_apply_payload["version"]["version_id"] == rollback_payload["rollback_version_id"]
assert rollback_apply_payload["version"]["payload"]["kind"] == "AUTHORING_ROLLBACK"
assert rollback_apply_payload["rollback_change_id"].startswith("rollback.")
version_diff = client.get(
f"/versions/{apply_payload['version']['lineage_id']}/diff",
params={
"from_version_id": apply_payload["version"]["version_id"],
"to_version_id": rollback_apply_payload["version"]["version_id"],
},
)
assert version_diff.status_code == 200
version_diff_payload = version_diff.json()
assert version_diff_payload["changed"] is True
assert any(entry["path"] == "kind" for entry in version_diff_payload["entries"])
rollback_production_apply = client.post(
f"/projects/{project_id}/authoring/changes/{apply_payload['change_id']}/apply-rollback",
json={
"expected_rollback_version_id": rollback_payload["rollback_version_id"],
"approved_by": "dev.ivan",
"apply_to_production": True,
},
)
assert rollback_production_apply.status_code == 403
production_apply = client.post(
f"/projects/{project_id}/authoring/apply-change-set",
json={
"routine_name": "Проведение",
"source_path": str(module),
"original_text": source_text,
"proposed_text": source_text + "\n",
"expected_next_version_id": diff_payload["version_preview"]["next_version_id"],
"approved_by": "dev.ivan",
"apply_to_production": True,
},
)
assert production_apply.status_code == 403
def test_authoring_apply_requires_active_task_session(tmp_path: Path):
project_id = f"authoring-guard-{uuid4()}"
module = tmp_path / "guard_module.bsl"
source_text = "Процедура Проверить()\nКонецПроцедуры\n"
module.write_text(source_text, encoding="utf-8")
client = TestClient(app)
indexed = client.post("/projects/index", json={"path": str(tmp_path), "project_id": project_id})
assert indexed.status_code == 200
preview = client.post(
f"/projects/{project_id}/authoring/semantic-diff-preview",
json={
"routine_name": "Проверить",
"source_path": str(module),
"original_text": source_text,
"proposed_text": source_text.replace("КонецПроцедуры", " Возврат;\nКонецПроцедуры"),
},
)
assert preview.status_code == 200
preview_payload = preview.json()
assert any(check["name"] == "task-session" and check["status"] == "BLOCKED" for check in preview_payload["checks"])
apply_response = client.post(
f"/projects/{project_id}/authoring/apply-change-set",
json={
"routine_name": "Проверить",
"source_path": str(module),
"original_text": source_text,
"proposed_text": source_text.replace("КонецПроцедуры", " Возврат;\nКонецПроцедуры"),
"expected_next_version_id": preview_payload["version_preview"]["next_version_id"],
"approved_by": "dev.ivan",
},
)
assert apply_response.status_code == 409
blocked = apply_response.json()["detail"]["blocked_checks"]
assert blocked[0]["name"] == "task-session"
def test_authoring_apply_requires_rbac_permission(tmp_path: Path):
project_id = f"authoring-rbac-{uuid4()}"
module = tmp_path / "rbac_module.bsl"
source_text = "Процедура Проверить()\nКонецПроцедуры\n"
module.write_text(source_text, encoding="utf-8")
client = TestClient(app)
indexed = client.post("/projects/index", json={"path": str(tmp_path), "project_id": project_id})
assert indexed.status_code == 200
user = client.post("/collaboration/users", json={"user_id": "viewer.ivan", "display_name": "Viewer"})
assert user.status_code == 200
grant = client.post("/security/users/viewer.ivan/roles/viewer")
assert grant.status_code == 200
task = client.post(
"/collaboration/tasks",
json={"task_id": "task.rbac", "project_id": project_id, "title": "RBAC authoring", "assignee_user_id": "viewer.ivan"},
)
assert task.status_code == 200
session = client.post(
"/collaboration/sessions",
json={"session": {"session_id": "session.rbac", "task_id": "task.rbac", "user_id": "viewer.ivan"}},
)
assert session.status_code == 200
preview = client.post(
f"/projects/{project_id}/authoring/semantic-diff-preview",
json={
"routine_name": "Проверить",
"source_path": str(module),
"original_text": source_text,
"proposed_text": source_text.replace("КонецПроцедуры", " Возврат;\nКонецПроцедуры"),
"task_id": "task.rbac",
"session_id": "session.rbac",
"user_id": "viewer.ivan",
},
)
assert preview.status_code == 200
preview_payload = preview.json()
assert any(check["name"] == "rbac" and check["status"] == "BLOCKED" for check in preview_payload["checks"])
apply_response = client.post(
f"/projects/{project_id}/authoring/apply-change-set",
json={
"routine_name": "Проверить",
"source_path": str(module),
"original_text": source_text,
"proposed_text": source_text.replace("КонецПроцедуры", " Возврат;\nКонецПроцедуры"),
"task_id": "task.rbac",
"session_id": "session.rbac",
"user_id": "viewer.ivan",
"expected_next_version_id": preview_payload["version_preview"]["next_version_id"],
"approved_by": "viewer.ivan",
},
)
assert apply_response.status_code == 409
assert any(check["name"] == "rbac" for check in apply_response.json()["detail"]["blocked_checks"])
def test_authoring_apply_blocks_sensitive_privacy_context(tmp_path: Path):
project_id = f"authoring-privacy-{uuid4()}"
(tmp_path / "metadata.xml").write_text(
"""
<Configuration>
<Document name="ЗаказПокупателя" qualifiedName="Документ.ЗаказПокупателя">
<Attribute name="Телефон" qualifiedName="Документ.ЗаказПокупателя.Телефон" />
</Document>
</Configuration>
""",
encoding="utf-8",
)
module = tmp_path / "Documents" / "ЗаказПокупателя" / "Ext" / "ObjectModule.bsl"
module.parent.mkdir(parents=True)
source_text = "Процедура Проверить()\n Сообщить(Телефон);\nКонецПроцедуры\n"
module.write_text(source_text, encoding="utf-8")
client = TestClient(app)
indexed = client.post("/projects/index", json={"path": str(tmp_path), "project_id": project_id})
assert indexed.status_code == 200
create_authoring_session(client, project_id, "task.privacy", "session.privacy")
schema = client.get(f"/projects/{project_id}/objects/schema/Документ.ЗаказПокупателя")
phone_lineage = schema.json()["attributes"][0]["lineage_id"]
marker = client.post(
f"/projects/{project_id}/privacy/markers",
json={"target_id": phone_lineage, "classification": "PERSONAL_DATA", "reason": "phone number"},
)
assert marker.status_code == 200
preview = client.post(
f"/projects/{project_id}/authoring/semantic-diff-preview",
json={
"object_name": "Документ.ЗаказПокупателя",
"routine_name": "Проверить",
"source_path": str(module),
"original_text": source_text,
"proposed_text": source_text.replace("Сообщить(Телефон);", "Сообщить(Телефон);\n Возврат;"),
"task_id": "task.privacy",
"session_id": "session.privacy",
"user_id": "dev.ivan",
},
)
assert preview.status_code == 200
preview_payload = preview.json()
assert any(check["name"] == "privacy" and check["status"] == "BLOCKED" for check in preview_payload["checks"])
apply_response = client.post(
f"/projects/{project_id}/authoring/apply-change-set",
json={
"object_name": "Документ.ЗаказПокупателя",
"routine_name": "Проверить",
"source_path": str(module),
"original_text": source_text,
"proposed_text": source_text.replace("Сообщить(Телефон);", "Сообщить(Телефон);\n Возврат;"),
"task_id": "task.privacy",
"session_id": "session.privacy",
"user_id": "dev.ivan",
"expected_next_version_id": preview_payload["version_preview"]["next_version_id"],
"approved_by": "dev.ivan",
},
)
assert apply_response.status_code == 409
assert any(check["name"] == "privacy" for check in apply_response.json()["detail"]["blocked_checks"])
def test_authoring_metadata_object_preview_and_apply(tmp_path: Path):
project_id = f"metadata-authoring-api-{uuid4()}"
(tmp_path / "metadata.xml").write_text(
"""
<Configuration>
<Catalog name="Контрагенты" qualifiedName="Справочник.Контрагенты" />
<Catalog name="Номенклатура" qualifiedName="Справочник.Номенклатура" />
</Configuration>
""",
encoding="utf-8",
)
client = TestClient(app)
indexed = client.post("/projects/index", json={"path": str(tmp_path), "project_id": project_id})
assert indexed.status_code == 200
create_authoring_session(client, project_id, "task.metadata", "session.metadata")
create_authoring_session(client, project_id, "task.metadata.rollback", "session.metadata.rollback")
draft = {
"object_kind": "DOCUMENT",
"name": "ЗаявкаНаЗакупку",
"synonym": "Заявка на закупку",
"attributes": [
{"name": "Контрагент", "type": "СправочникСсылка.Контрагенты", "required": True}
],
"tabular_sections": [
{
"name": "Товары",
"attributes": [
{"name": "Номенклатура", "type": "СправочникСсылка.Номенклатура"},
{"name": "Количество", "type": "Число"},
],
}
],
"forms": ["ФормаДокумента"],
"commands": [{"name": "Заполнить", "handler": "ЗаполнитьКоманда"}],
"task_id": "task.metadata",
"session_id": "session.metadata",
"user_id": "dev.ivan",
}
preview = client.post(f"/projects/{project_id}/authoring/metadata-object-preview", json=draft)
assert preview.status_code == 200
preview_payload = preview.json()
assert preview_payload["target"]["qualified_name"] == "Документ.ЗаявкаНаЗакупку"
assert preview_payload["changed"] is True
assert preview_payload["version_preview"]["apply_available"] is True
assert any(check["name"] == "task-session" and check["status"] == "OK" for check in preview_payload["checks"])
assert any(check["name"] == "rbac" and check["status"] == "OK" for check in preview_payload["checks"])
assert any("Реквизит.Контрагент" in row["text"] for row in preview_payload["semantic_diff"])
assert any("ТабличнаяЧасть.Товары" in row["text"] for row in preview_payload["semantic_diff"])
assert any("Команда.Заполнить" in row["text"] for row in preview_payload["semantic_diff"])
html5_preview = client.post(
f"/html5/projects/{project_id}/authoring/metadata-object-preview",
data={
"object_kind": "DOCUMENT",
"name": "ЗаявкаНаЗакупкуHtml5",
"synonym": "Заявка на закупку HTML5",
"attributes": "Контрагент:СправочникСсылка.Контрагенты",
"tabular_sections": "Товары[Номенклатура:СправочникСсылка.Номенклатура;Количество:Число]",
"forms": "ФормаДокумента",
"commands": "Заполнить:ЗаполнитьКоманда",
"task_id": "task.metadata",
"session_id": "session.metadata",
"user_id": "dev.ivan",
},
)
assert html5_preview.status_code == 200
assert "text/html" in html5_preview.headers["content-type"]
assert "data-html5-metadata-preview-result" in html5_preview.text
assert "data-html5-metadata-apply-form" in html5_preview.text
assert f'hx-post="/html5/projects/{project_id}/authoring/apply-metadata-object"' in html5_preview.text
assert "Документ.ЗаявкаНаЗакупкуHtml5" in html5_preview.text
assert "Реквизит.Контрагент" in html5_preview.text
assert "<html" not in html5_preview.text
html5_blocked_apply = client.post(
f"/html5/projects/{project_id}/authoring/apply-metadata-object",
data={
"object_kind": "DOCUMENT",
"name": "ЗаявкаНаЗакупкуHtml5",
"synonym": "Заявка на закупку HTML5",
"attributes": "Контрагент:СправочникСсылка.Контрагенты",
"tabular_sections": "Товары[Номенклатура:СправочникСсылка.Номенклатура;Количество:Число]",
"forms": "ФормаДокумента",
"commands": "Заполнить:ЗаполнитьКоманда",
"task_id": "task.metadata",
"session_id": "session.metadata",
"user_id": "dev.ivan",
"expected_next_version_id": "wrong-version",
"approved_by": "dev.ivan",
},
)
assert html5_blocked_apply.status_code == 200
assert "text/html" in html5_blocked_apply.headers["content-type"]
assert "data-html5-metadata-apply-result" in html5_blocked_apply.text
assert "Expected version id does not match current metadata preview" in html5_blocked_apply.text
assert "<html" not in html5_blocked_apply.text
apply_response = client.post(
f"/projects/{project_id}/authoring/apply-metadata-object",
json={
**draft,
"expected_next_version_id": preview_payload["version_preview"]["next_version_id"],
"approved_by": "dev.ivan",
"approval_note": "metadata draft checked",
},
)
assert apply_response.status_code == 200
apply_payload = apply_response.json()
assert apply_payload["status"] == "METADATA_DRAFT_APPLIED_TO_WORKSPACE"
assert apply_payload["version"]["payload"]["kind"] == "METADATA_OBJECT_DRAFT"
assert apply_payload["version"]["payload"]["draft"]["name"] == "ЗаявкаНаЗакупку"
changes = client.get(f"/projects/{project_id}/authoring/changes")
assert changes.status_code == 200
assert changes.json()[0]["change_id"] == apply_payload["change_id"]
assert changes.json()[0]["target"]["qualified_name"] == "Документ.ЗаявкаНаЗакупку"
lineage = apply_payload["version"]["lineage_id"]
versions = client.get(f"/versions/{lineage}")
assert versions.status_code == 200
assert any(version["version_id"] == apply_payload["version"]["version_id"] for version in versions.json())
rollback = client.get(
f"/projects/{project_id}/authoring/changes/{apply_payload['change_id']}/rollback-preview"
)
assert rollback.status_code == 200
rollback_payload = rollback.json()
assert rollback_payload["apply_available"] is True
assert rollback_payload["target"]["qualified_name"] == "Документ.ЗаявкаНаЗакупку"
assert any(line["kind"] == "REMOVE" and "Реквизит.Контрагент" in line["text"] for line in rollback_payload["semantic_diff"])
rollback_apply = client.post(
f"/projects/{project_id}/authoring/changes/{apply_payload['change_id']}/apply-rollback",
json={
"expected_rollback_version_id": rollback_payload["rollback_version_id"],
"approved_by": "dev.ivan",
"approval_note": "metadata rollback checked",
"task_id": "task.metadata.rollback",
"session_id": "session.metadata.rollback",
},
)
assert rollback_apply.status_code == 200
rollback_apply_payload = rollback_apply.json()
assert rollback_apply_payload["status"] == "ROLLED_BACK_TO_WORKSPACE"
assert rollback_apply_payload["version"]["payload"]["kind"] == "METADATA_OBJECT_DRAFT_ROLLBACK"
assert rollback_apply_payload["version"]["payload"]["draft"]["name"] == "ЗаявкаНаЗакупку"
production_apply = client.post(
f"/projects/{project_id}/authoring/apply-metadata-object",
json={
**draft,
"expected_next_version_id": preview_payload["version_preview"]["next_version_id"],
"approved_by": "dev.ivan",
"apply_to_production": True,
},
)
assert production_apply.status_code == 403
invalid_preview = client.post(
f"/projects/{project_id}/authoring/metadata-object-preview",
json={
**draft,
"name": "123Недопустимо",
},
)
assert invalid_preview.status_code == 422
duplicate_preview = client.post(
f"/projects/{project_id}/authoring/metadata-object-preview",
json={
**draft,
"attributes": [
{"name": "Контрагент", "type": "СправочникСсылка.Контрагенты"},
{"name": "контрагент", "type": "СправочникСсылка.Контрагенты"},
],
},
)
assert duplicate_preview.status_code == 422
duplicate_form_preview = client.post(
f"/projects/{project_id}/authoring/metadata-object-preview",
json={
**draft,
"forms": ["ФормаДокумента", "формадокумента"],
},
)
assert duplicate_form_preview.status_code == 422
duplicate_command_preview = client.post(
f"/projects/{project_id}/authoring/metadata-object-preview",
json={
**draft,
"commands": [
{"name": "Заполнить", "handler": "ЗаполнитьКоманда"},
{"name": "заполнить", "handler": "ЗаполнитьКоманда"},
],
},
)
assert duplicate_command_preview.status_code == 422
def test_authoring_metadata_object_preview_supports_catalog_types(tmp_path: Path):
(tmp_path / "metadata.xml").write_text("<Configuration />", encoding="utf-8")
client = TestClient(app)
project_id = f"metadata-kind-catalog-{uuid4()}"
indexed = client.post("/projects/index", json={"path": str(tmp_path), "project_id": project_id})
assert indexed.status_code == 200
expected_targets = {
"COMMON_MODULE": "ОбщийМодуль.ИнтеграцияСервер",
"REPORT": "Отчет.АнализПродаж",
"DATA_PROCESSOR": "Обработка.ЗагрузкаДанных",
"INFORMATION_REGISTER": "РегистрСведений.НастройкиОбмена",
"ACCUMULATION_REGISTER": "РегистрНакопления.ОстаткиТоваров",
"ACCOUNTING_REGISTER": "РегистрБухгалтерии.Хозрасчетный",
"CALCULATION_REGISTER": "РегистрРасчета.Начисления",
"BUSINESS_PROCESS": "БизнесПроцесс.СогласованиеЗаявки",
"TASK": "Задача.ЗадачаИсполнителя",
"CHART_OF_ACCOUNTS": "ПланСчетов.Управленческий",
}
for object_kind, qualified_name in expected_targets.items():
response = client.post(
f"/projects/{project_id}/authoring/metadata-object-preview",
json={
"object_kind": object_kind,
"name": qualified_name.split(".", 1)[1],
"synonym": qualified_name.split(".", 1)[1],
"attributes": [{"name": "Комментарий", "type": "Строка"}],
"forms": ["ФормаОбъекта"],
"commands": [{"name": "Обновить", "handler": "ОбновитьКоманда"}],
},
)
assert response.status_code == 200
payload = response.json()
assert payload["target"]["qualified_name"] == qualified_name
assert payload["changed"] is True
def test_authoring_context_includes_query_tables(tmp_path: Path):
project_id = f"authoring-query-context-{uuid4()}"
module = tmp_path / "demo_module.bsl"
source_text = """
Процедура ПроверитьОстатки()
Запрос = Новый Запрос;
Запрос.Текст =
"ВЫБРАТЬ
Остатки.Номенклатура
ИЗ
РегистрНакопления.ОстаткиТоваров КАК Остатки";
КонецПроцедуры
"""
module.write_text(source_text, encoding="utf-8")
client = TestClient(app)
indexed = client.post("/projects/index", json={"path": str(tmp_path), "project_id": project_id})
assert indexed.status_code == 200
context = client.post(
f"/projects/{project_id}/authoring/context",
json={
"routine_name": "ПроверитьОстатки",
"cursor_line": 3,
"source_text": source_text,
},
)
assert context.status_code == 200
assert [row["qualified_name"] for row in context.json()["query_tables"]] == [
"РегистрНакопления.ОстаткиТоваров"
]
def test_object_schema_endpoint(tmp_path: Path):
(tmp_path / "metadata.xml").write_text(
"""
<Configuration>
<Document name="ЗаказПокупателя" qualifiedName="Документ.ЗаказПокупателя">
<Attribute name="Контрагент" qualifiedName="Документ.ЗаказПокупателя.Контрагент" />
<TabularSection name="Товары" qualifiedName="Документ.ЗаказПокупателя.Товары">
<Attribute name="Номенклатура" qualifiedName="Документ.ЗаказПокупателя.Товары.Номенклатура" />
</TabularSection>
</Document>
</Configuration>
""",
encoding="utf-8",
)
client = TestClient(app)
indexed = client.post(
"/projects/index",
json={"path": str(tmp_path), "project_id": "demo-object-schema"},
)
assert indexed.status_code == 200
response = client.get("/projects/demo-object-schema/objects/schema/Документ.ЗаказПокупателя")
assert response.status_code == 200
payload = response.json()
assert payload["object"]["name"] == "ЗаказПокупателя"
assert payload["attributes"][0]["name"] == "Контрагент"
assert payload["tabular_sections"][0]["tabular_section"]["name"] == "Товары"
assert payload["tabular_sections"][0]["columns"][0]["name"] == "Номенклатура"
def test_knowledge_schema_coverage_endpoint(tmp_path: Path):
(tmp_path / "metadata.xml").write_text(
"""
<Configuration>
<Document name="ЗаказПокупателя" qualifiedName="Документ.ЗаказПокупателя">
<Attribute name="Контрагент" qualifiedName="Документ.ЗаказПокупателя.Контрагент" />
<TabularSection name="Товары" qualifiedName="Документ.ЗаказПокупателя.Товары">
<Attribute name="Номенклатура" qualifiedName="Документ.ЗаказПокупателя.Товары.Номенклатура" />
</TabularSection>
</Document>
</Configuration>
""",
encoding="utf-8",
)
client = TestClient(app)
indexed = client.post(
"/projects/index",
json={"path": str(tmp_path), "project_id": "demo-api-schema-coverage"},
)
assert indexed.status_code == 200
snapshot = client.get("/projects/demo-api-schema-coverage/snapshot/export").json()
document_lineage = next(
node["lineage_id"]
for node in snapshot["nodes"]
if node["kind"] == "DOCUMENT"
)
knowledge = client.post(
"/knowledge",
json={
"record_id": "knowledge.schema.document",
"scope": "PROJECT",
"title": "Заказ покупателя",
"body": "Описание структуры документа.",
"related_lineages": [document_lineage],
},
)
assert knowledge.status_code == 200
response = client.get("/projects/demo-api-schema-coverage/knowledge/schema-coverage")
assert response.status_code == 200
payload = response.json()
assert payload["covered_count"] == 1
assert payload["uncovered_count"] == 3
assert any(item["node"]["name"] == "ЗаказПокупателя" and item["record_count"] == 1 for item in payload["items"])
assert {node["name"] for node in payload["uncovered"]} == {"Контрагент", "Номенклатура", "Товары"}
def test_knowledge_pack_import_endpoint():
client = TestClient(app)
pack_id = f"bsp.core.{uuid4()}"
record_id = f"knowledge.bsp.{uuid4()}"
response = client.post(
"/knowledge/packs",
json={
"pack_id": pack_id,
"name": "БСП базовые правила",
"vendor": "1C",
"version": "3.1",
"records": [
{
"record_id": record_id,
"scope": "GLOBAL",
"title": "БСП роли",
"body": "Рекомендации по настройке ролей БСП.",
}
],
},
)
assert response.status_code == 200
packs = client.get("/knowledge/packs")
assert packs.status_code == 200
assert any(pack["pack_id"] == pack_id for pack in packs.json())
search = client.get("/knowledge/search", params={"q": "БСП"})
assert search.status_code == 200
record = next(item for item in search.json()["results"] if item["record_id"] == record_id)
assert f"pack:{pack_id}" in record["tags"]
assert "vendor:1C" in record["tags"]
assert record["attributes"]["pack_version"] == "3.1"
summary = client.get("/admin/summary")
assert summary.status_code == 200
assert summary.json()["knowledge_packs"] >= 1
def test_object_ownership_endpoint(tmp_path: Path):
project_id = f"demo-api-ownership-{uuid4()}"
(tmp_path / "metadata.xml").write_text(
"""
<Configuration>
<Document name="ЗаказПокупателя" qualifiedName="Документ.ЗаказПокупателя" />
</Configuration>
""",
encoding="utf-8",
)
client = TestClient(app)
indexed = client.post(
"/projects/index",
json={"path": str(tmp_path), "project_id": project_id},
)
assert indexed.status_code == 200
snapshot = client.get(f"/projects/{project_id}/snapshot/export").json()
document_lineage = next(
node["lineage_id"]
for node in snapshot["nodes"]
if node["kind"] == "DOCUMENT"
)
user_id = f"user.{uuid4()}"
user = client.post(
"/collaboration/users",
json={"user_id": user_id, "display_name": "Owner"},
)
assert user.status_code == 200
ownership = client.post(
f"/projects/{project_id}/ownership",
json={
"target_id": document_lineage,
"owner_user_id": user_id,
"role": "RESPONSIBLE",
"assigned_by": user_id,
},
)
assert ownership.status_code == 200
project_ownership = client.get(f"/projects/{project_id}/ownership")
assert project_ownership.status_code == 200
assert project_ownership.json()[0]["target_id"] == document_lineage
response = client.get(f"/projects/{project_id}/objects/ownership/Документ.ЗаказПокупателя")
assert response.status_code == 200
payload = response.json()
assert payload["object"]["name"] == "ЗаказПокупателя"
assert payload["owners"][0]["owner_user_id"] == user_id
assert payload["owners"][0]["role"] == "RESPONSIBLE"
report = client.get(f"/projects/{project_id}/report")
assert report.status_code == 200
assert report.json()["ownership_count"] == 1
assert report.json()["unowned_object_count"] == 0
review = client.get(f"/projects/{project_id}/review")
assert review.status_code == 200
assert not any(finding["title"] == "Missing 1C object owner" for finding in review.json())
def test_project_comments_endpoint(tmp_path: Path):
project_id = f"demo-api-comments-{uuid4()}"
(tmp_path / "metadata.xml").write_text(
"""
<Configuration>
<Document name="ЗаказПокупателя" qualifiedName="Документ.ЗаказПокупателя" />
</Configuration>
""",
encoding="utf-8",
)
client = TestClient(app)
indexed = client.post(
"/projects/index",
json={"path": str(tmp_path), "project_id": project_id},
)
assert indexed.status_code == 200
snapshot = client.get(f"/projects/{project_id}/snapshot/export").json()
document_lineage = next(
node["lineage_id"]
for node in snapshot["nodes"]
if node["kind"] == "DOCUMENT"
)
user_id = f"user.{uuid4()}"
user = client.post(
"/collaboration/users",
json={"user_id": user_id, "display_name": "Reviewer"},
)
assert user.status_code == 200
comment = client.post(
f"/projects/{project_id}/comments",
json={
"comment_id": f"comment.{uuid4()}",
"target_id": document_lineage,
"user_id": user_id,
"body": "Проверить правила проведения.",
},
)
assert comment.status_code == 200
comments = client.get(f"/projects/{project_id}/comments")
assert comments.status_code == 200
assert comments.json()[0]["target_id"] == document_lineage
target_comments = client.get(f"/projects/{project_id}/comments/{document_lineage}")
assert target_comments.status_code == 200
assert target_comments.json()[0]["body"] == "Проверить правила проведения."
activity = client.get(f"/projects/{project_id}/activity")
assert activity.status_code == 200
assert activity.json()[0]["verb"] == "ADD_COMMENT"
def test_review_endpoint_reports_missing_1c_object_owner(tmp_path: Path):
project_id = f"demo-api-missing-owner-{uuid4()}"
(tmp_path / "metadata.xml").write_text(
"""
<Configuration>
<Document name="ЗаказПокупателя" qualifiedName="Документ.ЗаказПокупателя" />
</Configuration>
""",
encoding="utf-8",
)
client = TestClient(app)
indexed = client.post(
"/projects/index",
json={"path": str(tmp_path), "project_id": project_id},
)
assert indexed.status_code == 200
response = client.get(f"/projects/{project_id}/review")
assert response.status_code == 200
assert any(
finding["title"] == "Missing 1C object owner"
and "Документ.ЗаказПокупателя" in finding["message"]
for finding in response.json()
)
def test_review_endpoint_reports_missing_1c_schema_knowledge(tmp_path: Path):
(tmp_path / "metadata.xml").write_text(
"""
<Configuration>
<Document name="ЗаказПокупателя" qualifiedName="Документ.ЗаказПокупателя">
<Attribute name="Контрагент" qualifiedName="Документ.ЗаказПокупателя.Контрагент" />
</Document>
</Configuration>
""",
encoding="utf-8",
)
client = TestClient(app)
indexed = client.post(
"/projects/index",
json={"path": str(tmp_path), "project_id": "demo-api-schema-knowledge-review"},
)
assert indexed.status_code == 200
snapshot = client.get("/projects/demo-api-schema-knowledge-review/snapshot/export").json()
document_lineage = next(
node["lineage_id"]
for node in snapshot["nodes"]
if node["kind"] == "DOCUMENT"
)
knowledge = client.post(
"/knowledge",
json={
"record_id": "knowledge.schema.review.document",
"scope": "PROJECT",
"title": "Документ заказа",
"body": "Описание документа.",
"related_lineages": [document_lineage],
},
)
assert knowledge.status_code == 200
response = client.get("/projects/demo-api-schema-knowledge-review/review")
assert response.status_code == 200
knowledge_findings = [
finding
for finding in response.json()
if finding["title"] == "Missing 1C schema knowledge"
]
assert any("Документ.ЗаказПокупателя.Контрагент" in finding["message"] for finding in knowledge_findings)
assert not any("Schema node Документ.ЗаказПокупателя has no" in finding["message"] for finding in knowledge_findings)
def test_privacy_marker_endpoint_classifies_1c_attribute(tmp_path: Path):
project_id = f"demo-api-privacy-{uuid4()}"
(tmp_path / "metadata.xml").write_text(
"""
<Configuration>
<Document name="ЗаказПокупателя" qualifiedName="Документ.ЗаказПокупателя">
<Attribute name="Телефон" qualifiedName="Документ.ЗаказПокупателя.Телефон" />
</Document>
</Configuration>
""",
encoding="utf-8",
)
client = TestClient(app)
indexed = client.post(
"/projects/index",
json={"path": str(tmp_path), "project_id": project_id},
)
assert indexed.status_code == 200
snapshot = client.get(f"/projects/{project_id}/snapshot/export").json()
phone_lineage = next(
node["lineage_id"]
for node in snapshot["nodes"]
if node["kind"] == "ATTRIBUTE" and node["name"] == "Телефон"
)
marker = client.post(
f"/projects/{project_id}/privacy/markers",
json={
"target_id": phone_lineage,
"classification": "PERSONAL_DATA",
"reason": "Контактный телефон клиента",
},
)
assert marker.status_code == 200
markers = client.get(f"/projects/{project_id}/privacy/markers")
assert markers.status_code == 200
assert markers.json()[0]["classification"] == "PERSONAL_DATA"
object_privacy = client.get(f"/projects/{project_id}/objects/privacy/Документ.ЗаказПокупателя")
assert object_privacy.status_code == 200
assert object_privacy.json()["markers"][0]["target_id"] == phone_lineage
report = client.get(f"/projects/{project_id}/report")
assert report.status_code == 200
assert report.json()["privacy_marker_count"] == 1
assert report.json()["sensitive_candidate_count"] == 1
assert report.json()["unclassified_sensitive_count"] == 0
review = client.get(f"/projects/{project_id}/review")
assert review.status_code == 200
assert not any(finding["title"] == "Unclassified sensitive 1C field" for finding in review.json())
def test_review_endpoint_reports_unclassified_sensitive_1c_field(tmp_path: Path):
project_id = f"demo-api-privacy-review-{uuid4()}"
(tmp_path / "metadata.xml").write_text(
"""
<Configuration>
<Catalog name="Контрагенты" qualifiedName="Справочник.Контрагенты">
<Attribute name="ИНН" qualifiedName="Справочник.Контрагенты.ИНН" />
</Catalog>
</Configuration>
""",
encoding="utf-8",
)
client = TestClient(app)
indexed = client.post(
"/projects/index",
json={"path": str(tmp_path), "project_id": project_id},
)
assert indexed.status_code == 200
response = client.get(f"/projects/{project_id}/review")
assert response.status_code == 200
assert any(
finding["title"] == "Unclassified sensitive 1C field"
and "Справочник.Контрагенты.ИНН" in finding["message"]
for finding in response.json()
)
def test_project_report_includes_1c_schema_summary(tmp_path: Path):
(tmp_path / "metadata.xml").write_text(
"""
<Configuration>
<Document name="ЗаказПокупателя" qualifiedName="Документ.ЗаказПокупателя">
<Attribute name="Контрагент" qualifiedName="Документ.ЗаказПокупателя.Контрагент" />
<TabularSection name="Товары" qualifiedName="Документ.ЗаказПокупателя.Товары">
<Attribute name="Номенклатура" qualifiedName="Документ.ЗаказПокупателя.Товары.Номенклатура" />
</TabularSection>
<TabularSection name="Услуги" qualifiedName="Документ.ЗаказПокупателя.Услуги" />
</Document>
</Configuration>
""",
encoding="utf-8",
)
client = TestClient(app)
indexed = client.post(
"/projects/index",
json={"path": str(tmp_path), "project_id": "demo-api-schema-report"},
)
assert indexed.status_code == 200
response = client.get("/projects/demo-api-schema-report/report")
assert response.status_code == 200
payload = response.json()
assert payload["attribute_count"] == 2
assert payload["object_attribute_count"] == 1
assert payload["tabular_section_count"] == 2
assert payload["tabular_section_column_count"] == 1
assert payload["empty_tabular_sections"] == ["Документ.ЗаказПокупателя.Услуги"]
def test_review_endpoint_reports_empty_1c_tabular_section(tmp_path: Path):
(tmp_path / "metadata.xml").write_text(
"""
<Configuration>
<Document name="ЗаказПокупателя" qualifiedName="Документ.ЗаказПокупателя">
<TabularSection name="Услуги" qualifiedName="Документ.ЗаказПокупателя.Услуги" />
</Document>
</Configuration>
""",
encoding="utf-8",
)
client = TestClient(app)
indexed = client.post(
"/projects/index",
json={"path": str(tmp_path), "project_id": "demo-api-empty-tabular-section"},
)
assert indexed.status_code == 200
response = client.get("/projects/demo-api-empty-tabular-section/review")
assert response.status_code == 200
assert any(
finding["title"] == "Empty 1C tabular section"
and "Документ.ЗаказПокупателя.Услуги" in finding["message"]
for finding in response.json()
)
def test_review_endpoint_reports_tabular_section_without_subject_column(tmp_path: Path):
(tmp_path / "metadata.xml").write_text(
"""
<Configuration>
<Document name="ЗаказПокупателя" qualifiedName="Документ.ЗаказПокупателя">
<TabularSection name="Товары" qualifiedName="Документ.ЗаказПокупателя.Товары">
<Attribute name="Количество" qualifiedName="Документ.ЗаказПокупателя.Товары.Количество" />
</TabularSection>
</Document>
</Configuration>
""",
encoding="utf-8",
)
client = TestClient(app)
indexed = client.post(
"/projects/index",
json={"path": str(tmp_path), "project_id": "demo-api-no-subject-column"},
)
assert indexed.status_code == 200
response = client.get("/projects/demo-api-no-subject-column/review")
assert response.status_code == 200
assert any(
finding["title"] == "No subject column in 1C tabular section"
and finding["severity"] == "INFO"
for finding in response.json()
)
def test_object_tabular_sections_endpoint(tmp_path: Path):
(tmp_path / "metadata.xml").write_text(
"""
<Configuration>
<Document name="ЗаказПокупателя" qualifiedName="Документ.ЗаказПокупателя">
<TabularSection name="Товары" qualifiedName="Документ.ЗаказПокупателя.Товары">
<Attribute name="Номенклатура" qualifiedName="Документ.ЗаказПокупателя.Товары.Номенклатура" />
</TabularSection>
</Document>
</Configuration>
""",
encoding="utf-8",
)
client = TestClient(app)
indexed = client.post(
"/projects/index",
json={"path": str(tmp_path), "project_id": "demo-object-tabular-sections"},
)
assert indexed.status_code == 200
response = client.get(
"/projects/demo-object-tabular-sections/objects/tabular-sections/Документ.ЗаказПокупателя"
)
assert response.status_code == 200
assert response.json()["results"][0]["name"] == "Товары"
columns = client.get(
"/projects/demo-object-tabular-sections/objects/tabular-sections/Документ.ЗаказПокупателя/columns"
)
assert columns.status_code == 200
assert columns.json()[0]["tabular_section"]["name"] == "Товары"
assert columns.json()[0]["columns"][0]["name"] == "Номенклатура"
def test_incremental_xml_updates_metadata_semantics(tmp_path: Path):
xml = tmp_path / "metadata.xml"
xml.write_text(
"""
<Configuration>
<Document name="ЗаказПокупателя" qualifiedName="Документ.ЗаказПокупателя" />
</Configuration>
""",
encoding="utf-8",
)
client = TestClient(app)
indexed = client.post(
"/projects/index",
json={"path": str(tmp_path), "project_id": "demo-api-xml-incremental"},
)
assert indexed.status_code == 200
xml.write_text(
"""
<Configuration>
<Document name="ЗаказПокупателя" qualifiedName="Документ.ЗаказПокупателя">
<Form name="ФормаДокумента" qualifiedName="Документ.ЗаказПокупателя.ФормаДокумента">
<Command name="Провести" qualifiedName="Документ.ЗаказПокупателя.ФормаДокумента.Провести" />
</Form>
</Document>
</Configuration>
""",
encoding="utf-8",
)
incremental = client.post(
"/projects/demo-api-xml-incremental/incremental/file",
json={"path": str(xml)},
)
assert incremental.status_code == 200
assert incremental.json()["added_nodes"] >= 2
forms = client.get("/projects/demo-api-xml-incremental/ui/forms")
assert forms.status_code == 200
assert forms.json()[0]["commands"][0]["name"] == "Провести"
def test_scheduled_jobs_endpoint(tmp_path: Path):
(tmp_path / "metadata.xml").write_text(
"""
<Configuration>
<ScheduledJob name="ОбновлениеЦен" qualifiedName="РегламентноеЗадание.ОбновлениеЦен" method="ОбновитьЦены" schedule="КаждыйДень" />
</Configuration>
""",
encoding="utf-8",
)
module = tmp_path / "CommonModules" / "РегламентныеОперации" / "Module.bsl"
module.parent.mkdir(parents=True)
module.write_text("Процедура ОбновитьЦены()\nКонецПроцедуры\n", encoding="utf-8")
client = TestClient(app)
indexed = client.post(
"/projects/index",
json={"path": str(tmp_path), "project_id": "demo-api-scheduled-jobs"},
)
assert indexed.status_code == 200
response = client.get("/projects/demo-api-scheduled-jobs/jobs/scheduled")
assert response.status_code == 200
payload = response.json()
assert payload[0]["name"] == "ОбновлениеЦен"
assert payload[0]["routine"]["name"] == "ОбновитьЦены"
assert payload[0]["schedule"] == "КаждыйДень"
impact = client.get(
"/projects/demo-api-scheduled-jobs/objects/impact/РегламентноеЗадание.ОбновлениеЦен"
)
assert impact.status_code == 200
assert impact.json()["jobs"][0]["name"] == "ОбновлениеЦен"
assert impact.json()["routines"][0]["name"] == "ОбновитьЦены"
def test_integrations_endpoint(tmp_path: Path):
(tmp_path / "integration.bsl").write_text(
"""
Процедура Отправить()
Соединение = Новый HTTPСоединение("api.example.local");
Адрес = "https://api.example.local/orders";
КонецПроцедуры
""",
encoding="utf-8",
)
(tmp_path / "metadata.xml").write_text(
"""
<Configuration>
<ExchangePlan name="ОбменСКассой" qualifiedName="ПланОбмена.ОбменСКассой" />
</Configuration>
""",
encoding="utf-8",
)
client = TestClient(app)
indexed = client.post(
"/projects/index",
json={"path": str(tmp_path), "project_id": "demo-api-integrations"},
)
assert indexed.status_code == 200
response = client.get("/projects/demo-api-integrations/integrations")
filtered = client.get(
"/projects/demo-api-integrations/integrations",
params={"kind": "EXCHANGE_PLAN"},
)
assert response.status_code == 200
assert any(item["kind"] == "HTTP_SERVICE" for item in response.json())
assert filtered.status_code == 200
assert filtered.json()[0]["name"] == "ОбменСКассой"
def test_patterns_endpoint_finds_repeated_writes(tmp_path: Path):
(tmp_path / "module.bsl").write_text(
"""
Процедура ПровестиЗаказ()
Движения.ОстаткиТоваров.Записать();
КонецПроцедуры
Процедура ОтменитьЗаказ()
Движения.ОстаткиТоваров.Записать();
КонецПроцедуры
""",
encoding="utf-8",
)
client = TestClient(app)
project_id = f"demo-api-patterns-{uuid4()}"
indexed = client.post("/projects/index", json={"path": str(tmp_path), "project_id": project_id})
assert indexed.status_code == 200
response = client.get(f"/projects/{project_id}/patterns")
assert response.status_code == 200
assert any(
pattern["kind"] == "REPEATED_TABLE_WRITE"
and pattern["support"] == 2
and pattern["targets"][0]["name"] == "ОстаткиТоваров"
for pattern in response.json()
)
def test_incremental_reprojects_neo4j_when_project_was_projected(monkeypatch, tmp_path: Path):
module = tmp_path / "demo_module.bsl"
module.write_text("Процедура Проведение()\nКонецПроцедуры\n", encoding="utf-8")
client = TestClient(app)
project_id = f"demo-api-neo4j-incremental-{uuid4().hex}"
projected: list[tuple[str, int]] = []
applied_deltas: list[tuple[str, int, int]] = []
async def fake_project_snapshot(project_id: str, snapshot):
projected.append((project_id, len(snapshot.nodes)))
return {"nodes": len(snapshot.nodes), "edges": len(snapshot.edges)}
async def fake_apply_delta(project_id: str, delta):
applied_deltas.append((project_id, len(delta.added_nodes), len(delta.added_edges)))
return {"nodes": len(delta.added_nodes), "edges": len(delta.added_edges)}
monkeypatch.setattr(main, "_project_snapshot_to_neo4j", fake_project_snapshot)
monkeypatch.setattr(main, "_apply_delta_to_neo4j", fake_apply_delta)
indexed = client.post("/projects/index", json={"path": str(tmp_path), "project_id": project_id})
assert indexed.status_code == 200
projection = client.post(f"/projects/{project_id}/graph/neo4j/project")
assert projection.status_code == 200
assert projected[-1][0] == project_id
module.write_text(
"""
Процедура Проведение()
Движения.ОстаткиТоваров.Записать();
КонецПроцедуры
""",
encoding="utf-8",
)
incremental = client.post(
f"/projects/{project_id}/incremental/file",
json={"path": str(module)},
)
assert incremental.status_code == 200
assert incremental.json()["neo4j_projected"] is True
assert incremental.json()["neo4j_error"] is None
assert len(projected) == 1
assert applied_deltas == [(project_id, incremental.json()["added_nodes"], incremental.json()["added_edges"])]
def test_index_missing_path_returns_404():
client = TestClient(app)
response = client.post("/projects/index", json={"path": "Z:/definitely/missing"})
assert response.status_code == 404
def test_index_demo_project_endpoint():
client = TestClient(app)
response = client.post("/projects/demo/index")
assert response.status_code == 200
assert response.json()["snapshot"]["project_id"] == "demo"
def test_collaboration_endpoints():
client = TestClient(app)
suffix = uuid4().hex
user_id = f"user.api.collaboration.{suffix}"
task_id = f"task.api.collaboration.{suffix}"
session_id = f"session.api.collaboration.{suffix}"
user = client.post(
"/collaboration/users",
json={"user_id": user_id, "display_name": "API Tester"},
)
assert user.status_code == 200
task = client.post(
"/collaboration/tasks",
json={
"task_id": task_id,
"project_id": "demo-api",
"title": "Проверить индекс",
"assignee_user_id": user_id,
},
)
assert task.status_code == 200
session = client.post(
"/collaboration/sessions",
json={
"session": {
"session_id": session_id,
"task_id": task_id,
"user_id": user_id,
}
},
)
assert session.status_code == 200
finished = client.post(f"/collaboration/sessions/{session_id}/finish")
assert finished.status_code == 200
assert finished.json()["finished_at"] is not None
feed = client.get("/projects/demo-api/activity")
assert feed.status_code == 200
assert feed.json()[0]["verb"] == "FINISH_SESSION"
assert any(event["verb"] == "UPSERT_TASK" for event in feed.json())
permission = client.get(f"/security/users/{user_id}/permissions/READ_GRAPH")
assert permission.status_code == 200
assert permission.json()["allowed"] is False
grant = client.post(f"/security/users/{user_id}/roles/viewer")
assert grant.status_code == 200
permission = client.get(f"/security/users/{user_id}/permissions/READ_GRAPH")
assert permission.status_code == 200
assert permission.json()["allowed"] is True
permissions = client.get(f"/security/users/{user_id}/permissions")
assert permissions.status_code == 200
assert permissions.json()["permissions"] == ["READ_GRAPH"]
def test_operations_endpoints():
client = TestClient(app)
job = client.post("/operations/jobs", json={"job_id": "job.api", "kind": "INDEX_PROJECT"})
assert job.status_code == 200
update = client.patch(
"/operations/jobs/job.api",
json={"status": "SUCCEEDED", "result": {"indexed": True}},
)
assert update.status_code == 200
assert update.json()["status"] == "SUCCEEDED"
metric = client.post(
"/operations/metrics",
json={"metric_id": "metric.api", "name": "api.requests", "value": 1.0},
)
assert metric.status_code == 200
packages = client.post(
"/marketplace/packages",
json={"package_id": "pack.api", "name": "BSP Knowledge", "version": "1.0.0"},
)
assert packages.status_code == 200
license_response = client.get("/license")
assert license_response.status_code == 200
assert license_response.json()["valid"] is True
summary = client.get("/admin/summary")
assert summary.status_code == 200
assert "stored_snapshots" in summary.json()
def test_ai_usage_endpoints(tmp_path: Path):
project_id = f"demo-api-ai-usage-{uuid4()}"
module = tmp_path / "module.bsl"
module.write_text("Процедура Проверить()\nКонецПроцедуры\n", encoding="utf-8")
client = TestClient(app)
indexed = client.post(
"/projects/index",
json={"path": str(tmp_path), "project_id": project_id},
)
assert indexed.status_code == 200
usage_id = f"usage.{uuid4()}"
usage = client.post(
"/ai/usage",
json={
"usage_id": usage_id,
"project_id": project_id,
"user_id": "user.ai",
"model": "gpt-test",
"operation": "review",
"prompt_tokens": 120,
"completion_tokens": 30,
"cost": 0.02,
},
)
assert usage.status_code == 200
assert usage.json()["total_tokens"] == 150
records = client.get("/ai/usage", params={"project_id": project_id})
assert records.status_code == 200
assert records.json()[0]["usage_id"] == usage_id
summary = client.get("/ai/usage/summary", params={"project_id": project_id})
assert summary.status_code == 200
assert summary.json()["request_count"] == 1
assert summary.json()["total_tokens"] == 150
policy = client.get("/ai/policy", params={"user_id": "user.ai"})
assert policy.status_code == 200
assert policy.json()["used_tokens"] >= 150
assert policy.json()["remaining_tokens"] is not None
report = client.get(f"/projects/{project_id}/report")
assert report.status_code == 200
assert report.json()["ai_usage_request_count"] == 1
assert report.json()["ai_usage_total_tokens"] == 150
def test_ai_answer_policy_allows_with_knowledge_context(tmp_path: Path):
project_id = f"demo-api-ai-answer-policy-{uuid4()}"
(tmp_path / "module.bsl").write_text("Процедура Проверить()\nКонецПроцедуры\n", encoding="utf-8")
client = TestClient(app)
indexed = client.post("/projects/index", json={"path": str(tmp_path), "project_id": project_id})
assert indexed.status_code == 200
snapshot = client.get(f"/projects/{project_id}/snapshot/export").json()
routine_lineage = next(node["lineage_id"] for node in snapshot["nodes"] if node["kind"] == "PROCEDURE")
knowledge = client.post(
"/knowledge",
json={
"record_id": f"knowledge.answer.{uuid4()}",
"scope": "PROJECT",
"title": "Правила проверки",
"body": "Процедура Проверить выполняет контроль.",
"related_lineages": [routine_lineage],
},
)
assert knowledge.status_code == 200
response = client.post(
f"/projects/{project_id}/ai/answer-policy",
json={
"user_id": "user.answer",
"question": "Что делает процедура Проверить?",
"related_lineages": [routine_lineage],
"estimated_tokens": 100,
},
)
assert response.status_code == 200
payload = response.json()
assert payload["allowed"] is True
assert payload["reasons"] == []
assert payload["knowledge_records"][0]["title"] == "Правила проверки"
def test_ai_answer_policy_blocks_sensitive_context(tmp_path: Path):
project_id = f"demo-api-ai-answer-policy-sensitive-{uuid4()}"
(tmp_path / "metadata.xml").write_text(
"""
<Configuration>
<Catalog name="Контрагенты" qualifiedName="Справочник.Контрагенты">
<Attribute name="ИНН" qualifiedName="Справочник.Контрагенты.ИНН" />
</Catalog>
</Configuration>
""",
encoding="utf-8",
)
client = TestClient(app)
indexed = client.post("/projects/index", json={"path": str(tmp_path), "project_id": project_id})
assert indexed.status_code == 200
snapshot = client.get(f"/projects/{project_id}/snapshot/export").json()
inn_lineage = next(
node["lineage_id"]
for node in snapshot["nodes"]
if node["kind"] == "ATTRIBUTE" and node["name"] == "ИНН"
)
marker = client.post(
f"/projects/{project_id}/privacy/markers",
json={
"target_id": inn_lineage,
"classification": "PERSONAL_DATA",
"reason": "Налоговый идентификатор контрагента",
},
)
assert marker.status_code == 200
response = client.post(
f"/projects/{project_id}/ai/answer-policy",
json={
"user_id": "user.answer",
"question": "Объясни реквизит ИНН",
"related_lineages": [inn_lineage],
"estimated_tokens": 100,
"require_knowledge": False,
},
)
assert response.status_code == 200
payload = response.json()
assert payload["allowed"] is False
assert "Privacy policy blocks sensitive 1C context" in payload["reasons"]
assert payload["privacy_markers"][0]["classification"] == "PERSONAL_DATA"
def test_operation_job_runner_indexes_project(tmp_path: Path):
module = tmp_path / "job_module.bsl"
module.write_text("Процедура JobRun()\nКонецПроцедуры\n", encoding="utf-8")
client = TestClient(app)
job_id = f"job.index.{uuid4().hex}"
job = client.post(
"/operations/jobs",
json={
"job_id": job_id,
"kind": "INDEX_PROJECT",
"payload": {"path": str(tmp_path), "project_id": "demo-job"},
},
)
assert job.status_code == 200
run = client.post(f"/operations/jobs/{job_id}/run")
assert run.status_code == 200
payload = run.json()
assert payload["status"] == "SUCCEEDED"
assert payload["result"]["snapshot"]["project_id"] == "demo-job"
def test_operation_job_runner_records_failure():
client = TestClient(app)
job_id = f"job.fail.{uuid4().hex}"
client.post(
"/operations/jobs",
json={"job_id": job_id, "kind": "INDEX_PROJECT", "payload": {}},
)
run = client.post(f"/operations/jobs/{job_id}/run")
assert run.status_code == 200
assert run.json()["status"] == "FAILED"
assert "payload.path" in run.json()["error"]
def test_neo4j_endpoints_contract_for_unindexed_project():
client = TestClient(app)
status = client.get("/graph/neo4j/status")
assert status.status_code == 200
assert status.json()["status"] in {"ok", "unavailable"}
projection = client.post("/projects/not-indexed/graph/neo4j/project")
assert projection.status_code == 404
def test_neo4j_callees_endpoint_contract(monkeypatch):
async def fake_query(project_id: str, routine_name: str, *, outgoing: bool):
assert project_id == "demo"
assert routine_name == "Проведение"
assert outgoing is True
return [
{
"lineage_id": "lineage.procedure.check",
"kind": "PROCEDURE",
"name": "ПроверитьОстатки",
"qualified_name": "Module.ПроверитьОстатки",
}
]
monkeypatch.setattr(main, "_neo4j_routine_query", fake_query)
client = TestClient(app)
response = client.get("/projects/demo/graph/neo4j/callees/Проведение")
assert response.status_code == 200
assert response.json()["results"][0]["name"] == "ПроверитьОстатки"
def test_neo4j_writes_endpoint_contract(monkeypatch):
async def fake_query(project_id: str, routine_name: str, *, relation: str):
assert project_id == "demo"
assert routine_name == "Проведение"
assert relation == "WRITES"
return [
{
"lineage_id": "lineage.register.stock",
"kind": "REGISTER",
"name": "ОстаткиТоваров",
"qualified_name": "РегистрНакопления.ОстаткиТоваров",
}
]
monkeypatch.setattr(main, "_neo4j_relation_query", fake_query)
client = TestClient(app)
response = client.get("/projects/demo/graph/neo4j/writes/Проведение")
assert response.status_code == 200
assert response.json()["results"][0]["name"] == "ОстаткиТоваров"
def test_neo4j_integrations_endpoints_contract(monkeypatch):
async def fake_integrations(project_id: str, *, kind: str | None = None):
assert project_id == "demo"
assert kind == "HTTP_SERVICE"
return [
{
"endpoint_id": "lineage.integration.http",
"name": "https://api.example.local/orders",
"kind": "HTTP_SERVICE",
"direction": "OUTBOUND",
"owner": "IntegrationModule",
"attributes": {"url": "https://api.example.local/orders"},
}
]
async def fake_modules(project_id: str, integration_name: str):
assert project_id == "demo"
assert integration_name == "https://api.example.local/orders"
return [
{
"lineage_id": "lineage.module.integration",
"kind": "MODULE",
"name": "IntegrationModule",
"qualified_name": "IntegrationModule",
}
]
monkeypatch.setattr(main, "_neo4j_integrations_query", fake_integrations)
monkeypatch.setattr(main, "_neo4j_integration_modules_query", fake_modules)
client = TestClient(app)
integrations = client.get(
"/projects/demo/graph/neo4j/integrations",
params={"kind": "HTTP_SERVICE"},
)
modules = client.get(
"/projects/demo/graph/neo4j/integrations/https://api.example.local/orders/modules"
)
assert integrations.status_code == 200
assert integrations.json()[0]["kind"] == "HTTP_SERVICE"
assert modules.status_code == 200
assert modules.json()["results"][0]["name"] == "IntegrationModule"
def test_neo4j_object_impact_endpoint_contract(monkeypatch):
async def fake_query(project_id: str, object_name: str):
assert project_id == "demo"
assert object_name == "Документ.ЗаказПокупателя"
return {
"object": {
"lineage_id": "lineage.document.order",
"kind": "DOCUMENT",
"name": "ЗаказПокупателя",
"qualified_name": "Документ.ЗаказПокупателя",
},
"modules": [
{
"lineage_id": "lineage.module.object",
"kind": "MODULE",
"name": "ObjectModule",
"qualified_name": "ObjectModule",
}
],
"routines": [
{
"lineage_id": "lineage.procedure.posting",
"kind": "PROCEDURE",
"name": "Проведение",
"qualified_name": "ObjectModule.Проведение",
}
],
"forms": [
{
"lineage_id": "lineage.form.main",
"kind": "FORM",
"name": "ФормаДокумента",
"qualified_name": "Документ.ЗаказПокупателя.ФормаДокумента",
}
],
"commands": [
{
"lineage_id": "lineage.command.post",
"kind": "COMMAND",
"name": "Провести",
"qualified_name": "Документ.ЗаказПокупателя.ФормаДокумента.Провести",
}
],
"attributes": [
{
"lineage_id": "lineage.attribute.counterparty",
"kind": "ATTRIBUTE",
"name": "Контрагент",
"qualified_name": "Документ.ЗаказПокупателя.Контрагент",
}
],
"tabular_sections": [
{
"lineage_id": "lineage.tabular.goods",
"kind": "TABULAR_SECTION",
"name": "Товары",
"qualified_name": "Документ.ЗаказПокупателя.Товары",
}
],
"tabular_section_columns": {
"lineage.tabular.goods": [
{
"lineage_id": "lineage.attribute.item",
"kind": "ATTRIBUTE",
"name": "Номенклатура",
"qualified_name": "Документ.ЗаказПокупателя.Товары.Номенклатура",
}
]
},
"roles": [
{
"lineage_id": "lineage.role.manager",
"kind": "ROLE",
"name": "Менеджер",
"qualified_name": "Роль.Менеджер",
}
],
"role_access": [
{
"role": {
"lineage_id": "lineage.role.manager",
"kind": "ROLE",
"name": "Менеджер",
"qualified_name": "Роль.Менеджер",
},
"permissions": {"read": "true", "write": "true", "post": "true"},
}
],
"callees": [],
"query_tables": [],
"writes": [
{
"lineage_id": "lineage.register.stock",
"kind": "REGISTER",
"name": "ОстаткиТоваров",
"qualified_name": "РегистрНакопления.ОстаткиТоваров",
}
],
}
monkeypatch.setattr(main, "_neo4j_object_impact_query", fake_query)
client = TestClient(app)
response = client.get(
"/projects/demo/graph/neo4j/objects/impact/Документ.ЗаказПокупателя"
)
assert response.status_code == 200
payload = response.json()
assert payload["object"]["name"] == "ЗаказПокупателя"
assert payload["modules"][0]["name"] == "ObjectModule"
assert payload["routines"][0]["name"] == "Проведение"
assert payload["attributes"][0]["name"] == "Контрагент"
assert payload["tabular_sections"][0]["name"] == "Товары"
assert payload["tabular_section_columns"]["lineage.tabular.goods"][0]["name"] == "Номенклатура"
assert payload["roles"][0]["name"] == "Менеджер"
assert payload["role_access"][0]["permissions"]["post"] == "true"
assert payload["writes"][0]["name"] == "ОстаткиТоваров"
def test_neo4j_object_impact_endpoint_returns_404(monkeypatch):
async def fake_query(project_id: str, object_name: str):
return None
monkeypatch.setattr(main, "_neo4j_object_impact_query", fake_query)
client = TestClient(app)
response = client.get("/projects/demo/graph/neo4j/objects/impact/НеСуществует")
assert response.status_code == 404
def test_neo4j_object_ui_endpoint_contract(monkeypatch):
async def fake_query(project_id: str, object_name: str):
assert project_id == "demo"
assert object_name == "Документ.ЗаказПокупателя"
return {
"object": {
"lineage_id": "lineage.document.order",
"kind": "DOCUMENT",
"name": "ЗаказПокупателя",
"qualified_name": "Документ.ЗаказПокупателя",
},
"forms": [
{
"form": {
"lineage_id": "lineage.form.order",
"kind": "FORM",
"name": "ФормаДокумента",
"qualified_name": "Документ.ЗаказПокупателя.ФормаДокумента",
},
"commands": [
{
"lineage_id": "lineage.command.post",
"kind": "COMMAND",
"name": "Провести",
"qualified_name": "Документ.ЗаказПокупателя.ФормаДокумента.Провести",
}
],
"elements": [],
"command_handlers": {
"lineage.command.post": {
"lineage_id": "lineage.procedure.post",
"kind": "PROCEDURE",
"name": "ПровестиКоманда",
"qualified_name": "ObjectModule.ПровестиКоманда",
}
},
}
],
}
monkeypatch.setattr(main, "_neo4j_object_ui_query", fake_query)
client = TestClient(app)
response = client.get("/projects/demo/graph/neo4j/objects/ui/Документ.ЗаказПокупателя")
assert response.status_code == 200
payload = response.json()
assert payload["object"]["name"] == "ЗаказПокупателя"
assert payload["forms"][0]["commands"][0]["name"] == "Провести"
assert next(iter(payload["forms"][0]["command_handlers"].values()))["name"] == "ПровестиКоманда"
def test_neo4j_object_attributes_endpoint_contract(monkeypatch):
async def fake_query(project_id: str, object_name: str):
assert project_id == "demo"
assert object_name == "Документ.ЗаказПокупателя"
return [
{
"lineage_id": "lineage.attribute.counterparty",
"kind": "ATTRIBUTE",
"name": "Контрагент",
"qualified_name": "Документ.ЗаказПокупателя.Контрагент",
}
]
monkeypatch.setattr(main, "_neo4j_object_attributes_query", fake_query)
client = TestClient(app)
response = client.get("/projects/demo/graph/neo4j/objects/attributes/Документ.ЗаказПокупателя")
assert response.status_code == 200
assert response.json()["results"][0]["name"] == "Контрагент"
def test_neo4j_object_schema_endpoint_contract(monkeypatch):
async def fake_query(project_id: str, object_name: str):
assert project_id == "demo"
assert object_name == "Документ.ЗаказПокупателя"
return {
"object": {
"lineage_id": "lineage.document.order",
"kind": "DOCUMENT",
"name": "ЗаказПокупателя",
"qualified_name": "Документ.ЗаказПокупателя",
},
"attributes": [
{
"lineage_id": "lineage.attribute.counterparty",
"kind": "ATTRIBUTE",
"name": "Контрагент",
"qualified_name": "Документ.ЗаказПокупателя.Контрагент",
}
],
"tabular_sections": [
{
"tabular_section": {
"lineage_id": "lineage.tabular.goods",
"kind": "TABULAR_SECTION",
"name": "Товары",
"qualified_name": "Документ.ЗаказПокупателя.Товары",
},
"columns": [
{
"lineage_id": "lineage.attribute.item",
"kind": "ATTRIBUTE",
"name": "Номенклатура",
"qualified_name": "Документ.ЗаказПокупателя.Товары.Номенклатура",
}
],
}
],
}
monkeypatch.setattr(main, "_neo4j_object_schema_query", fake_query)
client = TestClient(app)
response = client.get("/projects/demo/graph/neo4j/objects/schema/Документ.ЗаказПокупателя")
assert response.status_code == 200
payload = response.json()
assert payload["object"]["name"] == "ЗаказПокупателя"
assert payload["attributes"][0]["name"] == "Контрагент"
assert payload["tabular_sections"][0]["columns"][0]["name"] == "Номенклатура"
def test_neo4j_object_tabular_sections_endpoint_contract(monkeypatch):
async def fake_query(project_id: str, object_name: str):
assert project_id == "demo"
assert object_name == "Документ.ЗаказПокупателя"
return [
{
"lineage_id": "lineage.tabular.goods",
"kind": "TABULAR_SECTION",
"name": "Товары",
"qualified_name": "Документ.ЗаказПокупателя.Товары",
}
]
monkeypatch.setattr(main, "_neo4j_object_tabular_sections_query", fake_query)
client = TestClient(app)
response = client.get("/projects/demo/graph/neo4j/objects/tabular-sections/Документ.ЗаказПокупателя")
assert response.status_code == 200
assert response.json()["results"][0]["name"] == "Товары"
def test_neo4j_object_tabular_section_columns_endpoint_contract(monkeypatch):
async def fake_query(project_id: str, object_name: str):
assert project_id == "demo"
assert object_name == "Документ.ЗаказПокупателя"
return [
{
"tabular_section": {
"lineage_id": "lineage.tabular.goods",
"kind": "TABULAR_SECTION",
"name": "Товары",
"qualified_name": "Документ.ЗаказПокупателя.Товары",
},
"columns": [
{
"lineage_id": "lineage.attribute.item",
"kind": "ATTRIBUTE",
"name": "Номенклатура",
"qualified_name": "Документ.ЗаказПокупателя.Товары.Номенклатура",
}
],
}
]
monkeypatch.setattr(main, "_neo4j_object_tabular_section_columns_query", fake_query)
client = TestClient(app)
response = client.get("/projects/demo/graph/neo4j/objects/tabular-sections/Документ.ЗаказПокупателя/columns")
assert response.status_code == 200
assert response.json()[0]["tabular_section"]["name"] == "Товары"
assert response.json()[0]["columns"][0]["name"] == "Номенклатура"
def test_neo4j_access_endpoints_contract(monkeypatch):
async def fake_object_access(project_id: str, object_name: str):
assert project_id == "demo"
assert object_name == "Документ.ЗаказПокупателя"
return {
"object": {
"lineage_id": "lineage.document.order",
"kind": "DOCUMENT",
"name": "ЗаказПокупателя",
"qualified_name": "Документ.ЗаказПокупателя",
},
"grants": [
{
"role": {
"lineage_id": "lineage.role.manager",
"kind": "ROLE",
"name": "Менеджер",
"qualified_name": "Роль.Менеджер",
},
"permissions": {"read": "true", "write": "true"},
}
],
}
async def fake_role_access(project_id: str, role_name: str):
assert project_id == "demo"
assert role_name == "Роль.Менеджер"
return {
"role": {
"lineage_id": "lineage.role.manager",
"kind": "ROLE",
"name": "Менеджер",
"qualified_name": "Роль.Менеджер",
},
"objects": [
{
"lineage_id": "lineage.document.order",
"kind": "DOCUMENT",
"name": "ЗаказПокупателя",
"qualified_name": "Документ.ЗаказПокупателя",
}
],
"grants": [
{
"object": {
"lineage_id": "lineage.document.order",
"kind": "DOCUMENT",
"name": "ЗаказПокупателя",
"qualified_name": "Документ.ЗаказПокупателя",
},
"permissions": {"post": "true"},
}
],
}
monkeypatch.setattr(main, "_neo4j_object_access_query", fake_object_access)
monkeypatch.setattr(main, "_neo4j_role_access_query", fake_role_access)
client = TestClient(app)
object_response = client.get(
"/projects/demo/graph/neo4j/access/objects/Документ.ЗаказПокупателя/roles"
)
role_response = client.get("/projects/demo/graph/neo4j/access/roles/Роль.Менеджер/objects")
assert object_response.status_code == 200
assert object_response.json()["grants"][0]["role"]["name"] == "Менеджер"
assert role_response.status_code == 200
assert role_response.json()["objects"][0]["name"] == "ЗаказПокупателя"