Complete name-first 1C adapter saved-state support

This commit is contained in:
2026-07-26 16:39:53 +03:00
parent b8c62fa8fa
commit aed134d817
44 changed files with 15436 additions and 697 deletions
+51
View File
@@ -76,3 +76,54 @@ def test_sql_admin_preserves_repository_user_from_settings() -> None:
assert item["repository"]["repository_password_env"] == "REPO_PASSWORD"
assert item["repository"]["infobase_user"] == "designer-user"
assert item["repository"]["infobase_password_env"] == "IB_PASSWORD"
def test_sql_admin_accepts_independent_base_and_extension_policies() -> None:
extension_guid = "11111111-2222-3333-4444-555555555555"
_, item = adapter.sql_admin_validate({
"base_id": "upo_test", "server": "sql", "database": "upo_test", "user": "reader",
"development_layers": {
"base": {"repository": {"mode": "manual"}, "support": {"mode": "rules", "rules": {"Документ.Заказ": "vendor_not_editable"}}},
f"extension:{extension_guid}": {"repository": {"mode": "none"}, "support": {"mode": "none"}},
},
})
assert item["development_layers"]["base"]["repository"]["mode"] == "manual"
assert item["development_layers"]["base"]["support"]["rules"]["Документ.Заказ"] == "vendor_not_editable"
assert item["development_layers"][f"extension:{extension_guid}"]["repository"]["mode"] == "none"
def test_sql_admin_rejects_extension_layer_without_guid() -> None:
with pytest.raises(ValueError, match="extension:<GUID>"):
adapter.sql_admin_validate({
"base_id": "upo_test", "server": "sql", "database": "upo_test", "user": "reader",
"development_layers": {"extension:test": {"repository": {"mode": "none"}, "support": {"mode": "none"}}},
})
def test_agent_can_persist_layer_repository_connection_state(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
path = tmp_path / "bases.json"
guid = "11111111-2222-3333-4444-555555555555"
monkeypatch.delenv("ONEC_SQL_BASES_JSON", raising=False)
monkeypatch.setenv("ONEC_SQL_BASES_JSON_FILE", str(path))
adapter.sql_admin_save({
"neft": {
"server": "sql", "database": "neft", "user": "reader",
"development_layers": {
"base": {"repository": {"mode": "manual", "connection_state": "configured"}, "support": {"mode": "unknown"}},
f"extension:{guid}": {"repository": {"mode": "unknown", "connection_state": "unknown"}, "support": {"mode": "unknown"}},
},
},
})
missing_confirmation = adapter.call_method("repository.layer.connection.set", {"base_id": "neft", "extension_guid": guid, "connection_state": "not_configured"})
assert missing_confirmation["status"] == "invalid_argument"
result = adapter.call_method("repository.layer.connection.set", {
"base_id": "neft", "extension_guid": guid, "connection_state": "not_configured", "repository_user": "repo-extension-user",
"confirm_repository_connection_change": True,
})
assert result["status"] == "saved"
assert result["repository_status"] == "repository_not_connected"
stored = adapter.sql_admin_load()
repository = stored["neft"]["development_layers"][f"extension:{guid}"]["repository"]
assert repository["connection_state"] == "not_configured"
assert repository["mode"] == "none"
assert repository["repository_user"] == "repo-extension-user"
+71
View File
@@ -0,0 +1,71 @@
from __future__ import annotations
import sys
from pathlib import Path
from typing import Any
ROOT = Path(__file__).resolve().parents[2]
sys.path.insert(0, str(ROOT / "plugins" / "1c"))
from parser.common_command import ( # noqa: E402
common_command_group_guid,
index_common_command_groups,
parse_common_command_tree,
)
from parser.payload import serialize_brace_tree # noqa: E402
def command_tree(group_guid: str) -> dict[str, Any]:
atom = lambda value: {"type": "atom", "value": str(value)}
sequence = lambda *items: {"type": "list", "items": list(items)}
root = sequence()
node = root
path = (1, 1, 2, 7, 1)
for index in path[:-1]:
items = node.setdefault("items", [])
while len(items) <= index:
items.append(sequence())
node = items[index]
items = node.setdefault("items", [])
while len(items) <= path[-1]:
items.append(sequence())
items[path[-1]] = atom(group_guid)
return root
def payload(group_guid: str) -> bytes:
return serialize_brace_tree(command_tree(group_guid)).encode("utf-8")
def test_common_command_group_guid_decodes_observed_relation() -> None:
group_guid = "11111111-1111-1111-1111-111111111111"
tree = parse_common_command_tree(payload(group_guid))
assert common_command_group_guid(tree) == group_guid
def test_common_command_reverse_index_groups_rows_and_reports_gaps() -> None:
group_guid = "11111111-1111-1111-1111-111111111111"
first_guid = "22222222-2222-2222-2222-222222222222"
broken_guid = "33333333-3333-3333-3333-333333333333"
missing_guid = "44444444-4444-4444-4444-444444444444"
commands = [
{"guid": first_guid, "name": "Первая"},
{"guid": broken_guid, "name": "Поврежденная"},
{"guid": missing_guid, "name": "Без файла"},
]
result = index_common_command_groups(
commands,
{
first_guid.upper(): payload(group_guid),
broken_guid: b"not a Config tree",
},
)
assert result["groups"][group_guid] == [{"guid": first_guid, "name": "Первая"}]
assert result["scanned"] == 3
assert result["source_missing"] == 1
assert result["undecodable"] == 1
assert result["unassigned"] == 0
@@ -13,3 +13,12 @@ def test_rest_deploy_refuses_to_drop_service_authentication() -> None:
assert "Reusing the existing REST service token without printing or persisting it" in source
assert "Refusing to deploy an unauthenticated adapter" in source
assert source.index("Ensure-RestServiceToken") < source.index("Invoke-ComposeUp `", source.index("try {"))
def test_deploy_can_forward_complete_saved_state_write_smoke_skip() -> None:
source = (ROOT / "scripts" / "deploy_1c_adapter_stack.ps1").read_text(encoding="utf-8-sig")
assert "[switch]$SkipSavedStateWriteSmoke" in source
assert "[switch]$SkipCodeWriteSavedStateSmoke" in source
assert '$verifyCommand += "-SkipSavedStateWriteSmoke"' in source
assert '$verifyCommand += "-SkipCodeWriteSavedStateSmoke"' in source
+167
View File
@@ -0,0 +1,167 @@
from __future__ import annotations
import json
import sys
from pathlib import Path
from typing import Any
ROOT = Path(__file__).resolve().parents[2]
sys.path.insert(0, str(ROOT))
import scripts.check_1c_metadata_kind_fixtures as fixtures # noqa: E402
MANIFEST = ROOT / "config" / "1c_metadata_kind_fixtures.json"
def test_fixture_manifest_is_valid_and_forbids_direct_sql() -> None:
manifest = fixtures.load_json(MANIFEST)
assert fixtures.validate_manifest(manifest) == []
assert manifest["policy"]["direct_sql_write_allowed"] is False
assert manifest["designer"] == {
"required_version": "8.5.1.1236",
"access_mode": "operating_system_integrated_only",
"credential_arguments_allowed": False,
"export_script": "scripts/export_1c_extension_sources.ps1",
}
assert manifest["external_reference"]["usage"] == "external_reference_only_not_vendored"
assert len(manifest["external_reference"]["commit"]) == 40
assert {item["kind"] for item in manifest["fixtures"]} == {
"CalculationRegister",
"Sequence",
"Interface",
}
def test_offline_check_does_not_claim_live_readiness() -> None:
result = fixtures.check_manifest(MANIFEST)
assert result["passed"] is True
assert result["status"] == "manifest_valid"
assert result["counts"] == {"findings": 0, "fixtures": 3, "ready": 0, "gaps": 0}
assert {item["status"] for item in result["fixtures"]} == {"not_checked"}
def test_live_check_distinguishes_ready_missing_and_unconfigured() -> None:
calls: list[tuple[str, dict[str, Any]]] = []
def fake_rpc(_url: str, method: str, payload: dict[str, Any], _timeout: float, _token: str) -> dict[str, Any]:
calls.append((method, payload))
if method == "extension.objects.find":
return {
"status": "ok",
"objects": [
{
"kind": "CalculationRegister",
"name": "tt_РегистрРасчета1",
"guid": "a4dfe8f1-2ce8-43c9-8b41-3deb7238c5d5",
"activation_state": "saved_only",
"extension_guid": "fb26cf42-7609-11f1-828f-005056b0d483",
},
{
"kind": "Catalog",
"name": "tt_Последовательность1",
"guid": "11111111-1111-1111-1111-111111111111",
"activation_state": "saved_only",
},
],
}
return {"status": "ok"}
result = fixtures.check_manifest(MANIFEST, live=True, rpc_call=fake_rpc)
by_kind = {item["kind"]: item for item in result["fixtures"]}
assert by_kind["CalculationRegister"]["status"] == "fixture_ready"
assert by_kind["Sequence"]["status"] == "fixture_missing"
assert by_kind["Interface"]["status"] == "target_not_configured"
assert [item["method"] for item in by_kind["CalculationRegister"]["smoke"]] == [
"metadata.object.get",
"metadata.object.properties",
"metadata.object.attributes",
"metadata.object.related",
]
extension_find_calls = [payload for method, payload in calls if method == "extension.objects.find"]
assert len(extension_find_calls) == 1
calc_find_payload = extension_find_calls[0]
assert "kind" not in calc_find_payload
assert calc_find_payload["use_cache"] is True
assert calc_find_payload["full_scan"] is False
assert calc_find_payload["refresh_cache"] is False
assert result["passed"] is True
assert result["status"] == "gaps_found"
def test_require_ready_fails_when_a_fixture_is_missing() -> None:
def fake_rpc(_url: str, method: str, _payload: dict[str, Any], _timeout: float, _token: str) -> dict[str, Any]:
if method == "metadata.objects.list":
return {"status": "not_found", "objects": []}
return {"status": "not_found", "objects": []}
result = fixtures.check_manifest(
MANIFEST,
live=True,
require_ready=True,
base_overrides={"interface_legacy_base": "legacy_test"},
rpc_call=fake_rpc,
)
assert result["passed"] is False
assert result["counts"]["ready"] == 0
assert result["counts"]["gaps"] == 3
def test_live_check_reuses_extension_scan_transport_failure() -> None:
calls = 0
def fake_rpc(_url: str, _method: str, _payload: dict[str, Any], _timeout: float, _token: str) -> dict[str, Any]:
nonlocal calls
calls += 1
raise TimeoutError("scan timed out")
result = fixtures.check_manifest(MANIFEST, live=True, rpc_call=fake_rpc)
by_kind = {item["kind"]: item for item in result["fixtures"]}
assert calls == 1
assert by_kind["CalculationRegister"]["error"] == "TimeoutError"
assert "probe_error_reused" not in by_kind["CalculationRegister"]
assert by_kind["Sequence"]["error"] == "TimeoutError"
assert by_kind["Sequence"]["probe_error_reused"] is True
def test_manifest_rejects_credential_like_keys(tmp_path: Path) -> None:
value = fixtures.load_json(MANIFEST)
value["fixtures"][0]["password"] = "must-not-be-here"
path = tmp_path / "manifest.json"
path.write_text(json.dumps(value, ensure_ascii=False), encoding="utf-8")
result = fixtures.check_manifest(path)
assert result["passed"] is False
assert any(item["code"] == "credential_key_in_manifest" for item in result["findings"])
def test_manifest_requires_safe_designer_access_and_pinned_reference() -> None:
value = fixtures.load_json(MANIFEST)
value["designer"]["access_mode"] = "interactive_credentials"
value["external_reference"]["commit"] = "main"
findings = fixtures.validate_manifest(value)
assert {item["code"] for item in findings} >= {
"unsafe_designer_access_mode",
"invalid_external_reference_commit",
}
def test_designer_export_script_has_no_user_or_credential_parameters() -> None:
script = (ROOT / "scripts" / "export_1c_extension_sources.ps1").read_text(encoding="utf-8")
assert "/WA+" in script
assert "/N" not in script
assert "/P" not in script
assert "UserName" not in script
assert "Credential" not in script
assert "/DumpConfigToFiles" in script
assert "-Extension" in script
+584
View File
@@ -0,0 +1,584 @@
from __future__ import annotations
import sys
from pathlib import Path
from typing import Any
ROOT = Path(__file__).resolve().parents[2]
sys.path.insert(0, str(ROOT / "plugins" / "1c"))
sys.path.insert(0, str(ROOT / "plugins" / "1c" / "connector"))
import adapter_1c_server as adapter # noqa: E402
OBJECT_GUID = "11111111-2222-3333-4444-555555555555"
def atom(value: Any) -> dict[str, Any]:
return {"type": "atom", "value": str(value)}
def string(value: Any) -> dict[str, Any]:
return {"type": "string", "value": str(value)}
def seq(*items: dict[str, Any]) -> dict[str, Any]:
return {"type": "list", "items": list(items)}
def identity_tree(*, synonym: str = "Товары", comment: str = "Комментарий") -> dict[str, Any]:
return seq(
seq(atom(1), atom(0), atom(OBJECT_GUID)),
string("Номенклатура"),
seq(atom(1), string("ru"), string(synonym)),
string(comment),
atom(0),
)
def identity_tree_with_member(*, member_synonym: str = "Артикул товара") -> dict[str, Any]:
return seq(
seq(atom(1), atom(0), atom(OBJECT_GUID)),
string("Номенклатура"),
seq(atom(1), string("ru"), string("Товары")),
string("Комментарий"),
seq(
seq(atom(1), atom(0), atom("aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee")),
string("Артикул"),
seq(atom(1), string("ru"), string(member_synonym)),
string("Комментарий реквизита"),
),
atom(0),
)
def declared_attribute_tree(*, include_new: bool = False) -> dict[str, Any]:
records = [
seq(
seq(atom(1), atom(0), atom("aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee")),
string("Артикул"),
seq(atom(1), string("ru"), string("Артикул товара")),
string("Комментарий реквизита"),
atom("type-settings"),
)
]
if include_new:
new_guid = adapter.deterministic_member_guid(
OBJECT_GUID,
"Attribute",
"КодПоставщика",
scope="Catalog.Номенклатура",
)
records.append(
seq(
seq(atom(1), atom(0), atom(new_guid)),
string("КодПоставщика"),
seq(atom(1), string("ru"), string("Код поставщика")),
string("Комментарий реквизита"),
atom("type-settings"),
)
)
return seq(
seq(atom(1), atom(0), atom(OBJECT_GUID)),
string("Номенклатура"),
seq(atom(1), string("ru"), string("Товары")),
string("Комментарий"),
seq(atom(0), atom(len(records)), *records),
atom(0),
)
def prepare_collision() -> dict[str, Any]:
return {
"schema": "onec_saved_state_prepare.v1",
"status": "blocked_target_collision",
"base_id": "base",
"target": {"table": "ConfigSave"},
"object": {
"kind": "Catalog",
"name": "Номенклатура",
"synonym": "Товары",
"guid": OBJECT_GUID,
},
"file_names": ["object.0"],
"counts": {"file_names": 1, "target_rows": 1},
}
def base_payload(**overrides: Any) -> dict[str, Any]:
return {
"base_id": "base",
"ref": "Catalog.Номенклатура",
"property": "synonym",
"value": "Номенклатура товаров",
"allow_saved_state_write": True,
**overrides,
}
def install_common_mocks(monkeypatch, *, tree: dict[str, Any] | None = None) -> None:
monkeypatch.setattr(adapter, "repository_apply_gate", lambda *_args, **_kwargs: None)
monkeypatch.setattr(adapter, "metadata_saved_state_prepare", lambda _payload: prepare_collision())
monkeypatch.setattr(
adapter,
"read_storage_file_bytes",
lambda *_args, **_kwargs: (b"payload", {"database": "db"}, None),
)
monkeypatch.setattr(adapter, "parse_config_tree_from_bytes", lambda _data: tree or identity_tree())
def test_identity_property_target_resolves_existing_synonym_and_comment_paths() -> None:
tree = identity_tree()
synonym = adapter.config_tree_identity_property_target(tree, OBJECT_GUID, "синоним", language="ru")
comment = adapter.config_tree_identity_property_target(tree, OBJECT_GUID, "комментарий")
assert synonym["status"] == "ok"
assert synonym["path"] == "2.2"
assert synonym["current"] == "Товары"
assert synonym["node_type"] == "string"
assert comment["status"] == "ok"
assert comment["path"] == "3"
assert comment["current"] == "Комментарий"
def test_member_identity_resolves_full_public_path_by_semantic_category(monkeypatch) -> None:
member_guid = "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee"
tree = identity_tree_with_member()
monkeypatch.setattr(
adapter,
"nested_metadata_semantic_index",
lambda *_args, **_kwargs: {
member_guid: {
"category": "Attribute",
"ref": "Catalog.Номенклатура.Attribute.Артикул",
}
},
)
resolved = adapter.config_tree_member_identity_resolve(
tree,
parent_guid=OBJECT_GUID,
parent_kind="Catalog",
parent_name="Номенклатура",
member_path=["Реквизит", "Артикул"],
member_name="Артикул",
)
assert resolved["status"] == "ok"
assert resolved["member"]["guid"] == member_guid
assert resolved["member"]["kind"] == "Attribute"
assert resolved["member"]["ref"] == "Catalog.Номенклатура.Attribute.Артикул"
def test_declared_member_record_resolves_append_container() -> None:
resolved = adapter.config_tree_declared_record_for_guid(
declared_attribute_tree(),
"aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee",
)
assert resolved["status"] == "ok"
assert resolved["record_path"] == "4.2"
assert resolved["parent_path"] == "4"
def test_object_property_plan_uses_exact_guid_path_and_compare_guard(monkeypatch) -> None:
install_common_mocks(monkeypatch)
captured: dict[str, Any] = {}
def fake_propose(payload: dict[str, Any]) -> dict[str, Any]:
captured.update(payload)
return {
"status": "accepted_for_review",
"source": payload["source"],
"original": {"sha1": "old"},
"encoded": {"sha1": "new"},
"edits": payload["edits"],
"validation": {"status": "ok", "mode": "path_preserve_format"},
}
monkeypatch.setattr(adapter, "changes_propose", fake_propose)
result = adapter.metadata_object_property_write(base_payload(expected_old="Товары"))
assert result["status"] == "planned"
assert result["object"]["ref"] == "Catalog.Номенклатура"
assert result["property"] == {
"name": "synonym",
"language": "ru",
"current": "Товары",
"requested": "Номенклатура товаров",
}
assert captured["source"]["table"] == "ConfigSave"
assert captured["edits"] == [
{
"path": "2.2",
"value": "Номенклатура товаров",
"node_type": "string",
"expected_old": "Товары",
"property": "synonym",
}
]
assert "source" not in result["proposal"]
def test_object_property_plan_returns_name_first_prepare_call(monkeypatch) -> None:
monkeypatch.setattr(adapter, "repository_apply_gate", lambda *_args, **_kwargs: None)
monkeypatch.setattr(
adapter,
"metadata_saved_state_prepare",
lambda _payload: {
"status": "plan_ready",
"base_id": "base",
"target": {"table": "ConfigSave"},
"source": {"table": "Config"},
"object": {"kind": "Catalog", "name": "Номенклатура", "guid": OBJECT_GUID},
"file_names": ["object.0"],
"counts": {"source_rows": 1, "target_rows": 0},
},
)
result = adapter.metadata_object_property_write(base_payload())
assert result["status"] == "needs_prepare"
assert result["next_call"] == {
"method": "metadata.saved_state.prepare",
"params": {
"base_id": "base",
"layer": "base_saved_state",
"ref": "Catalog.Номенклатура",
"kind": "Catalog",
"name": "Номенклатура",
"mode": "plan",
},
}
assert "table" not in result["next_call"]["params"]
assert "file_name" not in result["next_call"]["params"]
def test_object_property_plan_writes_existing_member_by_name(monkeypatch) -> None:
install_common_mocks(monkeypatch, tree=identity_tree_with_member())
captured: dict[str, Any] = {}
def fake_propose(payload: dict[str, Any]) -> dict[str, Any]:
captured.update(payload)
return {
"status": "accepted_for_review",
"source": payload["source"],
"original": {"sha1": "old"},
"encoded": {"sha1": "new"},
"edits": payload["edits"],
"validation": {"status": "ok", "mode": "path_preserve_format"},
}
monkeypatch.setattr(adapter, "changes_propose", fake_propose)
result = adapter.metadata_object_property_write(
base_payload(
member_name="Артикул",
value="Код товара поставщика",
expected_old="Артикул товара",
)
)
assert result["status"] == "planned"
assert result["member"]["name"] == "Артикул"
assert result["member"]["guid"] == "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee"
assert captured["edits"][0]["path"] == "4.2.2"
assert captured["edits"][0]["expected_old"] == "Артикул товара"
def test_object_property_apply_verify_and_rollback(monkeypatch) -> None:
install_common_mocks(monkeypatch)
reads = iter([(b"old", {"database": "db"}, None), (b"new", {"database": "db"}, None)])
monkeypatch.setattr(adapter, "read_storage_file_bytes", lambda *_args, **_kwargs: next(reads))
monkeypatch.setattr(
adapter,
"parse_config_tree_from_bytes",
lambda data: identity_tree(synonym="Номенклатура товаров" if data == b"new" else "Товары"),
)
monkeypatch.setattr(
adapter,
"changes_propose",
lambda payload: {
"status": "accepted_for_review",
"source": payload["source"],
"original": {"sha1": "old"},
"encoded": {"sha1": "new", "payload_hex": "00"},
"edits": payload["edits"],
"validation": {"status": "ok", "mode": "path_preserve_format"},
},
)
monkeypatch.setattr(
adapter,
"storage_saved_state_apply_proposal",
lambda _payload: {"status": "applied", "applied": True, "backup": {"backup_id": "backup-1"}},
)
monkeypatch.setattr(
adapter,
"storage_saved_state_rollback",
lambda payload: {"status": "applied", "applied": True, "backup_id": payload["backup_id"]},
)
result = adapter.metadata_object_property_write(
base_payload(
execution_mode="apply_and_rollback",
allow_sql_saved_state_apply=True,
allow_sql_saved_state_rollback=True,
)
)
assert result["status"] == "verified_and_rolled_back"
assert result["applied"] is True
assert result["rolled_back"] is True
assert result["semantic_verification"] == {
"status": "ok",
"expected": "Номенклатура товаров",
"actual": "Номенклатура товаров",
}
def test_object_property_write_rejects_rename_and_expected_old_drift(monkeypatch) -> None:
install_common_mocks(monkeypatch)
rename = adapter.call_method(
adapter.OBJECT_PROPERTY_WRITE_METHOD,
base_payload(property="name", value="НовоеИмя"),
)
drift = adapter.metadata_object_property_write(base_payload(expected_old="Старое значение"))
assert rename["status"] == "invalid_argument"
assert rename["argument"] == "property"
assert drift["status"] == "precondition_failed"
assert drift["error"] == "expected_old_mismatch"
def test_metadata_write_routes_object_property_by_public_selector(monkeypatch) -> None:
monkeypatch.setattr(adapter, "repository_apply_gate", lambda *_args, **_kwargs: None)
captured: dict[str, Any] = {}
def fake_write(payload: dict[str, Any]) -> dict[str, Any]:
captured.update(payload)
return {"status": "planned", "property": {"name": payload["property"]}}
monkeypatch.setattr(adapter, "metadata_object_property_write", fake_write)
result = adapter.metadata_write(
{
"base_id": "base",
"target": {
"area": "object",
"kind": "Catalog",
"name": "Номенклатура",
"property": "comment",
},
"value": "Пояснение",
"execution_mode": "plan",
}
)
assert result["status"] == "planned"
assert result["routed_method"] == adapter.OBJECT_PROPERTY_WRITE_METHOD
assert captured["kind"] == "Catalog"
assert captured["name"] == "Номенклатура"
assert captured["property"] == "comment"
assert captured["value"] == "Пояснение"
assert captured["allow_saved_state_write"] is True
def test_object_member_add_plan_clones_attribute_and_generates_guid(monkeypatch) -> None:
install_common_mocks(monkeypatch, tree=declared_attribute_tree())
monkeypatch.setattr(
adapter,
"nested_metadata_semantic_index",
lambda *_args, **_kwargs: {
"aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee": {
"category": "Attribute",
"ref": "Catalog.Номенклатура.Attribute.Артикул",
}
},
)
captured: dict[str, Any] = {}
def fake_propose(payload: dict[str, Any]) -> dict[str, Any]:
captured.update(payload)
return {
"status": "accepted_for_review",
"source": payload["source"],
"original": {"sha1": "old"},
"encoded": {"sha1": "new"},
"edits": [{"mode": "structural_append_child_preserve_format"}],
"validation": {"status": "ok", "mode": "path_preserve_format"},
}
monkeypatch.setattr(adapter, "changes_propose", fake_propose)
result = adapter.metadata_object_member_add(
{
"base_id": "base",
"template_member_ref": "Catalog.Номенклатура.Attribute.Артикул",
"new_member_name": "КодПоставщика",
"new_member_synonym": "Код поставщика",
"allow_saved_state_write": True,
"execution_mode": "plan",
}
)
new_guid = adapter.deterministic_member_guid(
OBJECT_GUID,
"Attribute",
"КодПоставщика",
scope="Catalog.Номенклатура",
)
append = captured["edits"][0]["append_child"]
cloned_identities = adapter.config_tree_identity_records(append["node"])
assert result["status"] == "planned"
assert result["requested_member"]["guid"] == new_guid
assert result["requested_member"]["ref"] == "Catalog.Номенклатура.Attribute.КодПоставщика"
assert result["container"] == {"ref": "Catalog.Номенклатура", "scope": "object"}
assert append["parent_path"] == "4"
assert cloned_identities[new_guid]["name"] == "КодПоставщика"
assert cloned_identities[new_guid]["synonyms"]["ru"] == "Код поставщика"
def test_object_member_add_scopes_tabular_column_guid_and_ref(monkeypatch) -> None:
install_common_mocks(monkeypatch, tree=declared_attribute_tree())
monkeypatch.setattr(
adapter,
"nested_metadata_semantic_index",
lambda *_args, **_kwargs: {
"aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee": {
"category": "Attribute",
"ref": "Catalog.Номенклатура.TabularSection.Единицы.Attribute.Артикул",
}
},
)
monkeypatch.setattr(
adapter,
"changes_propose",
lambda payload: {
"status": "accepted_for_review",
"source": payload["source"],
"original": {"sha1": "old"},
"encoded": {"sha1": "new"},
"edits": [{"mode": "structural_append_child_preserve_format"}],
"validation": {"status": "ok", "mode": "path_preserve_format"},
},
)
result = adapter.metadata_object_member_add(
{
"base_id": "base",
"template_member_ref": "Catalog.Номенклатура.TabularSection.Единицы.Attribute.Артикул",
"new_member_name": "КодПоставщика",
"allow_saved_state_write": True,
"execution_mode": "plan",
}
)
expected_guid = adapter.deterministic_member_guid(
OBJECT_GUID,
"Attribute",
"КодПоставщика",
scope="Catalog.Номенклатура.TabularSection.Единицы",
)
assert result["status"] == "planned"
assert result["container"] == {
"ref": "Catalog.Номенклатура.TabularSection.Единицы",
"scope": "tabular_section",
}
assert result["requested_member"]["ref"] == (
"Catalog.Номенклатура.TabularSection.Единицы.Attribute.КодПоставщика"
)
assert result["requested_member"]["guid"] == expected_guid
def test_object_member_add_apply_verify_and_rollback(monkeypatch) -> None:
install_common_mocks(monkeypatch, tree=declared_attribute_tree())
reads = iter([(b"old", {"database": "db"}, None), (b"new", {"database": "db"}, None)])
monkeypatch.setattr(adapter, "read_storage_file_bytes", lambda *_args, **_kwargs: next(reads))
monkeypatch.setattr(
adapter,
"parse_config_tree_from_bytes",
lambda data: declared_attribute_tree(include_new=data == b"new"),
)
monkeypatch.setattr(
adapter,
"nested_metadata_semantic_index",
lambda *_args, **_kwargs: {
"aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee": {
"category": "Attribute",
"ref": "Catalog.Номенклатура.Attribute.Артикул",
}
},
)
monkeypatch.setattr(
adapter,
"changes_propose",
lambda payload: {
"status": "accepted_for_review",
"source": payload["source"],
"original": {"sha1": "old"},
"encoded": {"sha1": "new", "payload_hex": "00"},
"edits": [{"mode": "structural_append_child_preserve_format"}],
"validation": {"status": "ok", "mode": "path_preserve_format"},
},
)
monkeypatch.setattr(
adapter,
"storage_saved_state_apply_proposal",
lambda _payload: {"status": "applied", "applied": True, "backup": {"backup_id": "backup-add"}},
)
monkeypatch.setattr(
adapter,
"storage_saved_state_rollback",
lambda _payload: {"status": "applied", "applied": True},
)
result = adapter.metadata_object_member_add(
{
"base_id": "base",
"template_member_ref": "Catalog.Номенклатура.Attribute.Артикул",
"new_member_name": "КодПоставщика",
"new_member_synonym": "Код поставщика",
"allow_saved_state_write": True,
"allow_sql_saved_state_apply": True,
"allow_sql_saved_state_rollback": True,
"execution_mode": "apply_and_rollback",
}
)
assert result["status"] == "verified_and_rolled_back"
assert result["semantic_verification"]["status"] == "ok"
assert result["rolled_back"] is True
def test_metadata_write_routes_add_attribute(monkeypatch) -> None:
monkeypatch.setattr(adapter, "repository_apply_gate", lambda *_args, **_kwargs: None)
captured: dict[str, Any] = {}
def fake_add(payload: dict[str, Any]) -> dict[str, Any]:
captured.update(payload)
return {"status": "planned"}
monkeypatch.setattr(adapter, "metadata_object_member_add", fake_add)
result = adapter.metadata_write(
{
"base_id": "base",
"target": {
"area": "object",
"operation": "add_attribute",
"template_member_ref": "Catalog.Номенклатура.Attribute.Артикул",
"new_member_name": "КодПоставщика",
},
"mode": "plan",
}
)
assert result["status"] == "planned"
assert result["routed_method"] == adapter.OBJECT_MEMBER_ADD_METHOD
assert captured["template_member_ref"] == "Catalog.Номенклатура.Attribute.Артикул"
assert captured["new_member_name"] == "КодПоставщика"
assert captured["allow_saved_state_write"] is True
File diff suppressed because it is too large Load Diff
+461 -3
View File
@@ -4,6 +4,8 @@ import json
import sys
from pathlib import Path
import pytest
ROOT = Path(__file__).resolve().parents[2]
sys.path.insert(0, str(ROOT / "plugins" / "1c" / "connector"))
@@ -33,6 +35,21 @@ def configured_base(monkeypatch, tmp_path: Path, backend: str = "karman_bridge")
monkeypatch.setenv("ONEC_REPOSITORY_STATE_FILE", str(tmp_path / "locks.json"))
def configured_layers(monkeypatch, tmp_path: Path) -> None:
config = {
"base": {
"server": "sql", "database": "db", "user": "reader",
"development_layers": {
"base": {"repository": {"mode": "manual"}, "support": {"mode": "rules", "rules": {"Документ.Заказ": "vendor_not_editable", "Документ.Заказ.Форма.ФормаДокумента": "vendor_editable_support_preserved"}}},
"extension:11111111-2222-3333-4444-555555555555": {"repository": {"mode": "none"}, "support": {"mode": "none"}},
"extension:aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee": {"repository": {"mode": "manual"}, "support": {"mode": "editable"}},
},
}
}
monkeypatch.setenv("ONEC_SQL_BASES_JSON", json.dumps(config))
monkeypatch.setenv("ONEC_REPOSITORY_STATE_FILE", str(tmp_path / "locks.json"))
def test_repository_backend_and_endpoint_come_only_from_base_settings(monkeypatch, tmp_path: Path) -> None:
configured_base(monkeypatch, tmp_path)
config, error = repository_control.repository_config("base")
@@ -56,7 +73,11 @@ def test_http_runner_profile_does_not_require_local_designer_or_credentials(monk
def test_lock_plan_maps_child_metadata_to_development_owner() -> None:
result = repository_control.lock_plan({"object": "РегистрСведений.Настройки.Реквизит.Код"})
assert result["status"] == "ready"
assert result["scope_status"] == "resolved"
assert result["lock_objects"] == ["РегистрСведений.Настройки"]
assert result["native_lock_state"] == "unknown"
assert result["native_lock_verification"] == "not_available_via_infobase_sql"
assert result["capture_required"] is None
def test_manual_lock_confirmation_is_scoped_and_not_reported_as_verified(monkeypatch, tmp_path: Path) -> None:
@@ -67,10 +88,16 @@ def test_manual_lock_confirmation_is_scoped_and_not_reported_as_verified(monkeyp
planned = repository_control.lock_plan({"base_id": "base", "object": "Справочник.Товары.МодульОбъекта"})
assert planned["workflow"] == "manual"
assert planned["next_method"] == "repository.lock.request"
assert planned["user_action"]["action"] == "confirm_lock_in_configurator"
assert "уже захвачены" in planned["user_action"]["message"]
assert planned["user_action"]["objects"] == ["Справочник.Товары"]
requested = repository_control.create_lock_request({"base_id": "base", "object": "Справочник.Товары"})
confirmed = repository_control.confirm_manual_lock({"base_id": "base", "request_id": requested["request_id"], "user_confirmed_locked": True})
confirmed = repository_control.confirm_manual_lock({"base_id": "base", "request_id": requested["request_id"], "user_confirmed_locked": True, "confirmed_repository_user": "repo-user"})
assert confirmed["status"] == "manual_confirmed"
assert confirmed["capture_state"] == "manually_confirmed"
assert confirmed["native_lock_state"] == "unknown"
assert confirmed["native_lock_verification"] == "not_available_via_infobase_sql"
assert confirmed["automatically_verified"] is False
verified = repository_control.verify({"lock_session_id": confirmed["lock_session_id"]})
assert verified["status"] == "manual_confirmation_unverified"
@@ -88,17 +115,32 @@ def test_manual_lock_request_stays_pending_until_user_confirms_exact_saved_scope
assert requested["status"] == "pending_user_lock"
assert requested["objects"] == ["РегистрСведений.Настройки"]
assert requested["automatically_locked"] is False
assert requested["next_call"] == {
"method": "repository.lock.confirm",
"params": {"base_id": "base", "request_id": requested["request_id"], "user_confirmed_locked": True},
}
pending = repository_control.lock_request_status({"request_id": requested["request_id"]})
assert pending["status"] == "pending_user_lock"
assert pending["next_call"] == requested["next_call"]
incomplete = repository_control.confirm_manual_lock({"base_id": "base", "request_id": requested["request_id"]})
assert incomplete["status"] == "confirmation_required"
assert incomplete["error"] == "user_confirmed_locked_required"
assert incomplete["required_fields"] == ["base_id", "request_id", "user_confirmed_locked"]
assert incomplete["next_call"] == requested["next_call"]
assert incomplete["sql_resolution"] == []
confirmed = repository_control.confirm_manual_lock({
"base_id": "base",
"request_id": requested["request_id"],
"objects": ["Справочник.Подмена"],
"user_confirmed_locked": True,
"confirmed_repository_user": "repo-user",
})
assert confirmed["objects"] == ["РегистрСведений.Настройки"]
assert confirmed["automatically_verified"] is False
assert confirmed["write_context"]["lock_session_id"] == confirmed["lock_session_id"]
assert confirmed["write_context"]["repository_object"] == "РегистрСведений.Настройки"
completed = repository_control.lock_request_status({"request_id": requested["request_id"]})
assert completed["status"] == "confirmed_by_user"
@@ -148,6 +190,122 @@ def test_adapter_resolves_lock_request_object_against_live_sql(monkeypatch) -> N
assert normalized["objects"] == ["РегистрСведений.Настройки"]
def test_form_lock_scope_is_resolved_before_plan_request_and_confirmation(monkeypatch, tmp_path: Path) -> None:
configured_base(monkeypatch, tmp_path)
form_guid = "f5f217c8-ac69-4e23-966b-d8ec0c665638"
def fake_get_object(kind, name, **kwargs):
assert kind == "Document"
assert name == "тл_Планировщик"
return {"status": "ok", "object": {"kind": "Document", "name": "тл_Планировщик", "guid": "owner-guid"}}
monkeypatch.setattr(adapter_1c_server, "get_object", fake_get_object)
def fake_metadata_object_forms(payload):
assert "extension_guid" not in payload
return {"status": "ok", "forms": [{"guid": form_guid, "name": "ФормаДокументаНовая"}]}
monkeypatch.setattr(adapter_1c_server, "metadata_object_forms", fake_metadata_object_forms)
reference = "Document.тл_Планировщик.Form.ФормаДокументаНовая"
planned = adapter_1c_server.call_method("repository.lock.plan", {"base_id": "base", "objects": [reference]})
assert planned["status"] == "ready"
assert planned["lock_objects"] == [reference]
resolution = planned["sql_resolution"][0]
assert resolution["requested_object"] == reference
assert resolution["resolved_object"] == reference
assert resolution["form_guid"] == form_guid
assert resolution["owner"] == {"kind": "Document", "name": "тл_Планировщик", "guid": "owner-guid"}
assert resolution["table"] == "Config"
assert resolution["source"] == "live_sql_form_owner"
assert resolution["attempts"][-1] == {"stage": "form", "table": "Config", "status": "ok"}
request = adapter_1c_server.call_method("repository.lock.request", {"base_id": "base", "objects": [reference]})
assert request["status"] == "pending_user_lock"
assert request["sql_resolution"] == planned["sql_resolution"]
confirmed = repository_control.confirm_manual_lock({
"base_id": "base", "request_id": request["request_id"], "user_confirmed_locked": True,
"confirmed_repository_user": "repo-user",
})
assert confirmed["status"] == "manual_confirmed"
assert repository_control.write_gate({"base_id": "base", "lock_session_id": confirmed["lock_session_id"], "repository_object": reference})["allowed"] is True
mismatch = repository_control.write_gate({"base_id": "base", "lock_session_id": confirmed["lock_session_id"], "repository_object": "Document.тл_Планировщик.Form.Другая"})
assert mismatch["status"] == "blocked_repository_scope_mismatch"
assert mismatch["error"] == "lock_object_mismatch"
@pytest.mark.parametrize("kind", ["Catalog", "DataProcessor", "Report"])
def test_owned_form_lock_resolver_supports_other_owner_kinds(monkeypatch, tmp_path: Path, kind: str) -> None:
configured_base(monkeypatch, tmp_path)
monkeypatch.setattr(adapter_1c_server, "get_object", lambda actual_kind, name, **kwargs: {"status": "ok", "object": {"kind": actual_kind, "name": name, "guid": "owner-guid"}})
monkeypatch.setattr(adapter_1c_server, "metadata_object_forms", lambda payload: {"status": "ok", "forms": [{"guid": "11111111-2222-3333-4444-555555555555", "name": "Основная"}]})
result = adapter_1c_server.call_method("repository.lock.plan", {"base_id": "base", "object": f"{kind}.Объект.Form.Основная"})
assert result["status"] == "ready"
assert result["lock_objects"] == [f"{kind}.Объект.Form.Основная"]
def test_owned_extension_form_lock_discovers_its_sql_layer(monkeypatch, tmp_path: Path) -> None:
configured_layers(monkeypatch, tmp_path)
extension_guid = "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee"
reference = "Document.Заказ.Form.Основная"
calls: list[str | None] = []
def fake_resolve(**kwargs):
guid = kwargs.get("extension_guid")
calls.append(guid)
if guid != extension_guid:
return None, {"status": "not_found", "error": "repository_form_not_found_in_sql"}
return {
"requested_object": reference,
"resolved_object": reference,
"form_guid": "11111111-2222-3333-4444-555555555555",
"owner": {"kind": "Document", "name": "Заказ", "guid": "owner-guid"},
"table": "ConfigCAS",
"source": "live_sql_form_owner",
"extension_guid": extension_guid,
}, None
monkeypatch.setattr(adapter_1c_server, "repository_resolve_owned_form_sql", fake_resolve)
monkeypatch.setattr(adapter_1c_server, "list_extensions", lambda payload: {
"status": "ok", "extensions": [{"guid": extension_guid}],
})
result = adapter_1c_server.call_method("repository.lock.plan", {"base_id": "base", "object": reference})
assert result["status"] == "ready"
assert result["layer_id"] == f"extension:{extension_guid}"
assert result["sql_resolution"][0]["source"] == "live_sql_extension_form_owner"
assert calls == [None, extension_guid]
def test_form_guid_lock_scope_uses_cached_owner_only_after_live_validation(monkeypatch, tmp_path: Path) -> None:
configured_base(monkeypatch, tmp_path)
guid = "f5f217c8-ac69-4e23-966b-d8ec0c665638"
monkeypatch.setattr(adapter_1c_server, "metadata_form_owner_cache_lookup", lambda *args, **kwargs: {"owner": {"kind": "Document", "name": "тл_Планировщик"}, "form": {"name": "ФормаДокументаНовая"}})
monkeypatch.setattr(adapter_1c_server, "get_object", lambda kind, name, **kwargs: {"status": "ok", "object": {"kind": kind, "name": name, "guid": "owner-guid"}})
monkeypatch.setattr(adapter_1c_server, "metadata_object_forms", lambda payload: {"status": "ok", "forms": [{"guid": guid, "name": "ФормаДокументаНовая"}]})
result = adapter_1c_server.call_method("repository.lock.plan", {"base_id": "base", "object": f"Form.{guid}"})
assert result["status"] == "ready"
assert result["lock_objects"] == ["Document.тл_Планировщик.Form.ФормаДокументаНовая"]
def test_form_guid_lock_scope_falls_back_to_live_sql_owner_scan(monkeypatch, tmp_path: Path) -> None:
configured_base(monkeypatch, tmp_path)
guid = "f5f217c8-ac69-4e23-966b-d8ec0c665638"
monkeypatch.setattr(adapter_1c_server, "metadata_form_owner_cache_lookup", lambda *args, **kwargs: None)
monkeypatch.setattr(adapter_1c_server, "list_objects", lambda kind, **kwargs: {"status": "ok", "objects": [{"kind": "Document", "name": "тл_Планировщик", "guid": "owner-guid"}] if kind == "Document" else []})
monkeypatch.setattr(adapter_1c_server, "metadata_object_related", lambda payload: {"status": "ok", "related": [{"category": "Form", "identity": {"guid": guid, "name": "ФормаДокументаНовая"}}]})
monkeypatch.setattr(adapter_1c_server, "get_object", lambda kind, name, **kwargs: {"status": "ok", "object": {"kind": kind, "name": name, "guid": "owner-guid"}})
monkeypatch.setattr(adapter_1c_server, "metadata_object_forms", lambda payload: {"status": "ok", "forms": [{"guid": guid, "name": "ФормаДокументаНовая"}]})
result = adapter_1c_server.call_method("repository.lock.plan", {"base_id": "base", "object": f"Form.{guid}"})
assert result["status"] == "ready"
assert result["lock_objects"] == ["Document.тл_Планировщик.Form.ФормаДокументаНовая"]
def test_missing_owned_form_is_rejected_by_lock_plan(monkeypatch, tmp_path: Path) -> None:
configured_base(monkeypatch, tmp_path)
monkeypatch.setattr(adapter_1c_server, "get_object", lambda kind, name, **kwargs: {"status": "ok", "object": {"kind": kind, "name": name, "guid": "owner-guid"}})
monkeypatch.setattr(adapter_1c_server, "metadata_object_forms", lambda payload: {"status": "ok", "forms": []})
result = adapter_1c_server.call_method("repository.lock.plan", {"base_id": "base", "object": "Document.тл_Планировщик.Form.НетТакой"})
assert result["status"] == "not_found"
assert result["error"] == "repository_form_not_found_in_sql"
def test_structural_lock_plan_requires_confirmation() -> None:
result = repository_control.lock_plan({"operation": "delete", "object": "Справочник.Склады"})
assert result["status"] == "needs_confirmation"
@@ -160,11 +318,110 @@ def test_write_gate_requires_adapter_lock_for_configured_base(monkeypatch, tmp_p
def test_adapter_dispatch_exposes_repository_status(monkeypatch, tmp_path: Path) -> None:
configured_base(monkeypatch, tmp_path, backend="direct")
monkeypatch.setenv("ONEC_ADAPTER_ENABLE_EXTERNAL_1C", "true")
result = adapter_1c_server.call_method("repository.status", {"base_id": "base"})
assert result["status"] == "configured"
assert result["repository"]["backend"] == "direct"
def test_repository_confirmation_help_exposes_machine_readable_contract() -> None:
result = adapter_1c_server.call_method("help.methods", {"method": "repository.lock.confirm"})
schema = result["methods"][0]["input_schema"]
assert schema["required"] == ["base_id", "request_id", "user_confirmed_locked"]
assert schema["example"]["user_confirmed_locked"] is True
assert "lock_session_id" in schema["notes"][-1]
def test_sql_only_repository_status_does_not_claim_repository_connection(monkeypatch, tmp_path: Path) -> None:
configured_base(monkeypatch, tmp_path)
monkeypatch.delenv("ONEC_ADAPTER_ENABLE_EXTERNAL_1C", raising=False)
result = repository_control.status({"base_id": "base", "probe": True})
assert result["status"] == "manual_confirmation_required"
assert result["connected"] is False
assert result["available"] is None
assert result["sql_lock_evidence"]["native_lock_state"] == "unknown"
assert result["sql_lock_evidence"]["excluded_sources"]["_ConfigChngR"] == "exchange_plan_change_registration"
def test_repository_policy_is_selected_per_base_or_extension_guid(monkeypatch, tmp_path: Path) -> None:
configured_layers(monkeypatch, tmp_path)
base = repository_control.write_gate({"base_id": "base"})
no_repository = repository_control.write_gate({"base_id": "base", "extension_guid": "11111111-2222-3333-4444-555555555555"})
extension = repository_control.write_gate({"base_id": "base", "extension_guid": "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee"})
assert base["status"] == "needs_repository_lock"
assert base["layer_id"] == "base"
assert no_repository == {"required": False, "allowed": True, "status": "repository_not_connected", "layer_id": "extension:11111111-2222-3333-4444-555555555555"}
assert extension["status"] == "needs_repository_lock"
assert extension["layer_id"] == "extension:aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee"
missing = repository_control.write_gate({"base_id": "base", "extension_guid": "99999999-2222-3333-4444-555555555555"})
assert missing["status"] == "blocked_repository_configuration"
assert missing["problem"]["status"] == "layer_not_configured"
def test_repository_connection_state_is_per_resolved_configuration_layer(monkeypatch, tmp_path: Path) -> None:
configured_layers(monkeypatch, tmp_path)
values = json.loads(__import__("os").environ["ONEC_SQL_BASES_JSON"])
layers = values["base"]["development_layers"]
layers["base"]["repository"].update({"mode": "automatic", "connection_state": "unavailable", "repository_user": "repo-user", "runner": {"kind": "http", "url": "http://runner.invalid"}})
layers["extension:aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee"]["repository"]["connection_state"] = "unknown"
monkeypatch.setenv("ONEC_SQL_BASES_JSON", json.dumps(values))
base = repository_control.lock_plan({"base_id": "base", "object": "ОбщийМодуль.Тест"})
assert base["workflow"] == "manual"
assert base["status"] == "ready"
assert repository_control.status({"base_id": "base"})["status"] == "manual_confirmation_required"
extension = repository_control.write_gate({
"base_id": "base",
"target": {"origin": {"extension": {"guid": "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee"}}},
})
assert extension["layer_id"] == "extension:aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee"
assert extension["status"] == "blocked_repository_connection_unknown"
def test_manual_confirmation_uses_repository_user_saved_for_the_layer(monkeypatch, tmp_path: Path) -> None:
configured_base(monkeypatch, tmp_path)
values = json.loads(__import__("os").environ["ONEC_SQL_BASES_JSON"])
values["base"]["repository"].update({"lock_mode": "manual", "connection_state": "unavailable"})
monkeypatch.setenv("ONEC_SQL_BASES_JSON", json.dumps(values))
request = repository_control.create_lock_request({"base_id": "base", "object": "Справочник.Товары"})
assert request["repository_user"] == "repo-user"
assert request["repository_user_source"] == "layer_connection_setting"
confirmed_without_repeating_user = repository_control.confirm_manual_lock({
"base_id": "base", "request_id": request["request_id"], "user_confirmed_locked": True,
})
assert confirmed_without_repeating_user["status"] == "manual_confirmed"
assert confirmed_without_repeating_user["repository_user"] == "repo-user"
request = repository_control.create_lock_request({"base_id": "base", "object": "Справочник.Товары"})
mismatch = repository_control.confirm_manual_lock({"base_id": "base", "request_id": request["request_id"], "user_confirmed_locked": True, "confirmed_repository_user": "other"})
assert mismatch["error"] == "repository_user_mismatch"
confirmed = repository_control.confirm_manual_lock({"base_id": "base", "request_id": request["request_id"], "user_confirmed_locked": True, "confirmed_repository_user": "repo-user"})
assert confirmed["repository_user"] == "repo-user"
def test_repository_confirmation_cannot_cross_extension_layers(monkeypatch, tmp_path: Path) -> None:
configured_layers(monkeypatch, tmp_path)
guid = "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee"
request = repository_control.create_lock_request({"base_id": "base", "extension_guid": guid, "object": "ОбщийМодуль.МодульРасширения"})
confirmed = repository_control.confirm_manual_lock({"base_id": "base", "extension_guid": guid, "request_id": request["request_id"], "user_confirmed_locked": True, "confirmed_repository_user": "operator"})
assert repository_control.write_gate({"base_id": "base", "extension_guid": guid, "lock_session_id": confirmed["lock_session_id"], "repository_object": "ОбщийМодуль.МодульРасширения"})["allowed"] is True
mismatch = repository_control.write_gate({"base_id": "base", "lock_session_id": confirmed["lock_session_id"], "repository_object": "ОбщийМодуль.МодульРасширения"})
assert mismatch["status"] == "blocked_repository_lock_session"
def test_support_gate_uses_exact_target_scope_independently_from_repository_scope(monkeypatch, tmp_path: Path) -> None:
configured_layers(monkeypatch, tmp_path)
parent = repository_control.support_gate({"base_id": "base", "canonical_path": "Документ.Заказ"})
form = repository_control.support_gate({"base_id": "base", "canonical_path": "Документ.Заказ.Форма.ФормаДокумента"})
attribute = repository_control.support_gate({"base_id": "base", "canonical_path": "Документ.Заказ.Реквизит.Комментарий"})
extension = repository_control.support_gate({"base_id": "base", "extension_guid": "11111111-2222-3333-4444-555555555555", "canonical_path": "ОбщийМодуль.Тест"})
assert parent["allowed"] is False
assert form["allowed"] is True
assert form["matched_scope"] == "Документ.Заказ.Форма.ФормаДокумента"
assert attribute["allowed"] is False
assert extension["status"] == "not_on_support"
def test_write_preflight_reports_repository_lock_requirement(monkeypatch, tmp_path: Path) -> None:
configured_base(monkeypatch, tmp_path)
monkeypatch.setattr(
@@ -186,6 +443,208 @@ def test_write_preflight_reports_repository_lock_requirement(monkeypatch, tmp_pa
assert result["repository"]["backend"] == "karman_bridge"
def test_write_preflight_uses_resolved_origin_layer_not_a_client_selector(monkeypatch, tmp_path: Path) -> None:
configured_layers(monkeypatch, tmp_path)
values = json.loads(__import__("os").environ["ONEC_SQL_BASES_JSON"])
values["base"]["development_layers"]["extension:aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee"]["repository"]["connection_state"] = "unknown"
monkeypatch.setenv("ONEC_SQL_BASES_JSON", json.dumps(values))
monkeypatch.setattr(
adapter_1c_server,
"metadata_write_plan",
lambda payload: {
"allowed": True, "status": "ready", "path_resolution": {},
"target": {"origin": {"extension": {"guid": "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee"}}},
"route": {}, "required_guards": [], "problems": [],
},
)
monkeypatch.setattr(adapter_1c_server, "metadata_write_preflight_saved_target", lambda payload, plan: {})
monkeypatch.setattr(adapter_1c_server, "live_sql_support_gate", lambda payload: {"allowed": True, "status": "ok"})
result = adapter_1c_server.metadata_write_preflight({"base_id": "base"})
assert result["repository"]["layer_id"] == "extension:aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee"
assert result["status"] == "blocked_repository_connection_unknown"
def test_form_module_write_gate_context_is_resolved_from_live_sql(monkeypatch) -> None:
form_guid = "f5f217c8-ac69-4e23-966b-d8ec0c665638"
reference = "Document.тл_Планировщик.Form.ФормаДокументаНовая"
monkeypatch.setattr(adapter_1c_server, "repository_resolve_form_guid_sql", lambda **kwargs: ({
"resolved_object": reference,
"form_guid": form_guid,
"owner": {"kind": "Document", "name": "тл_Планировщик", "guid": "owner-guid"},
}, None))
context = adapter_1c_server.resolve_write_gate_context({
"base_id": "neft", "module_id": f"Config:{form_guid}.0",
"repository_object": "Document.Другая.Форма.Другая",
})
assert context["repository_object"] == reference
assert context["support_object_guid"] == form_guid
assert context["owner_resolution"]["layer_id"] == "base"
def test_confirm_write_context_can_be_forwarded_under_repository_lock() -> None:
context, error = adapter_1c_server.normalize_repository_write_context({
"base_id": "neft",
"repository_lock": {
"lock_session_id": "rlock-test",
"repository_object": "Document.тл_Планировщик.Form.ФормаДокументаНовая",
"layer_id": "base",
},
}, "metadata.write.preflight")
assert error is None
assert context["lock_session_id"] == "rlock-test"
assert context["repository_object"] == "Document.тл_Планировщик.Form.ФормаДокументаНовая"
assert context["layer_id"] == "base"
_context, conflict = adapter_1c_server.normalize_repository_write_context({
"lock_session_id": "rlock-one",
"write_context": {"lock_session_id": "rlock-two"},
}, "code.write")
assert conflict["error"] == "repository_lock_context_conflict"
def test_preflight_uses_form_module_context_for_both_gates(monkeypatch) -> None:
form_guid = "f5f217c8-ac69-4e23-966b-d8ec0c665638"
reference = "Document.тл_Планировщик.Form.ФормаДокументаНовая"
monkeypatch.setattr(adapter_1c_server, "repository_resolve_form_guid_sql", lambda **kwargs: ({
"resolved_object": reference, "form_guid": form_guid,
"owner": {"kind": "Document", "name": "тл_Планировщик"},
}, None))
monkeypatch.setattr(adapter_1c_server, "metadata_write_plan", lambda payload: {
"allowed": True, "status": "ready", "path_resolution": {}, "target": {},
"route": {}, "required_guards": [], "problems": [],
})
monkeypatch.setattr(adapter_1c_server, "metadata_write_preflight_saved_target", lambda payload, plan: {})
captured: dict[str, dict] = {}
def fake_repository_gate(payload):
captured["repository"] = dict(payload)
return {"allowed": True, "status": "ready"}
def fake_support_gate(payload):
captured["support"] = dict(payload)
return {"allowed": True, "status": "ready"}
monkeypatch.setattr(repository_control, "write_gate", fake_repository_gate)
monkeypatch.setattr(adapter_1c_server, "live_sql_support_gate", fake_support_gate)
result = adapter_1c_server.metadata_write_preflight({
"base_id": "neft", "module_id": f"Config:{form_guid}.0",
"repository_lock": {"lock_session_id": "rlock-test", "repository_object": reference, "layer_id": "base"},
})
assert result["owner_resolution"]["repository_object"] == reference
assert captured["repository"]["repository_object"] == reference
assert captured["repository"]["lock_session_id"] == "rlock-test"
assert captured["support"]["support_object_guid"] == form_guid
def test_form_module_preflight_returns_concrete_saved_state_prepare_plan(monkeypatch) -> None:
form_guid = "f5f217c8-ac69-4e23-966b-d8ec0c665638"
monkeypatch.setattr(adapter_1c_server, "repository_resolve_form_guid_sql", lambda **kwargs: ({
"resolved_object": "Document.тл_Планировщик.Form.ФормаДокументаНовая",
"form_guid": form_guid, "owner": {"kind": "Document", "name": "тл_Планировщик"},
}, None))
monkeypatch.setattr(adapter_1c_server, "metadata_write_plan", lambda payload: {
"allowed": False, "status": "needs_origin", "path_resolution": {}, "target": {},
"route": {}, "required_guards": [], "problems": [{"code": "origin_lookup_required"}],
})
monkeypatch.setattr(adapter_1c_server, "metadata_write_preflight_saved_target", lambda payload, plan: {})
monkeypatch.setattr(adapter_1c_server, "metadata_write_resolve_module_target", lambda payload, target, mode: ({
"error": "saved_state_prepare_required",
"prepared_module_ref": f"ConfigSave:{form_guid}.0",
"next_resolution": {
"method": "metadata.saved_state.prepare",
"params": {
"base_id": "neft",
"layer": "base_saved_state",
"ref": "Document.тл_Планировщик",
"mode": "plan",
},
},
"prepare_plan": {"status": "plan_ready"},
}, None))
monkeypatch.setattr(repository_control, "write_gate", lambda payload: {"allowed": True, "status": "ready"})
monkeypatch.setattr(adapter_1c_server, "live_sql_support_gate", lambda payload: {"allowed": True, "status": "ready"})
result = adapter_1c_server.metadata_write_preflight({"base_id": "neft", "module_id": f"Config:{form_guid}.0"})
assert result["status"] == "needs_prepare"
assert result["saved_state"]["prepare_plan"]["status"] == "plan_ready"
assert result["route"]["auto_resolves_saved_state"] is True
assert result["route"]["next_resolution"]["method"] == "metadata.saved_state.prepare"
assert result["route"]["next_resolution"]["params"]["ref"] == "Document.тл_Планировщик"
assert "payload" not in result["route"]["next_resolution"]
def test_form_module_context_selects_resolved_extension_layer(monkeypatch) -> None:
form_guid = "f5f217c8-ac69-4e23-966b-d8ec0c665638"
extension_guid = "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee"
monkeypatch.setattr(adapter_1c_server, "repository_resolve_form_guid_sql", lambda **kwargs: ({
"resolved_object": "Document.Заказ.Form.Основная", "form_guid": form_guid,
"owner": {"kind": "Document", "name": "Заказ"}, "extension_guid": extension_guid,
}, None))
context = adapter_1c_server.resolve_write_gate_context({"base_id": "base", "module_id": f"Config:{form_guid}.0"})
assert context["extension_guid"] == extension_guid
assert repository_control.development_layer_id(context) == f"extension:{extension_guid}"
assert context["target"]["origin"]["extension"]["guid"] == extension_guid
def test_code_write_carries_form_module_gate_context_into_metadata_write(monkeypatch) -> None:
form_guid = "f5f217c8-ac69-4e23-966b-d8ec0c665638"
reference = "Document.тл_Планировщик.Form.ФормаДокументаНовая"
monkeypatch.setattr(adapter_1c_server, "repository_resolve_form_guid_sql", lambda **kwargs: ({
"resolved_object": reference, "form_guid": form_guid,
"owner": {"kind": "Document", "name": "тл_Планировщик"},
}, None))
seen: dict[str, object] = {}
monkeypatch.setattr(adapter_1c_server, "metadata_write", lambda payload: (seen.update(payload) or {"status": "blocked"}))
result = adapter_1c_server.code_write({"base_id": "neft", "module_id": f"Config:{form_guid}.0", "routine_text": "Процедура Тест()\nКонецПроцедуры"})
assert result["owner_resolution"]["repository_object"] == reference
assert seen["repository_object"] == reference
assert seen["support_object_guid"] == form_guid
def test_saved_state_prepare_keeps_form_gate_context(monkeypatch) -> None:
form_guid = "f5f217c8-ac69-4e23-966b-d8ec0c665638"
reference = "Document.тл_Планировщик.Form.ФормаДокументаНовая"
captured: dict[str, object] = {}
monkeypatch.setattr(adapter_1c_server, "metadata_saved_state_prepare", lambda payload: (
captured.update(payload) or {"status": "verified"}
))
resolved, error = adapter_1c_server.metadata_write_resolve_module_target({
"base_id": "neft", "module_id": f"Config:{form_guid}.0",
"allow_sql_saved_state_apply": True,
"lock_session_id": "rlock-test",
"repository_object": reference,
"support_object_guid": form_guid,
}, {}, "apply")
assert error is not None
assert captured["lock_session_id"] == "rlock-test"
assert captured["repository_object"] == reference
assert captured["support_object_guid"] == form_guid
assert resolved["module_ref"] == f"ConfigSave:{form_guid}.0"
def test_non_form_module_id_does_not_receive_form_write_gate_context(monkeypatch) -> None:
called = False
def unexpected(**kwargs):
nonlocal called
called = True
return None, {"status": "not_found"}
monkeypatch.setattr(adapter_1c_server, "repository_resolve_form_guid_sql", unexpected)
context = adapter_1c_server.resolve_write_gate_context({"base_id": "neft", "module_id": "Config:not-a-guid.0"})
assert "owner_resolution" not in context
assert called is False
def test_repository_layers_audit_reports_all_discovered_layers(monkeypatch, tmp_path: Path) -> None:
configured_layers(monkeypatch, tmp_path)
monkeypatch.setattr(adapter_1c_server, "list_extensions", lambda payload: {"status": "ok", "extensions": [{"name": "Тест", "guid": "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee"}]})
monkeypatch.setattr(adapter_1c_server, "metadata_support_decode", lambda payload: {"status": "ok", "source": {"kind": "live_sql", "support_payload_present": False}, "suppliers": [{"general_mode": "editable"}], "diagnostics": {"source_discovery": "test"}, "object_support": {"status": "not_supported"}})
result = adapter_1c_server.call_method("repository.layers.audit", {"base_id": "base"})
assert result["status"] == "ok"
assert [layer["layer_id"] for layer in result["layers"]] == ["base", "extension:aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee"]
assert result["layers"][1]["safe_next_write_action"] == "request_and_confirm_repository_capture"
assert result["layers"][0]["support"]["general_modes"] == ["editable"]
def test_direct_apply_method_is_repository_gated(monkeypatch, tmp_path: Path) -> None:
configured_base(monkeypatch, tmp_path)
result = adapter_1c_server.metadata_module_write_apply({"base_id": "base", "execution_mode": "apply"})
@@ -230,6 +689,5 @@ def test_sql_only_mode_never_calls_external_repository_runner(monkeypatch, tmp_p
monkeypatch.setattr(repository_control, "_run_designer", forbidden)
result = repository_control.lock({"base_id": "base", "object": "Справочник.Товары", "allow_repository_lock": True})
assert result["status"] == "blocked"
assert result["execution"]["status"] == "external_1c_disabled"
assert result["status"] == "manual_action_required"
assert called is False
+119
View File
@@ -0,0 +1,119 @@
from __future__ import annotations
import sys
from pathlib import Path
ROOT = Path(__file__).resolve().parents[2]
sys.path.insert(0, str(ROOT / "plugins" / "1c"))
sys.path.insert(0, str(ROOT / "plugins" / "1c" / "connector"))
import adapter_1c_server # noqa: E402
class FakeCursor:
def __init__(self) -> None:
self.rows = []
def __enter__(self):
return self
def __exit__(self, *_args):
return False
def execute(self, query, _params=None) -> None:
if "FROM dbo.[Params]" in query:
self.rows = [
{"FileName": "a.ui", "PartNo": 0, "BinaryData": b"one"},
{"FileName": "a.ui", "PartNo": 1, "BinaryData": b"two"},
{"FileName": "b.si", "PartNo": 0, "BinaryData": b"three"},
]
else:
self.rows = []
def __iter__(self):
return iter(self.rows)
def fetchall(self):
return list(self.rows)
class FakeConnection:
def __enter__(self):
return self
def __exit__(self, *_args):
return False
def cursor(self, as_dict=False):
assert as_dict is True
return FakeCursor()
def test_repository_sql_state_snapshot_hashes_parts_without_payload(monkeypatch) -> None:
monkeypatch.setattr(
adapter_1c_server,
"connect_live_sql",
lambda base_id, method, timeout_seconds=120: (FakeConnection(), {"database": "db"}, None),
)
result = adapter_1c_server.repository_sql_state_snapshot(
{"base_id": "base", "tables": ["Params"], "include_extensions": False, "diagnostic": True}
)
assert result["status"] == "ok"
assert result["counts"] == {"records": 2, "extensions": 0}
first = result["records"][0]
assert first["file_name"] == "a.ui"
assert first["part_count"] == 2
assert first["bytes"] == 6
assert "payload" not in first
assert result["diagnostics"]["payload_returned"] is False
token_diff = adapter_1c_server.repository_sql_state_diff(
{
"before_token": result["comparison_token"],
"after_token": result["comparison_token"],
"diagnostic": True,
}
)
assert token_diff["status"] == "ok"
assert token_diff["changed"] is False
def test_repository_sql_state_diff_reports_changed_part() -> None:
before = {
"schema": "onec_repository_sql_state_snapshot.v1",
"base_id": "base",
"snapshot_id": "before",
"records": [
{
"table": "Params",
"file_name": "a.ui",
"bytes": 3,
"sha1": "old",
"part_count": 1,
"parts": [{"part_no": 0, "bytes": 3, "sha1": "old"}],
}
],
"extensions": [],
}
after = {
**before,
"snapshot_id": "after",
"records": [
{
"table": "Params",
"file_name": "a.ui",
"bytes": 4,
"sha1": "new",
"part_count": 1,
"parts": [{"part_no": 0, "bytes": 4, "sha1": "new"}],
}
],
}
result = adapter_1c_server.repository_sql_state_diff(
{"before": before, "after": after, "diagnostic": True}
)
assert result["status"] == "ok"
assert result["changed"] is True
assert result["counts"] == {"added": 0, "removed": 0, "modified": 1}
assert result["modified"][0]["key"] == "storage:Params:a.ui"
assert result["modified"][0]["changed_parts"] == ["0"]
+71
View File
@@ -0,0 +1,71 @@
from __future__ import annotations
import sys
from pathlib import Path
from typing import Any
ROOT = Path(__file__).resolve().parents[2]
sys.path.insert(0, str(ROOT / "plugins" / "1c"))
from parser.scheduled_job import decode_schedule, schedule_write_edits # noqa: E402
def atom(value: Any) -> dict[str, Any]:
return {"type": "atom", "value": str(value)}
def schedule_tree() -> dict[str, Any]:
return {
"type": "list",
"items": [
atom("00010101000000"),
atom("00010101000000"),
atom("00010101090000"),
atom("00010101180000"),
atom("00010101000000"),
atom(0),
atom(0),
atom(0),
atom(2),
atom(1),
atom(5),
atom(0),
atom(0),
atom(1),
atom(1),
atom(1),
atom(1),
],
}
def test_decode_schedule_is_adapter_independent() -> None:
result = decode_schedule(schedule_tree())
assert result["status"] == "ok"
assert result["begin_time"] == "09:00:00"
assert result["end_time"] == "18:00:00"
assert result["week_days"] == [1, 5]
assert result["months"] == [1]
def test_schedule_write_edits_rebuilds_resized_collections() -> None:
result = schedule_write_edits(
schedule_tree(),
{"week_days": [1, 2, 3, 4, 5], "months": [1, 6, 12]},
)
assert result["status"] == "ok"
assert result["counts"]["resized_collections"] == 2
rebuilt = decode_schedule(result["edits"][0]["replace_root"])
assert rebuilt["week_days"] == [1, 2, 3, 4, 5]
assert rebuilt["months"] == [1, 6, 12]
def test_schedule_write_edits_rejects_unknown_named_field() -> None:
result = schedule_write_edits(schedule_tree(), {"sql_path": "1.2.3"})
assert result["status"] == "invalid_argument"
assert result["error"] == "unsupported_schedule_fields"
assert result["diagnostics"]["fields"] == ["sql_path"]
+240
View File
@@ -0,0 +1,240 @@
from __future__ import annotations
import sys
from pathlib import Path
import pytest
ROOT = Path(__file__).resolve().parents[2]
sys.path.insert(0, str(ROOT / "plugins" / "1c"))
sys.path.insert(0, str(ROOT / "plugins" / "1c" / "connector"))
import adapter_1c_server # noqa: E402
from parser.support_rules import parse_parent_configurations_bytes
GUID = "8cec71dd-5046-4b16-ab13-cca39c826ca2"
CONFIG_GUID = "8cbe2d2d-295e-4b77-813b-63588e84f87a"
OTHER_GUID = "1fbe6b79-1ea3-43f9-bf72-f6cd4da9743b"
def payload(general_mode: int = 0, rule: int = 1) -> bytes:
text = (
f'{{6,0,1,{CONFIG_GUID},{general_mode},{OTHER_GUID},"1.2.3",'
f'"Поставщик","Конфигурация",1,{rule},0,{GUID},{GUID},0,0}}'
)
return text.encode("utf-8-sig")
def test_decodes_editable_support_rule() -> None:
result = parse_parent_configurations_bytes(payload())
supplier = result["suppliers"][0]
assert result["supplier_count"] == 1
assert supplier.general_mode == "editable"
assert supplier.name == "Конфигурация"
assert supplier.rules[0].object_guid == GUID
assert supplier.rules[0].rule == "editable_support_preserved"
def test_locked_general_mode_overrides_object_rule() -> None:
result = parse_parent_configurations_bytes(payload(general_mode=1, rule=2))
supplier = result["suppliers"][0]
assert supplier.general_mode == "locked"
assert supplier.rules[0].rule_code == 0
assert supplier.rules[0].rule == "not_editable"
@pytest.mark.parametrize(
("data", "message"),
[
(b'{5,0,0}', "expected format marker 6"),
(b'{6,0,1,not-a-guid,0,x,v,p,n,0,0,0}', "configuration GUID is invalid"),
(payload(rule=9), "unsupported object rule code 9"),
],
)
def test_rejects_invalid_support_payload(data: bytes, message: str) -> None:
with pytest.raises(ValueError, match=message):
parse_parent_configurations_bytes(data)
def test_sql_method_decodes_only_explicit_source_and_filters_object(monkeypatch) -> None:
monkeypatch.setattr(
adapter_1c_server,
"read_storage_file_bytes",
lambda base_id, table, file_name, timeout_seconds=30: (payload(), {"database": "db"}, None),
)
result = adapter_1c_server.metadata_support_decode(
{"base_id": "base", "table": "Params", "file_name": "support.si", "object_guid": GUID}
)
assert result["status"] == "ok"
assert result["source"] == {"kind": "live_sql", "database": "db", "table": "Params", "file_name": "support.si"}
assert result["matches"][0]["rule"] == "editable_support_preserved"
assert result["object_support"]["status"] == "editable_support_preserved"
assert result["object_support"]["edit_allowed_by_support"] is True
def test_sql_method_resolves_public_1c_name_before_filtering_support(monkeypatch) -> None:
captured: dict[str, str] = {}
def get_object(kind, name, **kwargs):
captured["kind"] = kind
captured["name"] = name
return {
"status": "ok",
"object": {
"kind": "CommonModule",
"name": "ОбменДанными",
"guid": GUID,
"ref": "CommonModule.ОбменДанными",
},
}
monkeypatch.setattr(adapter_1c_server, "get_object", get_object)
monkeypatch.setattr(
adapter_1c_server,
"read_storage_file_bytes",
lambda base_id, table, file_name, timeout_seconds=30: (payload(), {"database": "db"}, None),
)
result = adapter_1c_server.call_method(
"metadata.support.decode",
{
"base_id": "base",
"ref": "ОбщиеМодули.ОбменДанными",
"table": "Params",
"file_name": "support.si",
},
)
assert result["status"] == "ok"
assert captured == {"kind": "CommonModule", "name": "ОбменДанными"}
assert result["query"]["object_guid"] == GUID
assert result["query"]["object"]["ref"] == "CommonModule.ОбменДанными"
assert result["object_support"]["status"] == "editable_support_preserved"
def test_support_decode_help_publishes_optional_name_selector() -> None:
methods = {
row["name"]: row
for row in adapter_1c_server.call_method("help.methods", {})["methods"]
}
method = methods["metadata.support.decode"]
assert method["selector_capabilities"]["selector_purpose"] == "optional_object_support_filter"
assert method["selector_capabilities"]["accepts_ordinal"] is False
assert method["input_schema"]["properties"]["object_name"]["type"] == "string"
def test_sql_method_treats_absent_guid_as_not_supported_after_complete_decode(monkeypatch) -> None:
monkeypatch.setattr(
adapter_1c_server,
"read_storage_file_bytes",
lambda base_id, table, file_name, timeout_seconds=30: (payload(), {"database": "db"}, None),
)
result = adapter_1c_server.metadata_support_decode(
{"base_id": "base", "table": "Config", "file_name": "support.bin", "object_guid": OTHER_GUID}
)
assert result["status"] == "ok"
assert result["matches"] == []
assert result["object_support"] == {
"object_guid": OTHER_GUID,
"status": "not_supported",
"edit_allowed_by_support": True,
"evidence": "absent_from_complete_parent_configurations",
}
assert result["diagnostics"]["repository_lock_evaluated"] is False
def test_live_sql_support_gate_resolves_public_object_name(monkeypatch) -> None:
monkeypatch.setattr(
adapter_1c_server.repository_control,
"support_gate",
lambda request: {"required": True, "allowed": False, "status": "blocked_support_unknown"},
)
monkeypatch.setattr(
adapter_1c_server,
"get_object",
lambda kind, name, **kwargs: {"status": "ok", "object": {"guid": GUID}},
)
monkeypatch.setattr(
adapter_1c_server,
"metadata_support_decode",
lambda request: {
"status": "ok",
"object_support": {
"status": "not_supported",
"edit_allowed_by_support": True,
"evidence": "absent_from_complete_parent_configurations",
},
"diagnostics": {"source_discovery": "base_root_descriptor"},
},
)
gate = adapter_1c_server.live_sql_support_gate(
{"base_id": "base", "layer_id": "base", "ref": "ОбщийМодуль.Модуль"}
)
assert gate["allowed"] is True
assert gate["required"] is False
assert gate["mode"] == "live_sql"
assert gate["object_guid"] == GUID
def test_sql_method_discovers_base_source_and_rejects_invalid_payload(monkeypatch) -> None:
monkeypatch.setattr(
adapter_1c_server,
"discover_base_support_source",
lambda base_id, timeout_seconds=30: (
{
"kind": "live_sql",
"database": "db",
"table": "Config",
"file_name": f"{CONFIG_GUID}.4",
"root_file": OTHER_GUID,
"configuration_id": CONFIG_GUID,
"discovery": "base_root_descriptor",
},
None,
),
)
monkeypatch.setattr(
adapter_1c_server,
"read_storage_file_bytes",
lambda base_id, table, file_name, timeout_seconds=30: (payload(), {"database": "db"}, None),
)
discovered = adapter_1c_server.metadata_support_decode({"base_id": "base", "object_guid": GUID})
assert discovered["status"] == "ok"
assert discovered["source"]["file_name"] == f"{CONFIG_GUID}.4"
assert discovered["diagnostics"]["source_discovery"] == "base_root_descriptor"
monkeypatch.setattr(
adapter_1c_server,
"discover_extension_support_source",
lambda base_id, extension_guid, timeout_seconds=30: (
{
"kind": "live_sql",
"table": "ConfigCAS",
"configuration_id": OTHER_GUID,
"support_payload_present": False,
"discovery": "extension_cas_manifest",
},
None,
),
)
extension = adapter_1c_server.metadata_support_decode(
{"base_id": "base", "layer_id": f"extension:{CONFIG_GUID}", "object_guid": GUID}
)
assert extension["status"] == "ok"
assert extension["object_support"]["status"] == "not_supported"
assert extension["object_support"]["edit_allowed_by_support"] is True
assert extension["diagnostics"]["repository_lock_evaluated"] is False
monkeypatch.setattr(
adapter_1c_server,
"read_storage_file_bytes",
lambda base_id, table, file_name, timeout_seconds=30: (b"{0}", {"database": "db"}, None),
)
invalid = adapter_1c_server.metadata_support_decode(
{"base_id": "base", "table": "Params", "file_name": "not-support.si"}
)
assert invalid["status"] == "invalid_format"
assert invalid["diagnostics"]["permission_inference"] == "forbidden"
+74
View File
@@ -0,0 +1,74 @@
from __future__ import annotations
import sys
from pathlib import Path
from typing import Any
ROOT = Path(__file__).resolve().parents[2]
sys.path.insert(0, str(ROOT))
import scripts.smoke_1c_write_plan_safety as smoke # noqa: E402
def test_repository_gate_falls_back_to_non_mutating_path_route_probe(monkeypatch) -> None:
calls: list[tuple[str, dict[str, Any]]] = []
def fake_rpc_call(
_endpoint_url: str,
method: str,
payload: dict[str, Any],
_timeout: float,
*,
transport: str,
session_id: str | None,
) -> dict[str, Any]:
calls.append((method, payload))
assert transport == "rest"
assert session_id is None
if method == "metadata.write":
if payload.get("mode") == "apply":
return {
"schema": "onec_repository_write_gate.v1",
"status": "blocked",
"error": "needs_repository_lock",
}
return {
"schema": "onec_metadata_write.v1",
"status": "blocked",
"error": "write_plan_required",
"routed_method": "metadata.write.plan",
"target_kind": "form",
}
intent = payload.get("intent") or {}
if intent.get("current_text") is not None:
return {
"status": "blocked",
"allowed": False,
"problems": [{"code": "control_fragment_drift"}],
}
if intent.get("control_fragment"):
return {
"status": "planned",
"allowed": True,
"route": {"apply_method": "metadata.module.write_apply"},
"target": {"target_kind": "module"},
}
return {
"status": "blocked",
"allowed": False,
"problems": [{"code": "missing_control_fragment"}],
}
monkeypatch.setattr(smoke, "rpc_call", fake_rpc_call)
result = smoke.run_smoke("http://adapter", "upo_test", 30, transport="rest")
assert result["status"] == "ok"
assert result["failures"] == []
assert result["checks"]["blocked_effective_form_path"]["apply_gate"] == {
"status": "blocked",
"error": "needs_repository_lock",
}
metadata_write_modes = [payload["mode"] for method, payload in calls if method == "metadata.write"]
assert metadata_write_modes == ["apply", "plan"]
+63
View File
@@ -0,0 +1,63 @@
from __future__ import annotations
import sys
from pathlib import Path
from typing import Any
ROOT = Path(__file__).resolve().parents[2]
sys.path.insert(0, str(ROOT))
import scripts.smoke_1c_write_preflight as smoke # noqa: E402
def test_repository_lock_is_a_valid_preflight_classification() -> None:
assert smoke.classified_preflight_status("needs_repository_lock") is True
assert smoke.classified_preflight_status("unexpected") is False
def test_saved_module_target_prefers_bounded_stream(monkeypatch) -> None:
whole_form = {
"kind": "module",
"module_ref": "ConfigCASSave:extension__form.0",
"file_name": "extension__form.0",
"module_path": "2",
}
bounded_stream = {
"kind": "module",
"module_ref": "ConfigSave:module.3#stream:4",
"file_name": "module.3",
"stream_index": 4,
}
def fake_rpc_call(
_endpoint_url: str,
method: str,
_payload: dict[str, Any],
_timeout: float,
*,
transport: str,
session_id: str | None,
) -> dict[str, Any]:
assert method == "metadata.saved_state.modules.search"
assert transport == "rest"
assert session_id is None
return {
"status": "ok",
"modules": [
{"streams": [{"write_plan_target": whole_form}]},
{"streams": [{"write_plan_target": bounded_stream}]},
],
}
monkeypatch.setattr(smoke, "rpc_call", fake_rpc_call)
result = smoke.first_saved_module_target(
"http://adapter",
"upo_test",
30,
transport="rest",
session_id=None,
)
assert result == bounded_stream