585 lines
21 KiB
Python
585 lines
21 KiB
Python
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
|