5843 lines
245 KiB
Python
5843 lines
245 KiB
Python
import asyncio
|
||
from pathlib import Path
|
||
import json
|
||
import re
|
||
import time
|
||
from types import SimpleNamespace
|
||
from urllib.parse import quote
|
||
from uuid import uuid4
|
||
import zipfile
|
||
|
||
from fastapi.testclient import TestClient
|
||
|
||
from api_server import main
|
||
from api_server.html5_forms import (
|
||
form_value,
|
||
html5_csv_values,
|
||
html5_metadata_payload,
|
||
html5_metadata_request_payload,
|
||
)
|
||
from api_server.html5_operations import filter_html5_operation_jobs, latest_html5_import_job
|
||
from api_server.html5_sse import html5_sse_comment, html5_sse_event, html5_sse_if_changed
|
||
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 assert_html5_contract(text: str, *markers: str, full_page: bool = False) -> None:
|
||
assert "__next" not in text
|
||
assert "unpkg.com" not in text
|
||
assert 'hx-trigger="every' not in text
|
||
if full_page:
|
||
assert "<!doctype html>" in text
|
||
assert "<style>" not in text
|
||
assert re.search(r'/html5/assets/html5\.css\?v=[^"]+', text)
|
||
assert re.search(r'/html5/assets/htmx\.min\.js\?v=[^"]+', text)
|
||
assert re.search(r'/html5/assets/htmx-ext-sse\.js\?v=[^"]+', text)
|
||
else:
|
||
assert "<html" not in text
|
||
for marker in markers:
|
||
assert marker in text
|
||
|
||
|
||
def assert_html5_response_contract(response, *markers: str, full_page: bool = False) -> None:
|
||
assert "text/html" in response.headers["content-type"]
|
||
assert response.headers["cache-control"] == "no-cache, no-transform"
|
||
assert response.headers["x-content-type-options"] == "nosniff"
|
||
assert response.headers["content-security-policy"] == (
|
||
"default-src 'self'; "
|
||
"script-src 'self'; "
|
||
"style-src 'self'; "
|
||
"connect-src 'self'; "
|
||
"img-src 'self' data:; "
|
||
"base-uri 'self'; "
|
||
"form-action 'self'"
|
||
)
|
||
assert_html5_contract(response.text, *markers, full_page=full_page)
|
||
|
||
|
||
def test_html5_sse_formatters_emit_stable_event_stream_chunks():
|
||
assert html5_sse_comment("heartbeat") == ": heartbeat\n\n"
|
||
assert html5_sse_event("status", "<div>\nready</div>") == (
|
||
"event: status\n"
|
||
"retry: 5000\n"
|
||
"data: <div>\n"
|
||
"data: ready</div>\n\n"
|
||
)
|
||
|
||
last_fragments: dict[str, str] = {}
|
||
first = list(html5_sse_if_changed(last_fragments, "status", "<div>ready</div>"))
|
||
second = list(html5_sse_if_changed(last_fragments, "status", "<div>ready</div>"))
|
||
third = list(html5_sse_if_changed(last_fragments, "status", "<div>done</div>"))
|
||
|
||
assert len(first) == 1
|
||
assert second == []
|
||
assert len(third) == 1
|
||
assert "data: <div>done</div>" in third[0]
|
||
|
||
|
||
def test_html5_form_helpers_normalize_metadata_payloads():
|
||
form = {
|
||
"name": [" ЗаказПокупателя "],
|
||
"synonym": [" Заказ покупателя "],
|
||
"attributes": ["Номер:Строка, Дата:Дата\nКомментарий"],
|
||
"tabular_sections": ["Товары[Номенклатура:СправочникСсылка.Номенклатура;Количество:Число]"],
|
||
"forms": ["ФормаДокумента, ФормаСписка\nФормаВыбора"],
|
||
"commands": ["Провести:ПровестиДокумент, Печать"],
|
||
"task_id": [" task-1 "],
|
||
"empty": [" "],
|
||
}
|
||
|
||
payload = html5_metadata_payload(form)
|
||
|
||
assert form_value(form, "name") == "ЗаказПокупателя"
|
||
assert form_value(form, "empty") is None
|
||
assert html5_csv_values("one, two\nthree") == ["one", "two", "three"]
|
||
assert payload["object_kind"] == "DOCUMENT"
|
||
assert payload["name"] == "ЗаказПокупателя"
|
||
assert payload["attributes"] == [
|
||
{"name": "Номер", "type": "Строка"},
|
||
{"name": "Дата", "type": "Дата"},
|
||
{"name": "Комментарий", "type": "Строка"},
|
||
]
|
||
assert payload["tabular_sections"] == [
|
||
{
|
||
"name": "Товары",
|
||
"attributes": [
|
||
{"name": "Номенклатура", "type": "СправочникСсылка.Номенклатура"},
|
||
{"name": "Количество", "type": "Число"},
|
||
],
|
||
}
|
||
]
|
||
assert payload["forms"] == ["ФормаДокумента", "ФормаСписка", "ФормаВыбора"]
|
||
assert payload["commands"] == [
|
||
{"name": "Провести", "handler": "ПровестиДокумент"},
|
||
{"name": "Печать", "handler": None},
|
||
]
|
||
assert payload["_raw_attributes"] == "Номер:Строка, Дата:Дата\nКомментарий"
|
||
assert "_raw_attributes" not in html5_metadata_request_payload(payload)
|
||
|
||
|
||
def test_html5_operation_helpers_filter_sort_and_find_latest_import_job():
|
||
jobs = [
|
||
SimpleNamespace(
|
||
job_id="old",
|
||
kind="SERVER_IMPORT",
|
||
status="SUCCEEDED",
|
||
payload={"project_id": "demo"},
|
||
updated_at=10,
|
||
),
|
||
SimpleNamespace(
|
||
job_id="new",
|
||
kind="SERVER_IMPORT",
|
||
status="RUNNING",
|
||
payload={"project_id": "demo"},
|
||
updated_at=30,
|
||
),
|
||
SimpleNamespace(
|
||
job_id="other-kind",
|
||
kind="REINDEX",
|
||
status="RUNNING",
|
||
payload={"project_id": "demo"},
|
||
updated_at=40,
|
||
),
|
||
SimpleNamespace(
|
||
job_id="other-project",
|
||
kind="SERVER_IMPORT",
|
||
status="RUNNING",
|
||
payload={"project_id": "other"},
|
||
updated_at=50,
|
||
),
|
||
]
|
||
|
||
filtered = filter_html5_operation_jobs(
|
||
jobs,
|
||
project_id=" DEMO ",
|
||
status="running",
|
||
kind="server_import",
|
||
)
|
||
|
||
assert [job.job_id for job in filtered] == ["new"]
|
||
assert [job.job_id for job in filter_html5_operation_jobs(jobs, project_id="demo")] == [
|
||
"other-kind",
|
||
"new",
|
||
"old",
|
||
]
|
||
assert latest_html5_import_job(jobs, "demo").job_id == "new"
|
||
|
||
|
||
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-flowchart" in editor.text
|
||
assert 'sse-swap="project-flowchart"' in editor.text
|
||
assert f'hx-get="/html5/projects/{project_id}/flowchart?depth=1"' in editor.text
|
||
assert "data-html5-project-report" in editor.text
|
||
assert 'sse-swap="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 'sse-swap="project-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 'sse-swap="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 re.search(r'/html5/assets/html5\.css\?v=[^"]+', editor.text)
|
||
assert re.search(r'/html5/assets/htmx\.min\.js\?v=[^"]+', editor.text)
|
||
assert re.search(r'/html5/assets/htmx-ext-sse\.js\?v=[^"]+', editor.text)
|
||
assert "unpkg.com" not in editor.text
|
||
assert "client-js: htmx+sse only" in editor.text
|
||
css = client.get("/html5/assets/html5.css")
|
||
assert css.status_code == 200
|
||
assert ".object-actions .button[data-html5-object-action-active" in css.text
|
||
assert ".inline-actions" in css.text
|
||
assert ".object-breadcrumb" in css.text
|
||
assert ".object-summary" in css.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:
|
||
assert events.headers["cache-control"] == "no-cache, no-transform"
|
||
assert events.headers["x-accel-buffering"] == "no"
|
||
assert events.headers["connection"] == "keep-alive"
|
||
assert events.headers["x-content-type-options"] == "nosniff"
|
||
first_chunk = "".join(events.iter_text())
|
||
assert ": project " in first_chunk
|
||
assert "retry: 5000" in first_chunk
|
||
assert "event: status" in first_chunk
|
||
assert "event: authoring-changes" in first_chunk
|
||
assert "event: project-report" in first_chunk
|
||
assert "event: project-review" in first_chunk
|
||
assert "event: project-flowchart" in first_chunk
|
||
assert "data-html5-authoring-changes" in first_chunk
|
||
assert "data-html5-project-report" in first_chunk
|
||
assert "data-html5-review" in first_chunk
|
||
assert "data-html5-flowchart" 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 "data-html5-source-summary" in source.text
|
||
assert "Lines" in source.text
|
||
assert "chars" in source.text
|
||
assert "Проверить" in source.text
|
||
assert "<html" not in source.text
|
||
|
||
source_by_path = client.get(
|
||
f"/html5/projects/{project_id}/source/by-path",
|
||
params={"path": module_node["source_ref"]["source_path"]},
|
||
)
|
||
assert source_by_path.status_code == 200
|
||
assert "data-html5-source-summary" in source_by_path.text
|
||
assert "Проверить" in source_by_path.text
|
||
assert "<html" not in source_by_path.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 "data-html5-symbol-summary" in detail.text
|
||
assert "references" in detail.text
|
||
assert "data-html5-symbol-source" in detail.text
|
||
assert 'hx-target="[data-html5-source]"' 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 "data-html5-project-summary" in report.text
|
||
assert "risk signals" 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 "data-html5-review-summary" in review.text
|
||
assert "findings" in review.text
|
||
assert "data-html5-review-source" in review.text or "Findings не найдены" 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-summary" in authoring.text
|
||
assert "changes" in authoring.text
|
||
assert "data-html5-authoring-detail" in authoring.text
|
||
assert "Изменений пока нет" in authoring.text
|
||
assert "<html" not in authoring.text
|
||
|
||
htmx_asset = client.get("/html5/assets/htmx.min.js")
|
||
assert htmx_asset.status_code == 200
|
||
assert "javascript" in htmx_asset.headers["content-type"]
|
||
assert htmx_asset.headers["cache-control"] == "public, max-age=31536000, immutable"
|
||
assert htmx_asset.headers["x-content-type-options"] == "nosniff"
|
||
assert "htmx" in htmx_asset.text
|
||
sse_asset = client.get("/html5/assets/htmx-ext-sse.js")
|
||
assert sse_asset.status_code == 200
|
||
assert "javascript" in sse_asset.headers["content-type"]
|
||
assert sse_asset.headers["cache-control"] == "public, max-age=31536000, immutable"
|
||
assert sse_asset.headers["x-content-type-options"] == "nosniff"
|
||
assert "sse" in sse_asset.text
|
||
css_asset = client.get("/html5/assets/html5.css")
|
||
assert css_asset.status_code == 200
|
||
assert "text/css" in css_asset.headers["content-type"]
|
||
assert css_asset.headers["cache-control"] == "public, max-age=31536000, immutable"
|
||
assert css_asset.headers["x-content-type-options"] == "nosniff"
|
||
assert css_asset.headers["etag"]
|
||
cached_css_asset = client.get("/html5/assets/html5.css", headers={"If-None-Match": css_asset.headers["etag"]})
|
||
assert cached_css_asset.status_code == 304
|
||
assert cached_css_asset.headers["cache-control"] == "public, max-age=31536000, immutable"
|
||
assert cached_css_asset.headers["x-content-type-options"] == "nosniff"
|
||
assert cached_css_asset.content == b""
|
||
assert ".workspace" in css_asset.text
|
||
|
||
|
||
def test_html5_contracts_are_server_rendered_and_stable(tmp_path: Path):
|
||
client = TestClient(app)
|
||
project_id = f"html5-contract-{uuid4()}"
|
||
module = tmp_path / "contract_module.bsl"
|
||
module.write_text(
|
||
"Процедура ПроверитьКонтракт()\n"
|
||
" Сообщить(\"contract\");\n"
|
||
"КонецПроцедуры\n",
|
||
encoding="utf-8",
|
||
)
|
||
indexed = client.post("/projects/index", json={"path": str(tmp_path), "project_id": project_id})
|
||
assert indexed.status_code == 200
|
||
settings = client.post(
|
||
f"/projects/{project_id}/settings",
|
||
json={"name": "HTML5 Contract", "structure_source": "XML_DUMP"},
|
||
)
|
||
assert settings.status_code == 200
|
||
|
||
full_pages = [
|
||
("/html5", ("data-html5-page=\"projects\"", "data-html5-projects")),
|
||
(f"/html5/projects/{project_id}/editor", ("data-html5-page=\"editor\"", "data-html5-editor")),
|
||
(f"/html5/projects/{project_id}/setup", ("data-html5-page=\"setup\"", "data-html5-setup-summary")),
|
||
("/html5/operations", ("data-html5-page=\"operations\"", "data-html5-operations-filter")),
|
||
]
|
||
for path, markers in full_pages:
|
||
response = client.get(path)
|
||
assert response.status_code == 200
|
||
assert_html5_response_contract(response, *markers, full_page=True)
|
||
|
||
partials = [
|
||
(f"/html5/projects/{project_id}/symbols", {"q": "Проверить"}, ("data-html5-symbol",)),
|
||
(f"/html5/projects/{project_id}/report", {}, ("data-html5-project-report", "data-html5-project-summary")),
|
||
(f"/html5/projects/{project_id}/review", {}, ("data-html5-review", "data-html5-review-summary")),
|
||
(f"/html5/projects/{project_id}/flowchart", {}, ("data-html5-flowchart",)),
|
||
(f"/html5/projects/{project_id}/authoring/changes", {}, ("data-html5-authoring-changes", "data-html5-authoring-summary")),
|
||
(f"/html5/projects/{project_id}/setup/summary", {}, ("data-html5-setup-summary",)),
|
||
("/html5/operations/jobs", {}, ("data-html5-operation",)),
|
||
("/html5/operations/summary", {}, ("data-html5-operations-summary",)),
|
||
]
|
||
for path, params, markers in partials:
|
||
response = client.get(path, params=params)
|
||
assert response.status_code == 200
|
||
assert_html5_response_contract(response, *markers)
|
||
|
||
|
||
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_project_create_accepts_multipart_browser_form():
|
||
client = TestClient(app)
|
||
project_id = f"html5-multipart-{uuid4()}"
|
||
|
||
created = client.post(
|
||
"/html5/projects",
|
||
files={
|
||
"project_id": (None, project_id),
|
||
"name": (None, "HTML5 Multipart"),
|
||
},
|
||
)
|
||
|
||
assert created.status_code == 200
|
||
assert_html5_response_contract(created, project_id, "HTML5 Multipart")
|
||
|
||
setup = client.get(f"/html5/projects/{project_id}/setup")
|
||
assert setup.status_code == 200
|
||
assert_html5_response_contract(setup, "data-html5-page=\"setup\"", "HTML5 Multipart", full_page=True)
|
||
|
||
|
||
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(
|
||
"""
|
||
Процедура ПровестиКоманда()
|
||
ПроверитьКонтрагента();
|
||
Соединение = Новый HTTPСоединение("api.example.local");
|
||
Адрес = "https://api.example.local/orders";
|
||
КонецПроцедуры
|
||
|
||
Процедура ПроверитьКонтрагента()
|
||
КонецПроцедуры
|
||
""",
|
||
encoding="utf-8",
|
||
)
|
||
form_module = tmp_path / "Documents" / "ЗаказПокупателя" / "Forms" / "ФормаДокумента" / "Ext" / "Form" / "Module.bsl"
|
||
form_module.parent.mkdir(parents=True)
|
||
form_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
|
||
snapshot = client.get(f"/projects/{project_id}/snapshot/export").json()
|
||
handler = next(node for node in snapshot["nodes"] if node["name"] == "ПровестиКоманда")
|
||
form_node = next(node for node in snapshot["nodes"] if node["name"] == "ФормаДокумента")
|
||
attribute = next(node for node in snapshot["nodes"] if node["name"] == "Контрагент")
|
||
signal = client.post(
|
||
f"/projects/{project_id}/runtime/signals",
|
||
json={
|
||
"signal": {
|
||
"signal_id": "html5-runtime.1",
|
||
"lineage_id": handler["lineage_id"],
|
||
"kind": "ERROR",
|
||
"duration_ms": 125.0,
|
||
}
|
||
},
|
||
)
|
||
assert signal.status_code == 200
|
||
knowledge = client.post(
|
||
"/knowledge",
|
||
json={
|
||
"record_id": f"knowledge.html5.object.{uuid4()}",
|
||
"scope": "PROJECT",
|
||
"title": "Правила проведения HTML5",
|
||
"body": "Контекст проведения заказа для HTML5 inspector.",
|
||
"related_lineages": [handler["lineage_id"]],
|
||
},
|
||
)
|
||
assert knowledge.status_code == 200
|
||
marker = client.post(
|
||
f"/projects/{project_id}/privacy/markers",
|
||
json={
|
||
"target_id": attribute["lineage_id"],
|
||
"classification": "PERSONAL_DATA",
|
||
"reason": "Контрагент содержит персональные данные",
|
||
},
|
||
)
|
||
assert marker.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 "data-html5-object-actions" in context.text
|
||
assert f"/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 context.text
|
||
assert f"/projects/{project_id}/objects/schema/%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 context.text
|
||
assert f"/projects/{project_id}/objects/impact/%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 context.text
|
||
assert "mode=schema" in context.text
|
||
assert "mode=impact" in context.text
|
||
assert "mode=privacy" in context.text
|
||
assert 'data-html5-object-mode="overview"' in context.text
|
||
assert "data-html5-object-breadcrumb" in context.text
|
||
assert "<span>Документ</span>" in context.text
|
||
assert "<span>ЗаказПокупателя</span>" in context.text
|
||
assert "data-html5-object-summary" in context.text
|
||
assert "1 attrs" in context.text
|
||
assert "1 tables" in context.text
|
||
assert "1 commands" in context.text
|
||
assert "1 access rules" in context.text
|
||
assert "1 runtime signals" in context.text
|
||
assert "1 privacy markers" in context.text
|
||
assert "Object context · overview" in context.text
|
||
assert 'data-html5-object-action-active="true"' in context.text
|
||
assert 'aria-current="page"' in context.text
|
||
assert 'hx-target="[data-html5-object-context]"' in context.text
|
||
assert 'hx-target="[data-html5-flowchart]"' in context.text
|
||
assert f"/html5/projects/{project_id}/flowchart?focus=%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&depth=1" in context.text
|
||
assert 'hx-target="[data-html5-source]"' in context.text
|
||
assert 'data-html5-module-action="OBJECT_MODULE"' in context.text
|
||
assert 'data-html5-module-action="FORM_MODULE"' in context.text
|
||
assert "Модуль объекта" in context.text
|
||
assert "Модуль формы ФормаДокумента" in context.text
|
||
assert 'hx-target="[data-html5-symbol-detail]"' in context.text
|
||
assert "Контрагент" in context.text
|
||
assert "Товары" in context.text
|
||
assert "ФормаДокумента" in context.text
|
||
assert "data-html5-form-editor-link" in context.text
|
||
assert "Провести" in context.text
|
||
assert "ПровестиКоманда" in context.text
|
||
assert "ПроверитьКонтрагента" in context.text
|
||
assert "HTTPConnection" in context.text
|
||
assert "https://api.example.local/orders" in context.text
|
||
assert "OUTBOUND" in context.text
|
||
assert "data-html5-object-context-item=\"flow-edge\"" in context.text
|
||
assert "data-html5-flowchart-focus" in context.text
|
||
assert "data-html5-flowchart-context" in context.text
|
||
assert "data-html5-flowchart" in context.text
|
||
assert 'hx-swap-oob="outerHTML"' in context.text
|
||
assert "Карта связей · focus" in context.text
|
||
assert "data-html5-source" in context.text
|
||
assert 'data-html5-object-cache="warm"' in context.text
|
||
assert 'data-html5-owner="Документ.ЗаказПокупателя"' in context.text
|
||
assert 'data-html5-object-part="object.module"' in context.text
|
||
assert "data-html5-object-cache-summary" in context.text
|
||
assert "data-html5-source-summary" in context.text
|
||
assert "ObjectModule.bsl" in context.text
|
||
assert "Соединение = Новый HTTPСоединение" in context.text
|
||
assert "data-html5-symbol-detail" in context.text
|
||
assert "data-html5-symbol-summary" in context.text
|
||
assert "data-html5-symbol-source" in context.text
|
||
assert "Символ · DOCUMENT" in context.text
|
||
assert "HAS_ATTRIBUTE" in context.text
|
||
assert "data-html5-project-report" in context.text
|
||
assert "Отчет объекта" in context.text
|
||
assert "server focused summary" in context.text
|
||
|
||
form_editor = client.get(
|
||
f"/html5/projects/{project_id}/forms/editor",
|
||
params={"form": form_node["lineage_id"]},
|
||
)
|
||
assert form_editor.status_code == 200
|
||
assert "data-html5-page=\"form-editor\"" in form_editor.text
|
||
assert "data-html5-form-designer" in form_editor.text
|
||
assert "data-html5-form-canvas" in form_editor.text
|
||
assert "Документ.ЗаказПокупателя.ФормаДокумента" in form_editor.text
|
||
assert "Провести" in form_editor.text
|
||
assert "ПровестиКоманда" in form_editor.text
|
||
assert "Модуль формы" in form_editor.text
|
||
assert 'data-html5-object-cache="warm"' in form_editor.text
|
||
assert "data-html5-form-edit-form" in form_editor.text
|
||
assert "data-html5-form-window" in form_editor.text
|
||
assert "data-html5-form-properties" in form_editor.text
|
||
assert "Применить в макет" in form_editor.text
|
||
assert "ПриОткрытии" in form_editor.text
|
||
|
||
form_preview = client.post(
|
||
f"/html5/projects/{project_id}/forms/editor/preview",
|
||
data={
|
||
"form": form_node["lineage_id"],
|
||
"form_title": "Заказ покупателя 8.5",
|
||
"layout_kind": "columns",
|
||
"command_caption": "Провести заказ",
|
||
"new_element_name": "Комментарий",
|
||
"new_element_kind": "text",
|
||
},
|
||
)
|
||
assert form_preview.status_code == 200
|
||
assert "data-html5-form-designer" in form_preview.text
|
||
assert "Заказ покупателя 8.5" in form_preview.text
|
||
assert "Провести заказ" in form_preview.text
|
||
assert "Комментарий" in form_preview.text
|
||
assert 'data-html5-form-control="text"' in form_preview.text
|
||
assert 'data-html5-form-layout="columns"' in form_preview.text
|
||
assert "data-html5-object-report-summary" in context.text
|
||
assert "data links" in context.text
|
||
assert "data-html5-review" in context.text
|
||
assert "data-html5-review-summary" in context.text
|
||
assert "data-html5-review-source" in context.text
|
||
assert "Review объекта" in context.text
|
||
assert "External integration endpoint" in context.text
|
||
assert "1 signals" in context.text
|
||
assert "1 errors" in context.text
|
||
assert "125.0 ms" in context.text
|
||
assert "Правила проведения HTML5" in context.text
|
||
assert "Контекст проведения заказа" in context.text
|
||
assert "PERSONAL_DATA" 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
|
||
|
||
schema_context = client.get(
|
||
f"/html5/projects/{project_id}/objects/context/Документ.ЗаказПокупателя",
|
||
params={"mode": "schema"},
|
||
)
|
||
assert schema_context.status_code == 200
|
||
assert 'data-html5-object-mode="schema"' in schema_context.text
|
||
assert "Object context · schema" in schema_context.text
|
||
assert 'data-html5-object-action-active="true"' in schema_context.text
|
||
assert "Контрагент" in schema_context.text
|
||
|
||
impact_context = client.get(
|
||
f"/html5/projects/{project_id}/objects/context/Документ.ЗаказПокупателя",
|
||
params={"mode": "impact"},
|
||
)
|
||
assert impact_context.status_code == 200
|
||
assert 'data-html5-object-mode="impact"' in impact_context.text
|
||
assert "Object context · impact" in impact_context.text
|
||
assert "HTTPConnection" in impact_context.text
|
||
|
||
privacy_context = client.get(
|
||
f"/html5/projects/{project_id}/objects/context/Документ.ЗаказПокупателя",
|
||
params={"mode": "privacy"},
|
||
)
|
||
assert privacy_context.status_code == 200
|
||
assert 'data-html5-object-mode="privacy"' in privacy_context.text
|
||
assert "Object context · privacy" in privacy_context.text
|
||
assert "PERSONAL_DATA" in privacy_context.text
|
||
|
||
|
||
def test_html5_flowchart_fragment(tmp_path: Path):
|
||
client = TestClient(app)
|
||
project_id = f"html5-flowchart-{uuid4()}"
|
||
(tmp_path / "module.bsl").write_text(
|
||
"""
|
||
Процедура ПровестиЗаказ()
|
||
ПроверитьОстатки();
|
||
Движения.ОстаткиТоваров.Записать();
|
||
КонецПроцедуры
|
||
|
||
Процедура ПроверитьОстатки()
|
||
КонецПроцедуры
|
||
""",
|
||
encoding="utf-8",
|
||
)
|
||
indexed = client.post("/projects/index", json={"path": str(tmp_path), "project_id": project_id})
|
||
assert indexed.status_code == 200
|
||
|
||
flowchart = client.get(
|
||
f"/html5/projects/{project_id}/flowchart",
|
||
params={"focus": "ПровестиЗаказ"},
|
||
)
|
||
assert flowchart.status_code == 200
|
||
assert "text/html" in flowchart.headers["content-type"]
|
||
assert "data-html5-flowchart" in flowchart.text
|
||
assert "data-html5-flowchart-actions" in flowchart.text
|
||
assert "Depth 1" in flowchart.text
|
||
assert "Depth 2" in flowchart.text
|
||
assert "Depth 3" in flowchart.text
|
||
assert "depth=2" in flowchart.text
|
||
assert 'hx-target="[data-html5-flowchart]"' in flowchart.text
|
||
assert "data-html5-flowchart-focus" in flowchart.text
|
||
assert 'sse-swap="project-flowchart"' not in flowchart.text
|
||
assert 'hx-swap-oob="outerHTML"' not in flowchart.text
|
||
assert "Карта связей" in flowchart.text
|
||
assert "Nodes" in flowchart.text
|
||
assert "Edges" in flowchart.text
|
||
assert "ПровестиЗаказ" in flowchart.text or "ПроверитьОстатки" in flowchart.text
|
||
assert "<html" not in flowchart.text
|
||
|
||
deep_flowchart = client.get(
|
||
f"/html5/projects/{project_id}/flowchart",
|
||
params={"focus": "ПровестиЗаказ", "depth": 2},
|
||
)
|
||
assert deep_flowchart.status_code == 200
|
||
assert "depth=2" in deep_flowchart.text
|
||
assert 'data-html5-object-action-active="true"' in deep_flowchart.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 'hx-ext="sse"' in setup.text
|
||
assert f'sse-connect="/html5/projects/{project_id}/setup/events"' in setup.text
|
||
assert 'sse-swap="setup-summary"' in setup.text
|
||
assert 'sse-swap="setup-import-job"' in setup.text
|
||
assert 'hx-trigger="every 5s"' not 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 'sse-swap="setup-import-job"' in import_job.text
|
||
assert 'hx-trigger="every 2s"' not 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 'sse-swap="setup-import-job"' in job_fragment.text
|
||
assert 'hx-trigger="every 2s"' not 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-import-job" in reindex.text
|
||
assert "Переиндексация" in reindex.text
|
||
assert 'sse-swap="setup-import-job"' 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 'sse-swap="setup-summary"' in summary.text
|
||
assert 'hx-trigger="every 5s"' not in summary.text
|
||
assert "<html" not in summary.text
|
||
|
||
with client.stream("GET", f"/html5/projects/{project_id}/setup/events?once=1") as events:
|
||
assert events.headers["cache-control"] == "no-cache, no-transform"
|
||
assert events.headers["x-accel-buffering"] == "no"
|
||
assert events.headers["connection"] == "keep-alive"
|
||
assert events.headers["x-content-type-options"] == "nosniff"
|
||
first_chunk = "".join(events.iter_text())
|
||
assert ": setup " in first_chunk
|
||
assert "retry: 5000" in first_chunk
|
||
assert "event: setup-summary" in first_chunk
|
||
assert "event: setup-import-job" in first_chunk
|
||
assert "data-html5-setup-summary" in first_chunk
|
||
assert "data-html5-import-job" in first_chunk
|
||
assert project_id in first_chunk
|
||
|
||
|
||
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 "data-html5-operations-filter" in page.text
|
||
assert "data-html5-operation-detail" in page.text
|
||
assert 'hx-ext="sse"' in page.text
|
||
assert 'sse-connect="/html5/operations/events"' in page.text
|
||
assert 'sse-swap="operations-summary"' in page.text
|
||
assert 'sse-swap="operations-jobs"' in page.text
|
||
assert 'hx-trigger="every 3s"' not in page.text
|
||
assert project_id in page.text
|
||
assert "__next" not in page.text
|
||
|
||
with client.stream("GET", "/html5/operations/events?once=1") as events:
|
||
assert events.headers["cache-control"] == "no-cache, no-transform"
|
||
assert events.headers["x-accel-buffering"] == "no"
|
||
assert events.headers["connection"] == "keep-alive"
|
||
assert events.headers["x-content-type-options"] == "nosniff"
|
||
first_chunk = "".join(events.iter_text())
|
||
assert ": operations heartbeat" in first_chunk
|
||
assert "retry: 5000" in first_chunk
|
||
assert "event: operations-summary" in first_chunk
|
||
assert "event: operations-jobs" in first_chunk
|
||
assert "data-html5-operations-summary" in first_chunk
|
||
assert "data-html5-operation" in first_chunk
|
||
assert project_id in first_chunk
|
||
|
||
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 'hx-target="[data-html5-operation-detail]"' in rows.text
|
||
assert project_id in rows.text
|
||
assert "<html" not in rows.text
|
||
|
||
operation_job_id = client.get("/operations/jobs", params={"project_id": project_id, "kind": "SERVER_IMPORT"}).json()[0]["job_id"]
|
||
detail = client.get(f"/html5/operations/jobs/{operation_job_id}/detail")
|
||
assert detail.status_code == 200
|
||
assert "text/html" in detail.headers["content-type"]
|
||
assert "data-html5-operation-detail" in detail.text
|
||
assert operation_job_id in detail.text
|
||
assert "SERVER_IMPORT" in detail.text
|
||
assert project_id in detail.text
|
||
assert "<html" not in detail.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
|
||
|
||
job_status = client.get("/operations/jobs", params={"project_id": project_id, "kind": "SERVER_IMPORT"}).json()[0]["status"]
|
||
filtered = client.get("/html5/operations", params={"project_id": project_id, "status": job_status, "kind": "SERVER_IMPORT"})
|
||
assert filtered.status_code == 200
|
||
assert f'sse-connect="/html5/operations/events?project_id={project_id}&status={job_status}&kind=SERVER_IMPORT"' in filtered.text
|
||
assert f'value="{project_id}"' in filtered.text
|
||
assert f'value="{job_status}"' in filtered.text
|
||
assert 'value="SERVER_IMPORT"' in filtered.text
|
||
assert project_id in filtered.text
|
||
|
||
filtered_rows = client.get("/html5/operations/jobs", params={"project_id": project_id, "status": job_status, "kind": "SERVER_IMPORT"})
|
||
assert filtered_rows.status_code == 200
|
||
assert project_id in filtered_rows.text
|
||
|
||
with client.stream(
|
||
"GET",
|
||
f"/html5/operations/events?once=1&project_id={project_id}&status={job_status}&kind=SERVER_IMPORT",
|
||
) as events:
|
||
filtered_chunk = "".join(events.iter_text())
|
||
assert "event: operations-summary" in filtered_chunk
|
||
assert "event: operations-jobs" in filtered_chunk
|
||
assert project_id in filtered_chunk
|
||
|
||
|
||
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_smb_credentials_embedded_domain_format_is_normalized():
|
||
from api_server import smb_paths
|
||
|
||
domain, username = smb_paths._normalize_credentials("MST\\m", None)
|
||
|
||
assert domain == "MST"
|
||
assert username == "m"
|
||
|
||
|
||
def test_smb_error_translation_returns_russian_auth_message():
|
||
from api_server import smb_paths
|
||
|
||
message = smb_paths._translate_smb_error(
|
||
RuntimeError("STATUS_LOGON_FAILURE: logon failure"),
|
||
server="192.168.220.200",
|
||
username="MST\\m",
|
||
)
|
||
|
||
assert "Ошибка авторизации SMB" in message
|
||
assert "Проверьте логин, пароль, домен" in message
|
||
|
||
|
||
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(monkeypatch, tmp_path: Path):
|
||
(tmp_path / "metadata.xml").write_text(
|
||
"""
|
||
<Configuration>
|
||
<HTTPService name="ПубличныйAPI" qualifiedName="HTTPСервис.ПубличныйAPI" />
|
||
<Subsystem name="Продажи" qualifiedName="Подсистема.Продажи" />
|
||
<Sequence name="ПроведениеДокументов" qualifiedName="Последовательность.ПроведениеДокументов" />
|
||
<DocumentNumerator name="ОбщийНумератор" qualifiedName="НумераторДокументов.ОбщийНумератор" />
|
||
<Role name="Менеджер" qualifiedName="Роль.Менеджер">
|
||
<Right object="HTTPСервис.ПубличныйAPI" read="true" />
|
||
</Role>
|
||
<AccessProfile name="ПрофильМенеджера">
|
||
<Role name="Менеджер" />
|
||
</AccessProfile>
|
||
<AccessGroup name="Менеджеры" profile="ПрофильМенеджера">
|
||
<Member user="ivanov" />
|
||
</AccessGroup>
|
||
<InfobaseUser name="ivanov" fullName="Иванов Иван" />
|
||
</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"] >= 5
|
||
assert payload["normalized_summary"]["rights_count"] == 1
|
||
assert payload["normalized_summary"]["access_profile_count"] == 1
|
||
assert payload["normalized_summary"]["access_group_count"] == 1
|
||
assert payload["normalized_summary"]["access_user_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"
|
||
|
||
access = client.get(f"/projects/{project_id}/access")
|
||
assert access.status_code == 200
|
||
assert access.json()["profiles"][0]["roles"][0]["role_qualified_name"] == "Роль.Менеджер"
|
||
|
||
access_user = client.get(f"/projects/{project_id}/access/users/ivanov")
|
||
assert access_user.status_code == 200
|
||
assert access_user.json()["effective_roles"][0]["role_qualified_name"] == "Роль.Менеджер"
|
||
|
||
profile_preview = client.post(
|
||
f"/projects/{project_id}/access/profile-preview",
|
||
json={
|
||
"name": "НовыйПрофильHTTP",
|
||
"target_objects": ["HTTPСервис.ПубличныйAPI"],
|
||
"permissions": ["read"],
|
||
"source_user": "ivanov",
|
||
},
|
||
)
|
||
assert profile_preview.status_code == 200
|
||
preview_payload = profile_preview.json()
|
||
assert preview_payload["proposed_profile"]["qualified_name"] == "ПрофильГруппыДоступа.НовыйПрофильHTTP"
|
||
assert "Роль.Менеджер" in preview_payload["proposed_profile"]["roles"]
|
||
assert preview_payload["missing_objects"] == []
|
||
|
||
apply_profile = client.post(
|
||
f"/projects/{project_id}/access/profiles",
|
||
json={
|
||
"name": "НовыйПрофильHTTP",
|
||
"target_objects": ["HTTPСервис.ПубличныйAPI"],
|
||
"permissions": ["read"],
|
||
"author": "dev.ivan",
|
||
},
|
||
)
|
||
assert apply_profile.status_code == 200
|
||
assert apply_profile.json()["profile"]["attributes"]["status"] == "workspace_draft"
|
||
|
||
updated_access = client.get(f"/projects/{project_id}/access")
|
||
assert updated_access.status_code == 200
|
||
assert any(item["name"] == "НовыйПрофильHTTP" for item in updated_access.json()["profiles"])
|
||
|
||
publish_plan = client.get(
|
||
f"/projects/{project_id}/access/profiles/{quote('НовыйПрофильHTTP')}/publish-plan"
|
||
)
|
||
assert publish_plan.status_code == 200
|
||
plan_payload = publish_plan.json()
|
||
assert plan_payload["ready_for_extension"] is True
|
||
assert plan_payload["operations"][0]["action"] == "CREATE_ACCESS_PROFILE"
|
||
assert any(item["action"] == "ADD_ROLE_TO_PROFILE" and item["role"] == "Роль.Менеджер" for item in plan_payload["operations"])
|
||
assert plan_payload["extension_payload"]["operation"] == "access.profile.apply"
|
||
|
||
captured_extension_call = {}
|
||
|
||
def fake_extension_call(project_id_arg, settings_arg, request_arg):
|
||
captured_extension_call["project_id"] = project_id_arg
|
||
captured_extension_call["request"] = request_arg
|
||
return main.SferaExtensionCallResponse(
|
||
project_id=project_id_arg,
|
||
operation=request_arg.operation,
|
||
status="READY",
|
||
ready=True,
|
||
dry_run=request_arg.dry_run,
|
||
extension_url="http://example.test/hs/sfera/v1/metadata/apply",
|
||
result={"status": "dry_run", "operation": request_arg.operation},
|
||
)
|
||
|
||
monkeypatch.setattr(main, "_call_sfera_extension", fake_extension_call)
|
||
publish_dry_run = client.post(
|
||
f"/projects/{project_id}/access/profiles/{quote('НовыйПрофильHTTP')}/publish-dry-run"
|
||
)
|
||
assert publish_dry_run.status_code == 200
|
||
assert publish_dry_run.json()["operation"] == "access.profile.apply"
|
||
assert captured_extension_call["project_id"] == project_id
|
||
assert captured_extension_call["request"].dry_run is True
|
||
assert captured_extension_call["request"].payload["profile"]["roles"] == ["Роль.Менеджер"]
|
||
|
||
access_page = client.get(f"/html5/projects/{project_id}/access", params={"profile": "НовыйПрофильHTTP"})
|
||
assert_html5_response_contract(
|
||
access_page,
|
||
'data-html5-page="access"',
|
||
"НовыйПрофильHTTP",
|
||
"План публикации",
|
||
"Dry-run в 1С",
|
||
"Новый профиль доступа",
|
||
full_page=True,
|
||
)
|
||
|
||
html5_preview = client.post(
|
||
f"/html5/projects/{project_id}/access/profile-preview",
|
||
data={
|
||
"name": "HTML5ПрофильHTTP",
|
||
"target_objects": "HTTPСервис.ПубличныйAPI",
|
||
"permissions": "read",
|
||
"source_user": "ivanov",
|
||
},
|
||
)
|
||
assert_html5_response_contract(html5_preview, "предпросмотр", "Роль.Менеджер", "ПрофильГруппыДоступа.HTML5ПрофильHTTP")
|
||
|
||
html5_apply = client.post(
|
||
f"/html5/projects/{project_id}/access/profiles",
|
||
data={
|
||
"name": "HTML5ПрофильHTTP",
|
||
"target_objects": "HTTPСервис.ПубличныйAPI",
|
||
"permissions": "read",
|
||
"source_user": "ivanov",
|
||
},
|
||
)
|
||
assert_html5_response_contract(html5_apply, "сохранено", "HTML5ПрофильHTTP", "CREATE_ACCESS_PROFILE")
|
||
|
||
html5_user = client.get(f"/html5/projects/{project_id}/access/users/ivanov")
|
||
assert_html5_response_contract(html5_user, "пользователь", "ivanov", "Эффективные роли", "Роль.Менеджер")
|
||
|
||
access_plan = client.get(
|
||
f"/html5/projects/{project_id}/access/profiles/{quote('НовыйПрофильHTTP')}/plan"
|
||
)
|
||
assert_html5_response_contract(access_plan, "CREATE_ACCESS_PROFILE", "ADD_ROLE_TO_PROFILE")
|
||
|
||
access_dry_run = client.post(
|
||
f"/html5/projects/{project_id}/access/profiles/{quote('НовыйПрофильHTTP')}/publish-dry-run"
|
||
)
|
||
assert_html5_response_contract(access_dry_run, "Ответ расширения", "READY", "access.profile.apply")
|
||
|
||
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"] == "Общие")
|
||
common_labels = {item["label"] for item in common["children"]}
|
||
assert {"HTTP-сервисы", "Подсистемы", "Последовательности", "Нумераторы документов"}.issubset(common_labels)
|
||
|
||
|
||
def test_ai_structure_prepare_writes_ai_ready_package(tmp_path: Path):
|
||
source = tmp_path / "source"
|
||
output = tmp_path / "ai-out"
|
||
source.mkdir()
|
||
(source / "metadata.xml").write_text(
|
||
"""
|
||
<Configuration>
|
||
<Catalog name="Контрагенты" qualifiedName="Справочник.Контрагенты">
|
||
<Attribute name="ИНН" qualifiedName="Справочник.Контрагенты.ИНН" />
|
||
</Catalog>
|
||
<CommonModule name="Интеграция" qualifiedName="ОбщийМодуль.Интеграция" />
|
||
</Configuration>
|
||
""",
|
||
encoding="utf-8",
|
||
)
|
||
(source / "Интеграция.bsl").write_text(
|
||
"Процедура Выполнить() Экспорт\nКонецПроцедуры\n",
|
||
encoding="utf-8",
|
||
)
|
||
client = TestClient(app)
|
||
|
||
response = client.post(
|
||
"/ai-structure/prepare",
|
||
json={"project_id": "ai-demo", "input_path": str(source), "output_path": str(output)},
|
||
)
|
||
|
||
assert response.status_code == 200
|
||
payload = response.json()
|
||
assert payload["status"] == "ready"
|
||
assert payload["snapshot"]["nodes"] >= 2
|
||
codex_package = output / payload["codex_package_folder"]
|
||
assert (output / "manifest.json").exists()
|
||
assert (output / "normalized_project.json").exists()
|
||
assert (output / "sir_snapshot.json").exists()
|
||
assert (codex_package / "AGENTS.md").exists()
|
||
assert (codex_package / "README.md").exists()
|
||
assert (codex_package / "context" / "CODEX_START_HERE.md").exists()
|
||
assert (codex_package / "context" / "project-overview.md").exists()
|
||
assert (codex_package / "indexes" / "codex-navigation.json").exists()
|
||
assert (codex_package / "indexes" / "objects.json").exists()
|
||
assert (codex_package / "indexes" / "source-map.json").exists()
|
||
assert (codex_package / "raw" / "normalized_project.json").exists()
|
||
assert (codex_package / "source" / "metadata.xml").exists()
|
||
assert (codex_package / "source" / "Интеграция.bsl").read_text(encoding="utf-8").startswith("Процедура")
|
||
modules_index = json.loads((codex_package / "indexes" / "modules.json").read_text(encoding="utf-8"))
|
||
assert any(item.get("local_source_path") == "source/Интеграция.bsl" for item in modules_index)
|
||
module_docs = "\n".join(path.read_text(encoding="utf-8") for path in (codex_package / "modules").glob("*.md"))
|
||
assert "Локальный исходник: `source/Интеграция.bsl`" in module_docs
|
||
assert "Эта папка сгенерирована SFERA для Codex" in (codex_package / "AGENTS.md").read_text(encoding="utf-8")
|
||
assert "локальную папку `source/`" in (codex_package / "AGENTS.md").read_text(encoding="utf-8")
|
||
assert "Перенесите эту папку целиком в проект Codex" in (codex_package / "README.md").read_text(encoding="utf-8")
|
||
assert "Рассматривайте модули, формы и команды как части объектов 1С-владельцев" in (output / "ai_context.md").read_text(encoding="utf-8")
|
||
|
||
page = client.get("/html5/projects/ai-demo/ai-structure")
|
||
assert_html5_response_contract(
|
||
page,
|
||
'data-html5-page="ai-structure"',
|
||
"Структура для ИИ",
|
||
"192.168.220.200",
|
||
"Пути должны быть доступны серверу",
|
||
"smb_username",
|
||
"smb_password",
|
||
"data-ai-structure-progress",
|
||
"Осталось примерно",
|
||
"html5-ai-structure.js",
|
||
"Идентификатор проекта",
|
||
full_page=True,
|
||
)
|
||
|
||
html5_run = client.post(
|
||
"/html5/projects/ai-demo/ai-structure/run",
|
||
data={"project_id": "ai-demo-html5", "input_path": str(source), "output_path": str(tmp_path / "html5-out")},
|
||
)
|
||
assert_html5_response_contract(html5_run, "готово", "codex-1c-context-ai-demo-html5", "Снимок графа SIR", "Нормализованный проект")
|
||
|
||
html5_missing = client.post(
|
||
"/html5/projects/ai-demo/ai-structure/run",
|
||
data={"project_id": "ai-demo-html5", "input_path": str(tmp_path / "missing"), "output_path": str(tmp_path / "html5-out")},
|
||
)
|
||
assert_html5_response_contract(html5_missing, "ошибка", "Входная папка не найдена")
|
||
|
||
html5_smb_without_credentials = client.post(
|
||
"/html5/projects/ai-demo/ai-structure/run",
|
||
data={
|
||
"project_id": "ai-demo-html5",
|
||
"input_path": r"\\192.168.220.200\mst\1c\MARKA\CODEX\CF",
|
||
"output_path": r"\\192.168.220.200\mst\1c\MARKA\CODEX\CODEX",
|
||
},
|
||
)
|
||
assert_html5_response_contract(html5_smb_without_credentials, "ошибка", "логин и пароль SMB")
|
||
|
||
|
||
def test_ai_structure_prepare_reports_cf_cfe_export_required(tmp_path: Path):
|
||
source = tmp_path / "cf-source"
|
||
output = tmp_path / "cf-out"
|
||
source.mkdir()
|
||
(source / "base.cf").write_bytes(b"binary-cf")
|
||
(source / "ext.cfe").write_bytes(b"binary-cfe")
|
||
client = TestClient(app)
|
||
|
||
response = client.post(
|
||
"/ai-structure/prepare",
|
||
json={"project_id": "binary-demo", "input_path": str(source), "output_path": str(output)},
|
||
)
|
||
|
||
assert response.status_code == 200
|
||
payload = response.json()
|
||
assert payload["status"] == "export_required"
|
||
assert len(payload["binary_1c_files"]) == 2
|
||
assert "DumpConfigToFiles" in (output / "export_plan.md").read_text(encoding="utf-8")
|
||
assert (output / payload["codex_package_folder"] / "AGENTS.md").exists()
|
||
assert "Статус: `export_required`" in (output / payload["codex_package_folder"] / "README.md").read_text(encoding="utf-8")
|
||
|
||
|
||
def test_html5_ai_structure_routes_binary_cf_through_windows_agent(tmp_path: Path):
|
||
metadata_root = tmp_path / "metadata"
|
||
metadata_root.mkdir()
|
||
(metadata_root / "metadata.xml").write_text(
|
||
"""
|
||
<Configuration>
|
||
<Catalog name="Контрагенты" qualifiedName="Справочник.Контрагенты" />
|
||
</Configuration>
|
||
""",
|
||
encoding="utf-8",
|
||
)
|
||
cf_input = tmp_path / "demo.cf"
|
||
cf_input.write_bytes(b"binary-cf")
|
||
output = tmp_path / "ai-out"
|
||
client = TestClient(app)
|
||
project_id = f"ai-agent-{uuid4()}"
|
||
agent_id = f"win-agent-{uuid4()}"
|
||
|
||
indexed = client.post("/projects/index", json={"path": str(metadata_root), "project_id": project_id})
|
||
assert indexed.status_code == 200
|
||
settings = client.post(
|
||
f"/projects/{project_id}/settings",
|
||
json={
|
||
"name": "AI Agent Demo",
|
||
"structure_source": "CF_FILE",
|
||
"agent": {
|
||
"cf_agent_id": agent_id,
|
||
"one_c_server": "192.168.200.95",
|
||
"one_c_infobase": "upo_test",
|
||
"one_c_user": "svc",
|
||
"one_c_password": "secret",
|
||
},
|
||
},
|
||
)
|
||
assert settings.status_code == 200
|
||
heartbeat = client.post("/agent/heartbeat", json={"agent_id": agent_id, "host": "test-host"})
|
||
assert heartbeat.status_code == 200
|
||
|
||
queued = client.post(
|
||
f"/html5/projects/{project_id}/ai-structure/run",
|
||
data={"project_id": project_id, "input_path": str(cf_input), "output_path": str(output)},
|
||
)
|
||
assert queued.status_code == 200
|
||
assert "Windows Agent" in queued.text
|
||
assert "/ai-structure/jobs/" in queued.text
|
||
match = re.search(r"/html5/projects/[^/]+/ai-structure/jobs/([A-Za-z0-9-]+)", queued.text)
|
||
assert match is not None
|
||
job_id = match.group(1)
|
||
|
||
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()["source"] == "CF_FILE"
|
||
assert claimed.json()["local_path"] == str(cf_input)
|
||
|
||
completed = client.post(
|
||
f"/agent/jobs/{job_id}/result",
|
||
json={
|
||
"status": "SUCCEEDED",
|
||
"server_path": str(metadata_root),
|
||
"logs": ["Выгрузка конфигурации завершена."],
|
||
},
|
||
)
|
||
assert completed.status_code == 200
|
||
|
||
deadline = time.monotonic() + 10
|
||
fragment = ""
|
||
while time.monotonic() < deadline:
|
||
polled = client.get(f"/html5/projects/{project_id}/ai-structure/jobs/{job_id}")
|
||
assert polled.status_code == 200
|
||
fragment = polled.text
|
||
if "готово" in fragment:
|
||
break
|
||
time.sleep(0.05)
|
||
assert "готово" in fragment
|
||
assert "codex-1c-context" in fragment
|
||
assert (output / f"codex-1c-context-{project_id}" / "AGENTS.md").exists()
|
||
|
||
|
||
def test_html5_ai_structure_routes_binary_cfe_through_windows_agent(tmp_path: Path):
|
||
metadata_root = tmp_path / "extension"
|
||
metadata_root.mkdir()
|
||
(metadata_root / "metadata.xml").write_text(
|
||
"""
|
||
<Configuration>
|
||
<Catalog name="Контрагенты" qualifiedName="Справочник.Контрагенты" />
|
||
</Configuration>
|
||
""",
|
||
encoding="utf-8",
|
||
)
|
||
cfe_input = tmp_path / "MyExtension.cfe"
|
||
cfe_input.write_bytes(b"binary-cfe")
|
||
output = tmp_path / "ai-out-cfe"
|
||
client = TestClient(app)
|
||
project_id = f"ai-agent-cfe-{uuid4()}"
|
||
agent_id = f"win-agent-{uuid4()}"
|
||
|
||
indexed = client.post("/projects/index", json={"path": str(metadata_root), "project_id": project_id})
|
||
assert indexed.status_code == 200
|
||
settings = client.post(
|
||
f"/projects/{project_id}/settings",
|
||
json={
|
||
"name": "AI Agent Extension Demo",
|
||
"structure_source": "CFE_FILE",
|
||
"agent": {
|
||
"cf_agent_id": agent_id,
|
||
},
|
||
},
|
||
)
|
||
assert settings.status_code == 200
|
||
heartbeat = client.post("/agent/heartbeat", json={"agent_id": agent_id, "host": "test-host"})
|
||
assert heartbeat.status_code == 200
|
||
|
||
queued = client.post(
|
||
f"/html5/projects/{project_id}/ai-structure/run",
|
||
data={"project_id": project_id, "input_path": str(cfe_input), "output_path": str(output)},
|
||
)
|
||
assert queued.status_code == 200
|
||
match = re.search(r"/html5/projects/[^/]+/ai-structure/jobs/([A-Za-z0-9-]+)", queued.text)
|
||
assert match is not None
|
||
job_id = match.group(1)
|
||
|
||
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()["source"] == "CFE_FILE"
|
||
assert claimed.json()["local_path"] == str(cfe_input)
|
||
assert claimed.json()["metadata"]["one_c_extension"] == "MyExtension"
|
||
|
||
completed = client.post(
|
||
f"/agent/jobs/{job_id}/result",
|
||
json={
|
||
"status": "SUCCEEDED",
|
||
"server_path": str(metadata_root),
|
||
"logs": ["Выгрузка расширения завершена."],
|
||
},
|
||
)
|
||
assert completed.status_code == 200
|
||
|
||
deadline = time.monotonic() + 10
|
||
fragment = ""
|
||
while time.monotonic() < deadline:
|
||
polled = client.get(f"/html5/projects/{project_id}/ai-structure/jobs/{job_id}")
|
||
assert polled.status_code == 200
|
||
fragment = polled.text
|
||
if "готово" in fragment:
|
||
break
|
||
time.sleep(0.05)
|
||
assert "готово" in fragment
|
||
assert (output / f"codex-1c-context-{project_id}" / "AGENTS.md").exists()
|
||
|
||
|
||
def test_html5_ai_structure_routes_unc_directory_with_cf_through_windows_agent(monkeypatch, tmp_path: Path):
|
||
from api_server import html5_ai_structure_controller as controller
|
||
|
||
copied_root = tmp_path / "copied-unc"
|
||
copied_root.mkdir()
|
||
(copied_root / "base.cf").write_bytes(b"binary-cf")
|
||
|
||
copied_targets: list[tuple[str, Path]] = []
|
||
|
||
def fake_copy_smb_tree_to_local(*, source: str, target: Path, username: str, password: str, domain: str | None = None) -> None:
|
||
copied_targets.append((source, target))
|
||
target.mkdir(parents=True, exist_ok=True)
|
||
for item in copied_root.iterdir():
|
||
if item.is_file():
|
||
(target / item.name).write_bytes(item.read_bytes())
|
||
|
||
class FakeJob:
|
||
job_id = "agent-import-test"
|
||
status = "QUEUED"
|
||
source = "CF_FILE"
|
||
logs = ["queued"]
|
||
|
||
started: dict[str, object] = {}
|
||
|
||
async def fake_start_binary_job(**kwargs):
|
||
started.update(kwargs)
|
||
return FakeJob()
|
||
|
||
saved_runs: dict[str, dict[str, object]] = {}
|
||
|
||
monkeypatch.setattr(controller, "copy_smb_tree_to_local", fake_copy_smb_tree_to_local)
|
||
|
||
html = asyncio.run(
|
||
controller.html5_ai_structure_run(
|
||
project_id="unc-demo",
|
||
form={
|
||
"project_id": ["unc-demo"],
|
||
"input_path": [r"\\192.168.220.200\mst\1c\MARKA\CODEX\CF"],
|
||
"output_path": [r"\\192.168.220.200\mst\1c\MARKA\CODEX\CODEX"],
|
||
"smb_username": ["m"],
|
||
"smb_password": ["secret"],
|
||
},
|
||
prepare=lambda **_: {},
|
||
work_root=tmp_path / "work",
|
||
start_binary_job=fake_start_binary_job,
|
||
save_run_state=lambda job_id, payload: saved_runs.setdefault(job_id, payload),
|
||
)
|
||
)
|
||
|
||
assert "Windows Agent" in html
|
||
assert copied_targets
|
||
assert started["input_path"] == r"\\192.168.220.200\mst\1c\MARKA\CODEX\CF"
|
||
assert started["detected_binary_relative_path"] == "base.cf"
|
||
assert started["detected_binary_relative_paths"] == ["base.cf"]
|
||
assert "agent-import-test" in saved_runs
|
||
|
||
|
||
def test_html5_ai_structure_reports_multiple_binary_files_in_directory(tmp_path: Path):
|
||
first = tmp_path / "first.cf"
|
||
second = tmp_path / "second.cf"
|
||
first.write_bytes(b"cf-1")
|
||
second.write_bytes(b"cf-2")
|
||
client = TestClient(app)
|
||
project_id = f"ai-many-{uuid4()}"
|
||
agent_id = f"win-agent-{uuid4()}"
|
||
|
||
settings = client.post(
|
||
f"/projects/{project_id}/settings",
|
||
json={"name": "AI Many", "structure_source": "CF_FILE", "agent": {"cf_agent_id": agent_id}},
|
||
)
|
||
assert settings.status_code == 200
|
||
heartbeat = client.post("/agent/heartbeat", json={"agent_id": agent_id, "host": "test-host"})
|
||
assert heartbeat.status_code == 200
|
||
|
||
queued = client.post(
|
||
f"/html5/projects/{project_id}/ai-structure/run",
|
||
data={"project_id": project_id, "input_path": str(tmp_path), "output_path": str(tmp_path / 'out')},
|
||
)
|
||
assert queued.status_code == 200
|
||
assert "один конкретный файл .cf" in queued.text
|
||
assert "first.cf" in queued.text
|
||
assert "second.cf" in queued.text
|
||
|
||
|
||
def test_html5_ai_structure_reports_offline_agent_with_last_seen(tmp_path: Path):
|
||
cf_input = tmp_path / "demo.cf"
|
||
cf_input.write_bytes(b"binary-cf")
|
||
client = TestClient(app)
|
||
project_id = f"ai-offline-{uuid4()}"
|
||
agent_id = f"win-agent-{uuid4()}"
|
||
|
||
settings = client.post(
|
||
f"/projects/{project_id}/settings",
|
||
json={"name": "AI Offline", "structure_source": "CF_FILE", "agent": {"cf_agent_id": agent_id}},
|
||
)
|
||
assert settings.status_code == 200
|
||
heartbeat = client.post("/agent/heartbeat", json={"agent_id": agent_id, "host": "test-host"})
|
||
assert heartbeat.status_code == 200
|
||
main._agent_statuses[agent_id].last_seen_at = "2020-01-01T00:00:00+00:00"
|
||
|
||
queued = client.post(
|
||
f"/html5/projects/{project_id}/ai-structure/run",
|
||
data={"project_id": project_id, "input_path": str(cf_input), "output_path": str(tmp_path / 'out')},
|
||
)
|
||
assert queued.status_code == 200
|
||
assert "сейчас офлайн" in queued.text
|
||
assert "Последний heartbeat" in queued.text
|
||
|
||
|
||
def test_html5_ai_structure_page_shows_agent_status_panel():
|
||
client = TestClient(app)
|
||
project_id = f"ai-page-{uuid4()}"
|
||
agent_id = f"win-agent-{uuid4()}"
|
||
|
||
settings = client.post(
|
||
f"/projects/{project_id}/settings",
|
||
json={"name": "AI Page", "structure_source": "CF_FILE", "agent": {"cf_agent_id": agent_id}},
|
||
)
|
||
assert settings.status_code == 200
|
||
heartbeat = client.post(
|
||
"/agent/heartbeat",
|
||
json={
|
||
"agent_id": agent_id,
|
||
"host": "test-host",
|
||
"version": "0.2.31",
|
||
"network_roots": [r"\\192.168.220.220\mst"],
|
||
},
|
||
)
|
||
assert heartbeat.status_code == 200
|
||
|
||
page = client.get(f"/html5/projects/{project_id}/ai-structure")
|
||
assert page.status_code == 200
|
||
assert "Агент для CF/CFE" in page.text
|
||
assert "агент онлайн" in page.text
|
||
assert agent_id in page.text
|
||
assert "test-host" in page.text
|
||
assert "0.2.31" in page.text
|
||
assert r"\\192.168.220.220\mst" in page.text
|
||
|
||
|
||
def test_html5_ai_structure_page_shows_missing_agent_hint():
|
||
client = TestClient(app)
|
||
project_id = f"ai-page-missing-{uuid4()}"
|
||
|
||
settings = client.post(
|
||
f"/projects/{project_id}/settings",
|
||
json={"name": "AI Page Missing", "structure_source": "CF_FILE"},
|
||
)
|
||
assert settings.status_code == 200
|
||
|
||
page = client.get(f"/html5/projects/{project_id}/ai-structure")
|
||
assert page.status_code == 200
|
||
assert "агент не настроен" in page.text
|
||
assert "Укажите его в настройках проекта" in page.text
|
||
|
||
|
||
def test_html5_ai_structure_check_path_button_present():
|
||
client = TestClient(app)
|
||
project_id = f"ai-path-button-{uuid4()}"
|
||
settings = client.post(
|
||
f"/projects/{project_id}/settings",
|
||
json={"name": "AI Path Button", "structure_source": "CF_FILE"},
|
||
)
|
||
assert settings.status_code == 200
|
||
page = client.get(f"/html5/projects/{project_id}/ai-structure")
|
||
assert page.status_code == 200
|
||
assert "Проверить путь у агента" in page.text
|
||
assert "/ai-structure/check-path" in page.text
|
||
|
||
|
||
def test_html5_ai_structure_reports_unc_root_mismatch_for_online_agent(tmp_path: Path):
|
||
cf_input = r"\\192.168.220.200\mst\1c\MARKA\CODEX\CF\demo.cf"
|
||
client = TestClient(app)
|
||
project_id = f"ai-root-mismatch-{uuid4()}"
|
||
agent_id = f"win-agent-{uuid4()}"
|
||
|
||
settings = client.post(
|
||
f"/projects/{project_id}/settings",
|
||
json={"name": "AI Root Mismatch", "structure_source": "CF_FILE", "agent": {"cf_agent_id": agent_id}},
|
||
)
|
||
assert settings.status_code == 200
|
||
heartbeat = client.post(
|
||
"/agent/heartbeat",
|
||
json={
|
||
"agent_id": agent_id,
|
||
"host": "test-host",
|
||
"network_roots": [r"\\192.168.220.220\mst"],
|
||
},
|
||
)
|
||
assert heartbeat.status_code == 200
|
||
|
||
queued = client.post(
|
||
f"/html5/projects/{project_id}/ai-structure/run",
|
||
data={
|
||
"project_id": project_id,
|
||
"input_path": cf_input,
|
||
"output_path": str(tmp_path / "out"),
|
||
"smb_username": "m",
|
||
"smb_password": "secret",
|
||
},
|
||
)
|
||
assert queued.status_code == 200
|
||
assert "сейчас не может открыть путь" in queued.text
|
||
assert r"\\192.168.220.220\mst" in queued.text
|
||
|
||
|
||
def test_html5_ai_structure_check_path_reports_root_mismatch():
|
||
client = TestClient(app)
|
||
project_id = f"ai-path-check-{uuid4()}"
|
||
agent_id = f"win-agent-{uuid4()}"
|
||
settings = client.post(
|
||
f"/projects/{project_id}/settings",
|
||
json={"name": "AI Path Check", "structure_source": "CF_FILE", "agent": {"cf_agent_id": agent_id}},
|
||
)
|
||
assert settings.status_code == 200
|
||
heartbeat = client.post(
|
||
"/agent/heartbeat",
|
||
json={
|
||
"agent_id": agent_id,
|
||
"host": "test-host",
|
||
"network_roots": [r"\\192.168.220.220\mst"],
|
||
},
|
||
)
|
||
assert heartbeat.status_code == 200
|
||
|
||
checked = client.post(
|
||
f"/html5/projects/{project_id}/ai-structure/check-path",
|
||
data={"input_path": r"\\192.168.220.200\mst\1c\MARKA\CODEX\CF\demo.cf"},
|
||
)
|
||
assert checked.status_code == 200
|
||
assert "Путь недоступен" in checked.text
|
||
assert "доступны только корни" in checked.text
|
||
|
||
|
||
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>
|
||
<forms>
|
||
<name>ФормаЭлемента</name>
|
||
</forms>
|
||
</mdclass:Catalog>
|
||
""",
|
||
encoding="utf-8",
|
||
)
|
||
(module_dir / "ObjectModule.bsl").write_text(
|
||
"""
|
||
Процедура ПроверитьКонтрагента() Экспорт
|
||
КонецПроцедуры
|
||
""",
|
||
encoding="utf-8",
|
||
)
|
||
(module_dir / "ManagerModule.bsl").write_text(
|
||
"""
|
||
Процедура Создать() Экспорт
|
||
КонецПроцедуры
|
||
""",
|
||
encoding="utf-8",
|
||
)
|
||
form_module_dir = catalog_dir / "Forms" / "ФормаЭлемента" / "Ext" / "Form"
|
||
form_module_dir.mkdir(parents=True)
|
||
(form_module_dir / "Module.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"] == 3
|
||
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} == {"FORM_MODULE", "MANAGER_MODULE", "OBJECT_MODULE"}
|
||
assert all(module["attributes"]["source_hash"] for module in modules)
|
||
form_module = next(module for module in modules if module["module_kind"] == "FORM_MODULE")
|
||
assert form_module["attributes"]["owner_qualified_name"] == "Справочник.Контрагенты"
|
||
assert form_module["attributes"]["object_part"] == "form.ФормаЭлемента.module"
|
||
assert form_module["attributes"]["form_name"] == "ФормаЭлемента"
|
||
assert form_module["attributes"]["form_qualified_name"] == "Справочник.Контрагенты.ФормаЭлемента"
|
||
|
||
|
||
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 payload[0]["owner_qualified_name"] == "Справочник.Контрагенты"
|
||
assert payload[0]["owner_kind"] == "CATALOG"
|
||
assert payload[0]["object_part"] == "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 "data-html5-authoring-result-summary" in html5_preview.text
|
||
assert "diff lines" 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-result-summary" in html5_diff_preview.text
|
||
assert "changed" 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-summary" in html5_changes.text
|
||
assert "data-html5-authoring-recent-change" in html5_changes.text
|
||
assert "workspace" in html5_changes.text
|
||
assert "+3 / -0" 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 apply_payload["version"]["version_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 "data-html5-authoring-detail-summary" in html5_detail.text
|
||
assert "rollback ready" in html5_detail.text
|
||
assert "diff lines" 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.")
|
||
|
||
html5_rollback_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",
|
||
"approval_note": "rollback html5 checked",
|
||
"task_id": "task.rollback",
|
||
"session_id": "session.rollback",
|
||
},
|
||
)
|
||
assert html5_rollback_apply.status_code == 200
|
||
assert "text/html" in html5_rollback_apply.headers["content-type"]
|
||
assert "data-html5-authoring-result" in html5_rollback_apply.text
|
||
assert "data-html5-authoring-apply-summary" in html5_rollback_apply.text
|
||
assert 'data-html5-authoring-apply-kind="rollback"' in html5_rollback_apply.text
|
||
assert "ROLLED_BACK_TO_WORKSPACE" in html5_rollback_apply.text
|
||
assert rollback_apply_payload["rollback_change_id"] in html5_rollback_apply.text
|
||
assert "<html" not in html5_rollback_apply.text
|
||
|
||
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-authoring-result-summary" in html5_preview.text
|
||
assert "changed" in html5_preview.text
|
||
assert "diff lines" 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
|
||
|
||
html5_expected_version = re.search(r'name="expected_next_version_id" value="([^"]+)"', html5_preview.text)
|
||
assert html5_expected_version is not None
|
||
html5_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": html5_expected_version.group(1),
|
||
"approved_by": "dev.ivan",
|
||
"approval_note": "html5 metadata draft checked",
|
||
},
|
||
)
|
||
assert html5_apply.status_code == 200
|
||
assert "text/html" in html5_apply.headers["content-type"]
|
||
assert "data-html5-metadata-apply-result" in html5_apply.text
|
||
assert "data-html5-authoring-apply-summary" in html5_apply.text
|
||
assert 'data-html5-authoring-apply-kind="metadata"' in html5_apply.text
|
||
assert "METADATA_DRAFT_APPLIED_TO_WORKSPACE" in html5_apply.text
|
||
assert "ЗаявкаНаЗакупкуHtml5" in html5_apply.text
|
||
assert "<html" not in html5_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"] == "ЗаказПокупателя"
|