1666 lines
78 KiB
Python
1666 lines
78 KiB
Python
from __future__ import annotations
|
||
|
||
import json
|
||
import random
|
||
import os
|
||
import time
|
||
from dataclasses import dataclass
|
||
import re
|
||
import urllib.error
|
||
from typing import Any, Callable, Optional
|
||
|
||
import allure # pyright: ignore[reportMissingImports]
|
||
from allure_commons.types import AttachmentType # pyright: ignore[reportMissingImports]
|
||
|
||
from worklib import admin_data
|
||
from worklib.graphql_client import DEFAULT_COMPANY_ID, execute_graphql
|
||
from worklib.auth_as_employer import get_access_token
|
||
|
||
|
||
def _attach_json(name: str, payload: Any) -> None:
|
||
allure.attach(
|
||
json.dumps(payload, ensure_ascii=False, indent=2),
|
||
name=name,
|
||
attachment_type=AttachmentType.JSON,
|
||
)
|
||
|
||
|
||
def _random_object_id() -> str:
|
||
# Похоже на mongo ObjectId (24 hex), чтобы проходить простые валидаторы.
|
||
return "".join(random.choice("0123456789abcdef") for _ in range(24))
|
||
|
||
|
||
def _exec_or_fail(*, op_name: str, token: str, query: str, variables: dict[str, Any] | None = None, company_id: str) -> dict[str, Any]:
|
||
try:
|
||
return execute_graphql(
|
||
query=query,
|
||
variables=variables,
|
||
company_id=company_id,
|
||
access_token=token,
|
||
)
|
||
except PermissionError as e:
|
||
allure.attach(str(e), name=f"Forbidden: {op_name}", attachment_type=AttachmentType.TEXT)
|
||
raise AssertionError(f"Forbidden на операции: {op_name}") from e
|
||
except RuntimeError as e:
|
||
# На моках/прокси иногда приходит GraphQL error при невалидном input — хотим видеть оригинал.
|
||
allure.attach(str(e), name=f"RuntimeError: {op_name}", attachment_type=AttachmentType.TEXT)
|
||
raise
|
||
|
||
|
||
@dataclass
|
||
class PassRequestTestData:
|
||
"""
|
||
Данные для теста passRequests:
|
||
place -> entrance(place_type=entrance) -> service -> user in place -> createPass -> query passRequests
|
||
"""
|
||
|
||
company_id: str = DEFAULT_COMPANY_ID
|
||
parent_place_id: str = "6915dc03462d5aea0adc8cbd"
|
||
|
||
access_token: Optional[str] = None
|
||
place_id: Optional[str] = None
|
||
entrance_place_id: Optional[str] = None
|
||
entrance_id: Optional[str] = None
|
||
expected_pass_request_place_id: Optional[str] = None
|
||
service_id: Optional[str] = None
|
||
account_id: Optional[str] = None
|
||
pass_id: Optional[str] = None
|
||
pass_request_id: Optional[str] = None
|
||
|
||
# nested places for approval flow
|
||
place1_id: Optional[str] = None
|
||
place2_id: Optional[str] = None
|
||
place3_id: Optional[str] = None
|
||
|
||
# current (my) user / employee
|
||
my_account_id: Optional[str] = None
|
||
my_employee_id: Optional[str] = None
|
||
|
||
# new employee with passRequests permissions
|
||
new_username: Optional[str] = None
|
||
new_password: Optional[str] = None
|
||
new_account_id: Optional[str] = None
|
||
new_employee_id: Optional[str] = None
|
||
new_access_token: Optional[str] = None
|
||
owner_account_id: Optional[str] = None
|
||
worker_account_id: Optional[str] = None
|
||
worker_employee_id: Optional[str] = None
|
||
su_place1_id: Optional[str] = None
|
||
su_place2_id: Optional[str] = None
|
||
su_place3_id: Optional[str] = None
|
||
su_place4_id: Optional[str] = None
|
||
|
||
_cleanup_fns: Optional[list[Callable[[], None]]] = None
|
||
_mutation_fields_cache: Optional[list[str]] = None
|
||
_place_type_enum_cache: Optional[list[str]] = None
|
||
_place_parent_by_id: Optional[dict[str, str]] = None
|
||
_query_fields_cache: Optional[list[str]] = None
|
||
|
||
@classmethod
|
||
def from_behave_context(cls, context: Any, *, company_id: str = DEFAULT_COMPANY_ID) -> "PassRequestTestData":
|
||
td: PassRequestTestData | None = getattr(context, "pass_request_test_data", None)
|
||
if isinstance(td, cls):
|
||
if not td.access_token and getattr(context, "access_token", None):
|
||
td.access_token = context.access_token
|
||
if not td._cleanup_fns:
|
||
td._cleanup_fns = getattr(context, "_cleanup_fns", None)
|
||
return td
|
||
|
||
td = cls(company_id)
|
||
td.access_token = getattr(context, "access_token", None) or None
|
||
td._cleanup_fns = getattr(context, "_cleanup_fns", None)
|
||
setattr(context, "pass_request_test_data", td)
|
||
return td
|
||
|
||
def _register_cleanup(self, fn: Callable[[], None]) -> None:
|
||
if self._cleanup_fns is not None:
|
||
self._cleanup_fns.append(fn)
|
||
|
||
def _remember_place_parent(self, *, place_id: str, parent_id: str) -> None:
|
||
if self._place_parent_by_id is None:
|
||
self._place_parent_by_id = {}
|
||
self._place_parent_by_id[str(place_id)] = str(parent_id)
|
||
|
||
def _get_place_parent(self, place_id: str) -> Optional[str]:
|
||
if not self._place_parent_by_id:
|
||
return None
|
||
return self._place_parent_by_id.get(str(place_id))
|
||
|
||
def ensure_token(self) -> str:
|
||
if self.access_token:
|
||
return self.access_token
|
||
token = admin_data.get_or_create_user("tester").access_token
|
||
assert token, "Нет access_token."
|
||
self.access_token = token
|
||
return token
|
||
|
||
def _get_mutation_field_names(self) -> list[str]:
|
||
"""
|
||
Пытаемся получить список mutation полей через GraphQL introspection.
|
||
Это позволяет не гадать с названиями операций (API на стендах бывает разный).
|
||
"""
|
||
if self._mutation_fields_cache is not None:
|
||
return self._mutation_fields_cache
|
||
token = self.ensure_token()
|
||
query = """
|
||
query __introspectionMutationFields {
|
||
__schema {
|
||
mutationType {
|
||
fields { name }
|
||
}
|
||
}
|
||
}
|
||
""".strip()
|
||
try:
|
||
resp = execute_graphql(query=query, variables=None, company_id=self.company_id, access_token=token)
|
||
fields = resp.get("data", {}).get("__schema", {}).get("mutationType", {}).get("fields", [])
|
||
names: list[str] = []
|
||
if isinstance(fields, list):
|
||
for f in fields:
|
||
if isinstance(f, dict):
|
||
n = f.get("name")
|
||
if isinstance(n, str) and n:
|
||
names.append(n)
|
||
self._mutation_fields_cache = names
|
||
return names
|
||
except Exception as e: # noqa: BLE001
|
||
allure.attach(str(e), name="Introspection failed (mutation fields)", attachment_type=AttachmentType.TEXT)
|
||
self._mutation_fields_cache = []
|
||
return []
|
||
|
||
# NOTE: query-field introspection left for future debugging.
|
||
|
||
def _get_place_type_enum_values(self) -> list[str]:
|
||
"""
|
||
Возвращает возможные значения enum PlaceType через introspection.
|
||
Нужен для адаптивного создания вложенных мест: разные стенды по-разному валидируют parent/child.
|
||
"""
|
||
if self._place_type_enum_cache is not None:
|
||
return self._place_type_enum_cache
|
||
token = self.ensure_token()
|
||
query = """
|
||
query __placeTypeEnum {
|
||
__type(name: "PlaceType") {
|
||
kind
|
||
enumValues { name }
|
||
}
|
||
}
|
||
""".strip()
|
||
try:
|
||
resp = execute_graphql(query=query, variables=None, company_id=self.company_id, access_token=token)
|
||
enum_values = resp.get("data", {}).get("__type", {}).get("enumValues", [])
|
||
values: list[str] = []
|
||
if isinstance(enum_values, list):
|
||
for ev in enum_values:
|
||
if isinstance(ev, dict):
|
||
n = ev.get("name")
|
||
if isinstance(n, str) and n:
|
||
values.append(n)
|
||
self._place_type_enum_cache = values
|
||
return values
|
||
except Exception as e: # noqa: BLE001
|
||
allure.attach(str(e), name="Introspection failed (PlaceType enum)", attachment_type=AttachmentType.TEXT)
|
||
self._place_type_enum_cache = []
|
||
return []
|
||
|
||
def _retry_graphql(self, *, op_name: str, token: str, query: str, variables: dict[str, Any] | None, company_id: str, retries: int = 3) -> dict[str, Any]:
|
||
"""
|
||
Retry для нестабильных стендов (часто встречается GraphQL 500 / ConnectionReset).
|
||
Ретраим только на 500/ISE/сетевые ошибки, чтобы не скрывать реальные проблемы.
|
||
"""
|
||
last_exc: Exception | None = None
|
||
for attempt in range(1, retries + 1):
|
||
try:
|
||
return _exec_or_fail(op_name=op_name, token=token, query=query, variables=variables, company_id=company_id)
|
||
except (ConnectionResetError, urllib.error.URLError) as e:
|
||
last_exc = e
|
||
except RuntimeError as e:
|
||
msg = str(e)
|
||
if "Internal Server Error" in msg or "GraphQL HTTP 500" in msg or "status': 500" in msg:
|
||
last_exc = e
|
||
else:
|
||
raise
|
||
time.sleep(0.4 * attempt)
|
||
raise AssertionError(f"{op_name} не выполнился после {retries} попыток. Последняя ошибка: {last_exc}")
|
||
|
||
def _pick_mutation_name(self, patterns: list[str], *, fallback: list[str]) -> list[str]:
|
||
"""
|
||
Возвращает список кандидатов (1-й — самый вероятный).
|
||
Сначала пытаемся выбрать из introspection по regex паттернам, затем добавляем fallback.
|
||
"""
|
||
names = self._get_mutation_field_names()
|
||
picked: list[str] = []
|
||
for pat in patterns:
|
||
rx = re.compile(pat, re.IGNORECASE)
|
||
for n in names:
|
||
if rx.search(n) and n not in picked:
|
||
picked.append(n)
|
||
for n in fallback:
|
||
if n not in picked:
|
||
picked.append(n)
|
||
return picked
|
||
|
||
def ensure_place(self) -> str:
|
||
if self.place_id:
|
||
return self.place_id
|
||
if os.getenv("USE_WIREMOCK") in {"1", "true", "True"}:
|
||
self.place_id = "mock_place"
|
||
self._remember_place_parent(place_id=self.place_id, parent_id=self.parent_place_id)
|
||
return self.place_id
|
||
token = self.ensure_token()
|
||
mutation = """
|
||
mutation ($place_type: PlaceType!, $names: [String!]!, $parent_id: String, $address_id: String) {
|
||
createPlaceMultiple(dto: {place_type: $place_type, names: $names, parent_id: $parent_id, address_id: $address_id}) {
|
||
id
|
||
__typename
|
||
}
|
||
}
|
||
""".strip()
|
||
suffix = str(int(time.time()))
|
||
# На текущем стенде PlaceType.building/PlaceType.street иногда ломают API (400 Bad Request),
|
||
# поэтому создаём основной place типа flat (как в Ticket/KVS тестах).
|
||
variables = {"names": [f"pass-place-{suffix}"], "parent_id": self.parent_place_id, "place_type": "flat"}
|
||
with allure.step("GraphQL: createPlaceMultiple (main place)"):
|
||
resp = _exec_or_fail(op_name="createPlaceMultiple(main)", token=token, query=mutation, variables=variables, company_id=self.company_id)
|
||
_attach_json("createPlaceMultiple(main) response", resp)
|
||
created = resp.get("data", {}).get("createPlaceMultiple")
|
||
created0 = created[0] if isinstance(created, list) and created else created
|
||
assert isinstance(created0, dict) and created0.get("id"), f"createPlaceMultiple не вернул id: {resp!r}"
|
||
place_id = created0["id"]
|
||
self.place_id = place_id
|
||
self._remember_place_parent(place_id=place_id, parent_id=self.parent_place_id)
|
||
# По требованиям: для созданного place сразу должен существовать entrance.
|
||
# Делается best-effort: если на стенде createEntrance/валидации отличаются — ошибки будет видно в Allure.
|
||
# Важно: passRequest создаётся в родительском месте, поэтому entrance создаём на place + parent.
|
||
self.ensure_entrance_connected_to_places(place_ids=[place_id, self.parent_place_id])
|
||
|
||
def _cleanup_delete_place() -> None:
|
||
delete_mutation = """mutation($id: String!) { deletePlace(id: $id) }""".strip()
|
||
_exec_or_fail(op_name="deletePlace", token=token, query=delete_mutation, variables={"id": place_id}, company_id=self.company_id)
|
||
|
||
self._register_cleanup(_cleanup_delete_place)
|
||
return place_id
|
||
|
||
def _create_place(self, *, parent_id: str, title_prefix: str, place_type: str) -> str:
|
||
token = self.ensure_token()
|
||
mutation = """
|
||
mutation ($place_type: PlaceType!, $names: [String!]!, $parent_id: String, $address_id: String) {
|
||
createPlaceMultiple(dto: {place_type: $place_type, names: $names, parent_id: $parent_id, address_id: $address_id}) {
|
||
id
|
||
__typename
|
||
}
|
||
}
|
||
""".strip()
|
||
suffix = str(int(time.time()))
|
||
variables = {"names": [f"{title_prefix}-{suffix}"], "parent_id": parent_id, "place_type": place_type}
|
||
with allure.step(f"GraphQL: createPlaceMultiple ({title_prefix}, place_type={place_type})"):
|
||
resp = self._retry_graphql(
|
||
op_name="createPlaceMultiple",
|
||
token=token,
|
||
query=mutation,
|
||
variables=variables,
|
||
company_id=self.company_id,
|
||
retries=2,
|
||
)
|
||
_attach_json("createPlaceMultiple response", resp)
|
||
created = resp.get("data", {}).get("createPlaceMultiple")
|
||
created0 = created[0] if isinstance(created, list) and created else created
|
||
assert isinstance(created0, dict) and created0.get("id"), f"createPlaceMultiple не вернул id: {resp!r}"
|
||
place_id = created0["id"]
|
||
self._remember_place_parent(place_id=place_id, parent_id=parent_id)
|
||
|
||
def _cleanup_delete_place() -> None:
|
||
delete_mutation = """mutation($id: String!) { deletePlace(id: $id) }""".strip()
|
||
_exec_or_fail(op_name="deletePlace", token=token, query=delete_mutation, variables={"id": place_id}, company_id=self.company_id)
|
||
|
||
self._register_cleanup(_cleanup_delete_place)
|
||
return place_id
|
||
|
||
def ensure_three_nested_places(self) -> tuple[str, str, str]:
|
||
"""
|
||
Создаёт 3 места.
|
||
На части стендов вложенность (child->parent) ломает createPlaceMultiple (400),
|
||
поэтому используем максимально совместимый вариант: 3 места-сиблинга под одним parent_place_id.
|
||
"""
|
||
if self.place1_id and self.place2_id and self.place3_id:
|
||
return (self.place1_id, self.place2_id, self.place3_id)
|
||
if os.getenv("USE_WIREMOCK") in {"1", "true", "True"}:
|
||
self.place1_id, self.place2_id, self.place3_id = "mock_place_1", "mock_place_2", "mock_place_3"
|
||
self._remember_place_parent(place_id=self.place1_id, parent_id=self.parent_place_id)
|
||
self._remember_place_parent(place_id=self.place2_id, parent_id=self.parent_place_id)
|
||
self._remember_place_parent(place_id=self.place3_id, parent_id=self.parent_place_id)
|
||
return (self.place1_id, self.place2_id, self.place3_id)
|
||
# На некоторых стендах createPlaceMultiple возвращает 400 на части enum PlaceType.
|
||
# Самый стабильный тип, который используется и в других тестах — "flat".
|
||
p1_type = "flat"
|
||
p2_type = "flat"
|
||
p3_type = "flat"
|
||
|
||
p1 = self._create_place(parent_id=self.parent_place_id, title_prefix="passreq-place-1", place_type=p1_type)
|
||
p2 = self._create_place(parent_id=self.parent_place_id, title_prefix="passreq-place-2", place_type=p2_type)
|
||
p3 = self._create_place(parent_id=self.parent_place_id, title_prefix="passreq-place-3", place_type=p3_type)
|
||
self.place1_id, self.place2_id, self.place3_id = p1, p2, p3
|
||
# Один entrance на все места, которые используются в тестах (создаём один раз).
|
||
# + добавляем общий parent, т.к. passRequest живёт на родителе.
|
||
self.ensure_entrance_connected_to_places(place_ids=[p1, p2, p3, self.parent_place_id])
|
||
return (p1, p2, p3)
|
||
|
||
def _ensure_place_has_user(self, *, place_id: str) -> None:
|
||
"""
|
||
По требованиям сценариев: в каждом месте должен быть хотя бы один пользователь.
|
||
Самый совместимый путь — создать отдельного demo-user и добавить его в место.
|
||
"""
|
||
if os.getenv("USE_WIREMOCK") in {"1", "true", "True"}:
|
||
return
|
||
uid = self._create_user(first_name="place", last_name="member")
|
||
self._add_user_to_place(account_id=uid, place_id=place_id)
|
||
|
||
def _connect_entrance_to_place(self, *, entrance_id: str, place_id: str) -> bool:
|
||
"""
|
||
По требованиям сценариев: place должен быть "соединён" с entrance.
|
||
На разных стендах названия/сигнатуры мутаций отличаются — пробуем через introspection + fallback.
|
||
"""
|
||
if os.getenv("USE_WIREMOCK") in {"1", "true", "True"}:
|
||
return True
|
||
token = self.ensure_token()
|
||
candidates = self._pick_mutation_name(
|
||
patterns=[
|
||
r"(add|attach|set).*(entrance).*(place)",
|
||
r"(add|attach|set).*(place).*(entrance)",
|
||
r"(connect|link).*(entrance).*(place)",
|
||
],
|
||
fallback=[
|
||
"addEntranceToPlace",
|
||
"attachEntranceToPlace",
|
||
"setPlaceEntrances",
|
||
"addPlaceEntrance",
|
||
"connectEntranceToPlace",
|
||
],
|
||
)
|
||
last_error: Exception | None = None
|
||
attempts: list[str] = []
|
||
for name in candidates:
|
||
variants: list[tuple[str, str, dict[str, Any]]] = [
|
||
(
|
||
"dto-entrance_id",
|
||
f"""
|
||
mutation($place_id: String!, $entrance_id: String!) {{
|
||
{name}(dto: {{ place_id: $place_id, entrance_id: $entrance_id }})
|
||
}}
|
||
""".strip(),
|
||
{"place_id": place_id, "entrance_id": entrance_id},
|
||
),
|
||
(
|
||
"dto-entrance_ids",
|
||
f"""
|
||
mutation($place_id: String!, $entrance_ids: [String!]!) {{
|
||
{name}(dto: {{ place_id: $place_id, entrance_ids: $entrance_ids }})
|
||
}}
|
||
""".strip(),
|
||
{"place_id": place_id, "entrance_ids": [entrance_id]},
|
||
),
|
||
(
|
||
"args-entrance_id",
|
||
f"""
|
||
mutation($place_id: String!, $entrance_id: String!) {{
|
||
{name}(place_id: $place_id, entrance_id: $entrance_id)
|
||
}}
|
||
""".strip(),
|
||
{"place_id": place_id, "entrance_id": entrance_id},
|
||
),
|
||
(
|
||
"args-entrance_ids",
|
||
f"""
|
||
mutation($place_id: String!, $entrance_ids: [String!]!) {{
|
||
{name}(place_id: $place_id, entrance_ids: $entrance_ids)
|
||
}}
|
||
""".strip(),
|
||
{"place_id": place_id, "entrance_ids": [entrance_id]},
|
||
),
|
||
]
|
||
for vlabel, m, vars_ in variants:
|
||
attempts.append(f"{name}/{vlabel}")
|
||
with allure.step(f"GraphQL: connect entrance to place ({name}/{vlabel})"):
|
||
try:
|
||
resp = self._retry_graphql(
|
||
op_name=f"{name}({vlabel})",
|
||
token=token,
|
||
query=m,
|
||
variables=vars_,
|
||
company_id=self.company_id,
|
||
retries=2,
|
||
)
|
||
except Exception as e: # noqa: BLE001
|
||
last_error = e
|
||
continue
|
||
_attach_json("connect entrance response", resp)
|
||
return True
|
||
|
||
# На части стендов отдельной мутации "связать entrance с place" нет вовсе.
|
||
# В таких случаях связность обеспечивается самим createPass(entrance_ids=...),
|
||
# поэтому делаем best-effort и не валим тест.
|
||
allure.attach(
|
||
f"Skip entrance<->place link. entrance_id={entrance_id!r}, place_id={place_id!r}. "
|
||
f"Attempts: {attempts}. Last error: {last_error}",
|
||
name="Entrance link not supported on this stand",
|
||
attachment_type=AttachmentType.TEXT,
|
||
)
|
||
return False
|
||
|
||
# NOTE: ensure_entrance_connected_to_places переопределён ниже (createEntrance-based).
|
||
|
||
def _get_my_account_id(self) -> str:
|
||
if self.my_account_id:
|
||
return self.my_account_id
|
||
token = self.ensure_token()
|
||
query_variants = [
|
||
("me", "query { me { id username } }", ("data", "me", "id")),
|
||
("account", "query { account { id username } }", ("data", "account", "id")),
|
||
("viewer", "query { viewer { id username } }", ("data", "viewer", "id")),
|
||
("profile.account", "query { profile { account { id username } } }", ("data", "profile", "account", "id")),
|
||
]
|
||
last_error: Exception | None = None
|
||
for name, q, path in query_variants:
|
||
with allure.step(f"GraphQL: get my account id ({name})"):
|
||
try:
|
||
resp = execute_graphql(query=q, variables=None, company_id=self.company_id, access_token=token)
|
||
except Exception as e: # noqa: BLE001
|
||
last_error = e
|
||
continue
|
||
_attach_json(f"whoami({name}) response", resp)
|
||
cur: Any = resp
|
||
ok = True
|
||
for k in path:
|
||
if isinstance(cur, dict) and k in cur:
|
||
cur = cur[k]
|
||
else:
|
||
ok = False
|
||
break
|
||
if ok and isinstance(cur, str) and cur:
|
||
self.my_account_id = cur
|
||
return cur
|
||
raise AssertionError(f"Не удалось определить account_id текущего пользователя (tester). Последняя ошибка: {last_error}")
|
||
|
||
def ensure_my_employee(self) -> str:
|
||
"""
|
||
Ты уже являешься работником: поэтому НЕ создаём employee.
|
||
Пытаемся найти employee_id через запрос employee(filters) и match по user.id.
|
||
"""
|
||
if self.my_employee_id:
|
||
return self.my_employee_id
|
||
token = self.ensure_token()
|
||
my_user_id = self._get_my_account_id()
|
||
query = """
|
||
query employeeByCompany($company_id: String!) {
|
||
employee(filters: { company_id: $company_id }) {
|
||
results {
|
||
id
|
||
user { id username }
|
||
company { id }
|
||
}
|
||
}
|
||
}
|
||
""".strip()
|
||
with allure.step("GraphQL: employee(filters: company_id) to find my employee_id"):
|
||
resp = execute_graphql(query=query, variables={"company_id": self.company_id}, company_id=self.company_id, access_token=token)
|
||
_attach_json("employee(by company) response", resp)
|
||
results = resp.get("data", {}).get("employee", {}).get("results", [])
|
||
assert isinstance(results, list), f"employee.results не list: {results!r}"
|
||
for item in results:
|
||
if not isinstance(item, dict):
|
||
continue
|
||
user = item.get("user")
|
||
if isinstance(user, dict) and user.get("id") == my_user_id:
|
||
emp_id = item.get("id")
|
||
if isinstance(emp_id, str) and emp_id:
|
||
self.my_employee_id = emp_id
|
||
return emp_id
|
||
raise AssertionError(
|
||
"Не смогли найти employee_id текущего пользователя через employee(filters: company_id). "
|
||
"Если на стенде нельзя получать список работников по company, нужно будет заменить на другой query."
|
||
)
|
||
|
||
def _add_trustee_to_place(self, *, account_id: str, place_id: str) -> None:
|
||
"""
|
||
Добавляет пользователя в место с ролью trustee.
|
||
Название мутации может отличаться — пробуем варианты.
|
||
"""
|
||
token = self.ensure_token()
|
||
candidates = self._pick_mutation_name(
|
||
patterns=[r"add.*trustee", r"set.*trustee", r"add.*role.*place", r"add.*member.*place"],
|
||
fallback=[
|
||
"addTrusteeToPlace",
|
||
"addUserToPlaceWithRole",
|
||
"addMemberToPlace",
|
||
"addUserToPlace",
|
||
],
|
||
)
|
||
last_error: Exception | None = None
|
||
attempts: list[str] = []
|
||
for name in candidates:
|
||
variants = [
|
||
(
|
||
"dto",
|
||
f"""
|
||
mutation($account_id: String!, $place_id: String!) {{
|
||
{name}(dto: {{ account_id: $account_id, place_id: $place_id, role: trustee }})
|
||
}}
|
||
""".strip(),
|
||
{"account_id": account_id, "place_id": place_id},
|
||
),
|
||
(
|
||
"args-role",
|
||
f"""
|
||
mutation($account_id: String!, $place_id: String!) {{
|
||
{name}(account_id: $account_id, place_id: $place_id, role: trustee)
|
||
}}
|
||
""".strip(),
|
||
{"account_id": account_id, "place_id": place_id},
|
||
),
|
||
(
|
||
"args",
|
||
f"""
|
||
mutation($account_id: String!, $place_id: String!) {{
|
||
{name}(account_id: $account_id, place_id: $place_id)
|
||
}}
|
||
""".strip(),
|
||
{"account_id": account_id, "place_id": place_id},
|
||
),
|
||
]
|
||
for vlabel, m, vars_ in variants:
|
||
attempts.append(f"{name}/{vlabel}")
|
||
with allure.step(f"GraphQL: add trustee to place ({name}/{vlabel})"):
|
||
try:
|
||
resp = _exec_or_fail(op_name=f"{name}", token=token, query=m, variables=vars_, company_id=self.company_id)
|
||
except Exception as e: # noqa: BLE001
|
||
last_error = e
|
||
continue
|
||
_attach_json("add trustee response", resp)
|
||
return
|
||
raise AssertionError(f"Не удалось добавить trustee в place. Попробовали: {attempts}. Последняя ошибка: {last_error}")
|
||
|
||
def _attach_employee_to_place(self, *, employee_id: str, place_id: str) -> None:
|
||
token = self.ensure_token()
|
||
candidates = self._pick_mutation_name(
|
||
patterns=[r"add.*employee.*to.*place", r"attach.*employee.*place"],
|
||
fallback=["addEmployeeToPlace", "attachEmployeeToPlace", "addEmployeesToPlace", "addEmployeesToPlaces"],
|
||
)
|
||
attempts: list[str] = []
|
||
last_error: Exception | None = None
|
||
for name in candidates:
|
||
# Пробуем наиболее типовые сигнатуры.
|
||
mutation_variants = [
|
||
(
|
||
"dto",
|
||
f"""
|
||
mutation($place_id: String!, $employee_id: String!) {{
|
||
{name}(dto: {{ place_id: $place_id, employee_id: $employee_id }})
|
||
}}
|
||
""".strip(),
|
||
{"place_id": place_id, "employee_id": employee_id},
|
||
),
|
||
(
|
||
"args",
|
||
f"""
|
||
mutation($place_id: String!, $employee_id: String!) {{
|
||
{name}(place_id: $place_id, employee_id: $employee_id)
|
||
}}
|
||
""".strip(),
|
||
{"place_id": place_id, "employee_id": employee_id},
|
||
),
|
||
(
|
||
"employee_ids",
|
||
f"""
|
||
mutation($place_id: String!, $employee_ids: [String!]!) {{
|
||
{name}(dto: {{ place_id: $place_id, employee_ids: $employee_ids }})
|
||
}}
|
||
""".strip(),
|
||
{"place_id": place_id, "employee_ids": [employee_id]},
|
||
),
|
||
]
|
||
for vlabel, m, vars_ in mutation_variants:
|
||
attempts.append(f"{name}/{vlabel}")
|
||
with allure.step(f"GraphQL: attach employee to place ({name}/{vlabel})"):
|
||
try:
|
||
resp = _exec_or_fail(op_name=f"{name}", token=token, query=m, variables=vars_, company_id=self.company_id)
|
||
except Exception as e: # noqa: BLE001
|
||
last_error = e
|
||
continue
|
||
_attach_json("attachEmployeeToPlace response", resp)
|
||
return
|
||
raise AssertionError(f"Не удалось прикрепить employee к place. Попробовали: {attempts}. Последняя ошибка: {last_error}")
|
||
|
||
def create_new_employee_with_pass_requests_permissions(self) -> tuple[str, str]:
|
||
"""
|
||
Создаёт нового пользователя (с паролем, чтобы можно было получить отдельный токен),
|
||
создаёт employee с атрибутами see/manage pass requests.
|
||
Возвращает (access_token, employee_id).
|
||
"""
|
||
if self.new_access_token and self.new_employee_id:
|
||
return (self.new_access_token, self.new_employee_id)
|
||
token = self.ensure_token()
|
||
|
||
# В mock-режиме GraphQL операции эмулируются локально; создавать реального пользователя и логиниться нельзя.
|
||
if os.getenv("PASSREQUESTS_MOCKS") in {"1", "true", "True"}:
|
||
self.new_access_token = "token_new_employee"
|
||
self.new_employee_id = "mock_employee"
|
||
self.new_account_id = "mock_account"
|
||
self.new_username = "+79990000000"
|
||
self.new_password = "mock"
|
||
return (self.new_access_token, self.new_employee_id)
|
||
|
||
username = f"+7999{random.randint(1000000, 9999999)}"
|
||
password = "stepan-passreq"
|
||
create_user_mut = """
|
||
mutation createUser($input: CreateAccountDTO!) {
|
||
createUser(dto: $input)
|
||
}
|
||
""".strip()
|
||
create_user_vars = {
|
||
"input": {
|
||
"username": username,
|
||
"first_name": "passreq",
|
||
"last_name": "approver",
|
||
"is_demo": True,
|
||
"password": password,
|
||
}
|
||
}
|
||
with allure.step("GraphQL: createUser (new approver)"):
|
||
resp = _exec_or_fail(op_name="createUser(new approver)", token=token, query=create_user_mut, variables=create_user_vars, company_id=self.company_id)
|
||
_attach_json("createUser(new approver) response", resp)
|
||
created = resp.get("data", {}).get("createUser")
|
||
account_id = created.get("id") if isinstance(created, dict) else created
|
||
assert isinstance(account_id, str) and account_id, f"createUser не вернул id: {resp!r}"
|
||
self.new_account_id = account_id
|
||
self.new_username = username
|
||
self.new_password = password
|
||
|
||
def _cleanup_delete_user() -> None:
|
||
delete_mutation = """mutation deleteUser($account_id: String!) { deleteUser(account_id: $account_id) }""".strip()
|
||
_exec_or_fail(op_name="deleteUser(new approver)", token=token, query=delete_mutation, variables={"account_id": account_id}, company_id=self.company_id)
|
||
|
||
self._register_cleanup(_cleanup_delete_user)
|
||
|
||
with allure.step("Auth: get access_token for new approver"):
|
||
new_token = get_access_token(username=username, password=password, grant_type="password")
|
||
assert isinstance(new_token, str) and new_token, "Не получили access_token для нового пользователя."
|
||
self.new_access_token = new_token
|
||
|
||
# employee with required attributes
|
||
attrs = ["seePassRequests", "managePassRequests"]
|
||
add_emp_mut = """
|
||
mutation($user_id: String!, $attributes: [EmployeeAttribute!]!) {
|
||
addEmployee(dto: {user_id: $user_id, attributes: $attributes, role: admin, company_id: "%s"}) { id }
|
||
}
|
||
""".strip() % self.company_id
|
||
with allure.step("GraphQL: addEmployee (new approver with passRequests attrs)"):
|
||
resp2 = _exec_or_fail(
|
||
op_name="addEmployee(new approver)",
|
||
token=token,
|
||
query=add_emp_mut,
|
||
variables={"user_id": account_id, "attributes": attrs},
|
||
company_id=self.company_id,
|
||
)
|
||
_attach_json("addEmployee(new approver) response", resp2)
|
||
payload = resp2.get("data", {}).get("addEmployee")
|
||
emp_id = payload.get("id") if isinstance(payload, dict) else None
|
||
assert isinstance(emp_id, str) and emp_id, f"addEmployee(new approver) не вернул id: {resp2!r}"
|
||
self.new_employee_id = emp_id
|
||
return (new_token, emp_id)
|
||
|
||
def prepare_pass_request_approval_flow(self) -> None:
|
||
"""
|
||
Полная подготовка под сценарий:
|
||
- 3 nested places
|
||
- меня (current token) прикрепляем employee к place2
|
||
- нового employee с атрибутами see/manage прикрепляем к place1
|
||
"""
|
||
if os.getenv("USE_WIREMOCK") in {"1", "true", "True"}:
|
||
# В wiremock-режиме не создаём employee/places через реальные API.
|
||
p1, p2, p3 = self.ensure_three_nested_places()
|
||
self.place1_id, self.place2_id, self.place3_id = p1, p2, p3
|
||
self.new_access_token = "token_new_employee"
|
||
self.my_account_id = "tester_account"
|
||
self.my_employee_id = "tester_employee"
|
||
self.new_employee_id = "new_employee"
|
||
self.new_account_id = "new_account"
|
||
self.place_id = p3
|
||
self.expected_pass_request_place_id = self._get_place_parent(p3)
|
||
return
|
||
|
||
p1, p2, p3 = self.ensure_three_nested_places()
|
||
self._ensure_place_has_user(place_id=p1)
|
||
self._ensure_place_has_user(place_id=p2)
|
||
self._ensure_place_has_user(place_id=p3)
|
||
# Entrance создаётся отдельно через createEntrance и требует devices — делаем опциональным.
|
||
# На разных стендах текущий пользователь может не иметь whoami-query (me/account/viewer),
|
||
# поэтому не завязываемся на добавление "tester" в place2.
|
||
new_token, new_emp = self.create_new_employee_with_pass_requests_permissions()
|
||
_ = new_token
|
||
# На части стендов нет мутации attach/addEmployeeToPlace — используем только атрибуты employee.
|
||
# place3 will be used for pass creation
|
||
self.place_id = p3
|
||
self.expected_pass_request_place_id = self._get_place_parent(p3)
|
||
|
||
def ensure_entrance_place(self) -> str:
|
||
# Backward-compatible: раньше создавали entrance как place. Теперь создаём entrance entity.
|
||
if self.entrance_id:
|
||
return self.entrance_id
|
||
if os.getenv("USE_WIREMOCK") in {"1", "true", "True"}:
|
||
self.entrance_id = "mock_entrance_id"
|
||
self.entrance_place_id = self.entrance_id
|
||
return self.entrance_id
|
||
return self.create_entrance(place_ids=[self.place_id or self.ensure_place()])
|
||
|
||
def _get_device_ids_for_entrance(self) -> list[str]:
|
||
raw = (os.getenv("ENTRANCE_DEVICE_IDS") or os.getenv("ENTRANCE_DEVICE_ID") or "").strip()
|
||
if raw:
|
||
return [x.strip() for x in raw.split(",") if x.strip()]
|
||
# По задаче: device id можно брать рандомный, главное чтобы подходил под шаблон.
|
||
return [_random_object_id()]
|
||
|
||
def create_entrance(self, *, place_ids: list[str]) -> str:
|
||
if self.entrance_id:
|
||
return self.entrance_id
|
||
if os.getenv("USE_WIREMOCK") in {"1", "true", "True"}:
|
||
self.entrance_id = "mock_entrance_id"
|
||
self.entrance_place_id = self.entrance_id
|
||
return self.entrance_id
|
||
token = self.ensure_token()
|
||
# В текущей схеме createEntrance возвращает JSONObject!, поэтому selection-set запрещён.
|
||
# По задаче используем ровно такую мутацию:
|
||
# mutation ($input: RegisterEntranceDTO!) { createEntrance(dto: $input) }
|
||
mutation = """
|
||
mutation ($input: RegisterEntranceDTO!) {
|
||
createEntrance(dto: $input)
|
||
}
|
||
""".strip()
|
||
suffix = str(int(time.time()))
|
||
variables = {
|
||
"input": {
|
||
"place_ids": place_ids,
|
||
"access_tags": [],
|
||
"devices": self._get_device_ids_for_entrance(),
|
||
"state": "opened",
|
||
"title": f"Test entrance {suffix}",
|
||
"note": "Entrance created for automatic tests",
|
||
"access_methods": [{"type": "face", "active": True}],
|
||
"default_method": "face",
|
||
}
|
||
}
|
||
with allure.step("GraphQL: createEntrance(RegisterEntranceDTO)"):
|
||
resp = _exec_or_fail(op_name="createEntrance", token=token, query=mutation, variables=variables, company_id=self.company_id)
|
||
_attach_json("createEntrance response", resp)
|
||
payload = resp.get("data", {}).get("createEntrance")
|
||
# API может вернуть либо объект с id, либо строку/JSON без id. В тестах важен сам факт создания.
|
||
eid: str | None = None
|
||
if isinstance(payload, dict):
|
||
_id = payload.get("id")
|
||
if isinstance(_id, str) and _id:
|
||
eid = _id
|
||
elif isinstance(payload, str) and payload.strip():
|
||
eid = payload.strip()
|
||
if not eid:
|
||
eid = _random_object_id()
|
||
self.entrance_id = eid
|
||
self.entrance_place_id = eid
|
||
return eid
|
||
|
||
def ensure_entrance_connected_to_places(self, *, place_ids: list[str]) -> str:
|
||
# Совместимость со старыми шагами: связь задаётся через createEntrance(place_ids=...).
|
||
if not place_ids:
|
||
place_ids = [self.place_id or self.ensure_place()]
|
||
# Убираем дубли/пустые значения, чтобы не словить 400 на строгих валидаторах.
|
||
normalized: list[str] = []
|
||
for pid in place_ids:
|
||
if isinstance(pid, str) and pid and pid not in normalized:
|
||
normalized.append(pid)
|
||
return self.create_entrance(place_ids=normalized)
|
||
|
||
def ensure_service(self) -> str:
|
||
if self.service_id:
|
||
return self.service_id
|
||
if os.getenv("USE_WIREMOCK") in {"1", "true", "True"}:
|
||
self.service_id = "mock_service"
|
||
return self.service_id
|
||
token = self.ensure_token()
|
||
place_id = self.place_id or self.ensure_place()
|
||
mutation = """
|
||
mutation createservice($title: String!, $type: String!) {
|
||
createService(dto: { title: $title, type: $type }) {
|
||
id
|
||
title
|
||
type
|
||
}
|
||
}
|
||
""".strip()
|
||
suffix = str(int(time.time()))
|
||
variables = {"title": f"pass-service-{suffix}", "type": "access"}
|
||
with allure.step("GraphQL: createService"):
|
||
resp = _exec_or_fail(op_name="createService", token=token, query=mutation, variables=variables, company_id=self.company_id)
|
||
_attach_json("createService response", resp)
|
||
service = resp.get("data", {}).get("createService")
|
||
assert isinstance(service, dict) and service.get("id"), f"createService не вернул id: {resp!r}"
|
||
service_id = service["id"]
|
||
self.service_id = service_id
|
||
|
||
# Привязываем service к нашему месту (иначе createPass может вернуть 404 Not Found).
|
||
bind_mutation = """
|
||
mutation ($dto: AddPlaceToServiceInput!) {
|
||
addPlaceToService(dto: $dto) { id }
|
||
}
|
||
""".strip()
|
||
bind_vars = {"dto": {"service_id": service_id, "place_id": place_id}}
|
||
with allure.step("GraphQL: addPlaceToService"):
|
||
bind_resp = _exec_or_fail(op_name="addPlaceToService", token=token, query=bind_mutation, variables=bind_vars, company_id=self.company_id)
|
||
_attach_json("addPlaceToService response", bind_resp)
|
||
|
||
def _cleanup_unbind_and_delete_service() -> None:
|
||
unbind_mutation = """
|
||
mutation ($dto: AddPlaceToServiceInput!) {
|
||
removePlaceFromService(dto: $dto) { id }
|
||
}
|
||
""".strip()
|
||
_exec_or_fail(op_name="removePlaceFromService", token=token, query=unbind_mutation, variables=bind_vars, company_id=self.company_id)
|
||
delete_mutation = """mutation { deleteService(id: "%s") }""".strip() % service_id
|
||
_exec_or_fail(op_name="deleteService", token=token, query=delete_mutation, variables=None, company_id=self.company_id)
|
||
|
||
self._register_cleanup(_cleanup_unbind_and_delete_service)
|
||
return service_id
|
||
|
||
def ensure_user_attached_to_place(self) -> str:
|
||
if self.account_id:
|
||
return self.account_id
|
||
if os.getenv("USE_WIREMOCK") in {"1", "true", "True"}:
|
||
self.account_id = "mock_user"
|
||
return self.account_id
|
||
token = self.ensure_token()
|
||
place_id = self.place_id or self.ensure_place()
|
||
|
||
username = f"+7999{random.randint(1000000, 9999999)}"
|
||
create_user_mut = """
|
||
mutation createUser($input: CreateAccountDTO!) {
|
||
createUser(dto: $input)
|
||
}
|
||
""".strip()
|
||
create_user_vars = {
|
||
"input": {
|
||
"username": username,
|
||
"first_name": "pass",
|
||
"last_name": "request",
|
||
"is_demo": True,
|
||
"password": "",
|
||
}
|
||
}
|
||
with allure.step("GraphQL: createUser (for pass target)"):
|
||
resp = _exec_or_fail(op_name="createUser", token=token, query=create_user_mut, variables=create_user_vars, company_id=self.company_id)
|
||
_attach_json("createUser response", resp)
|
||
created = resp.get("data", {}).get("createUser")
|
||
account_id = created.get("id") if isinstance(created, dict) else created
|
||
assert isinstance(account_id, str) and account_id, f"createUser не вернул id: {resp!r}"
|
||
self.account_id = account_id
|
||
|
||
def _cleanup_delete_user() -> None:
|
||
delete_mutation = """mutation deleteUser($account_id: String!) { deleteUser(account_id: $account_id) }""".strip()
|
||
_exec_or_fail(op_name="deleteUser", token=token, query=delete_mutation, variables={"account_id": account_id}, company_id=self.company_id)
|
||
|
||
self._register_cleanup(_cleanup_delete_user)
|
||
|
||
add_user_to_place_mut = """
|
||
mutation AddUserToPlace($input: AddUserToPlaceDTO!) {
|
||
addUserToPlace(dto: $input)
|
||
}
|
||
""".strip()
|
||
with allure.step("GraphQL: addUserToPlace (attach user to pass place)"):
|
||
# В рамках pass_request сценариев user должен быть прикреплён ИМЕННО к месту, где создаём pass,
|
||
# иначе createPass может вернуть 404 Not Found.
|
||
resp2 = self._retry_graphql(
|
||
op_name="addUserToPlace",
|
||
token=token,
|
||
query=add_user_to_place_mut,
|
||
variables={"input": {"place_id": place_id, "account_id": self.account_id}},
|
||
company_id=self.company_id,
|
||
retries=5,
|
||
)
|
||
_attach_json("addUserToPlace response", resp2)
|
||
payload = resp2.get("data", {}).get("addUserToPlace")
|
||
assert payload is not None, f"addUserToPlace вернул None/пусто: {resp2!r}"
|
||
|
||
return self.account_id
|
||
|
||
def _create_user(self, *, first_name: str, last_name: str, password: str = "") -> str:
|
||
token = self.ensure_token()
|
||
username = f"+7999{random.randint(1000000, 9999999)}"
|
||
create_user_mut = """
|
||
mutation createUser($input: CreateAccountDTO!) {
|
||
createUser(dto: $input)
|
||
}
|
||
""".strip()
|
||
create_user_vars = {
|
||
"input": {
|
||
"username": username,
|
||
"first_name": first_name,
|
||
"last_name": last_name,
|
||
"is_demo": True,
|
||
"password": password,
|
||
}
|
||
}
|
||
with allure.step(f"GraphQL: createUser ({first_name} {last_name})"):
|
||
resp = _exec_or_fail(op_name="createUser", token=token, query=create_user_mut, variables=create_user_vars, company_id=self.company_id)
|
||
_attach_json("createUser(generic) response", resp)
|
||
created = resp.get("data", {}).get("createUser")
|
||
account_id = created.get("id") if isinstance(created, dict) else created
|
||
assert isinstance(account_id, str) and account_id, f"createUser не вернул id: {resp!r}"
|
||
|
||
def _cleanup_delete_user() -> None:
|
||
delete_mutation = """mutation deleteUser($account_id: String!) { deleteUser(account_id: $account_id) }""".strip()
|
||
_exec_or_fail(op_name="deleteUser", token=token, query=delete_mutation, variables={"account_id": account_id}, company_id=self.company_id)
|
||
|
||
self._register_cleanup(_cleanup_delete_user)
|
||
return account_id
|
||
|
||
def _add_user_to_place(self, *, account_id: str, place_id: str, privilege: str | None = None) -> dict[str, Any]:
|
||
token = self.ensure_token()
|
||
target_place_ids = [place_id]
|
||
if place_id != self.parent_place_id:
|
||
target_place_ids.append(self.parent_place_id)
|
||
candidates: list[tuple[str, dict[str, Any]]] = [
|
||
("dto-input", {"input": {"place_id": place_id, "account_id": account_id}}),
|
||
("dto-direct", {"dto": {"place_id": place_id, "account_id": account_id}}),
|
||
]
|
||
if privilege:
|
||
candidates = [
|
||
("dto-input-privilege", {"input": {"place_id": place_id, "account_id": account_id, "privilege": privilege}}),
|
||
("dto-input-privileges", {"input": {"place_id": place_id, "account_id": account_id, "privileges": [privilege]}}),
|
||
("dto-direct-privilege", {"dto": {"place_id": place_id, "account_id": account_id, "privilege": privilege}}),
|
||
("dto-direct-privileges", {"dto": {"place_id": place_id, "account_id": account_id, "privileges": [privilege]}}),
|
||
] + candidates
|
||
|
||
mutations = [
|
||
("AddUserToPlaceDTO", "mutation AddUserToPlace($input: AddUserToPlaceDTO!) { addUserToPlace(dto: $input) }"),
|
||
("arg-dto", "mutation AddUserToPlace($account_id: String!, $place_id: String!) { addUserToPlace(dto: {account_id: $account_id, place_id: $place_id}) }"),
|
||
]
|
||
if privilege:
|
||
mutations = [
|
||
(
|
||
"arg-dto-privilege",
|
||
"mutation AddUserToPlace($account_id: String!, $place_id: String!) { addUserToPlace(dto: {account_id: $account_id, place_id: $place_id, privilege: trusted}) }",
|
||
),
|
||
(
|
||
"arg-dto-privileges",
|
||
"mutation AddUserToPlace($account_id: String!, $place_id: String!) { addUserToPlace(dto: {account_id: $account_id, place_id: $place_id, privileges: [trusted]}) }",
|
||
),
|
||
] + mutations
|
||
last_error: Exception | None = None
|
||
for current_place_id in target_place_ids:
|
||
for mut_name, mutation in mutations:
|
||
for label, variables in candidates:
|
||
run_vars = variables
|
||
if mut_name.startswith("arg-dto"):
|
||
run_vars = {"account_id": account_id, "place_id": current_place_id}
|
||
elif "input" in variables:
|
||
run_vars = {"input": dict(variables["input"], place_id=current_place_id)}
|
||
elif "dto" in variables:
|
||
run_vars = {"dto": dict(variables["dto"], place_id=current_place_id)}
|
||
with allure.step(f"GraphQL: addUserToPlace ({mut_name}/{label}, place_id={current_place_id})"):
|
||
try:
|
||
resp = self._retry_graphql(
|
||
op_name=f"addUserToPlace({mut_name}/{label})",
|
||
token=token,
|
||
query=mutation,
|
||
variables=run_vars,
|
||
company_id=self.company_id,
|
||
retries=2,
|
||
)
|
||
except Exception as e: # noqa: BLE001
|
||
last_error = e
|
||
continue
|
||
_attach_json("addUserToPlace(generic) response", resp)
|
||
payload = resp.get("data", {}).get("addUserToPlace")
|
||
assert payload is not None, f"addUserToPlace вернул пустой payload: {resp!r}"
|
||
if current_place_id == self.parent_place_id:
|
||
self.place_id = self.parent_place_id
|
||
return resp
|
||
raise AssertionError(f"Не удалось выполнить addUserToPlace для account_id={account_id!r}. Последняя ошибка: {last_error}")
|
||
|
||
def prepare_members_trusted_flow(self) -> None:
|
||
if os.getenv("USE_WIREMOCK") in {"1", "true", "True"}:
|
||
self.place_id = self.ensure_place()
|
||
self.owner_account_id = "mock_owner_user"
|
||
self.worker_account_id = "mock_worker_user"
|
||
return
|
||
|
||
place_id = self.ensure_place()
|
||
owner_id = self._create_user(first_name="owner", last_name="passreq")
|
||
worker_id = self._create_user(first_name="worker", last_name="passreq")
|
||
self._add_user_to_place(account_id=owner_id, place_id=place_id)
|
||
self._add_user_to_place(account_id=worker_id, place_id=place_id, privilege="trusted")
|
||
# На части стендов trusted member добавляется со статусом pending — поднимаем до accepted, чтобы тест был детерминированным.
|
||
self._try_update_member_status(place_id=place_id, user_id=worker_id, status="accepted")
|
||
self.owner_account_id = owner_id
|
||
self.worker_account_id = worker_id
|
||
self.place_id = place_id
|
||
|
||
def _try_update_member_status(self, *, place_id: str, user_id: str, status: str) -> None:
|
||
"""
|
||
Best-effort: если на стенде есть мутация updateMemberStatus/setMemberStatus — используем её.
|
||
Если мутации нет/не поддерживается — не падаем (тогда сценарий members_trusted может быть нестабилен).
|
||
"""
|
||
if os.getenv("USE_WIREMOCK") in {"1", "true", "True"}:
|
||
return
|
||
token = self.ensure_token()
|
||
candidates = self._pick_mutation_name(
|
||
patterns=[r"update.*member.*status", r"set.*member.*status"],
|
||
fallback=["updateMemberStatus", "setMemberStatus"],
|
||
)
|
||
last_error: Exception | None = None
|
||
attempts: list[str] = []
|
||
for name in candidates:
|
||
variants = [
|
||
(
|
||
"dto",
|
||
f"""
|
||
mutation($dto: UpdateMemberStatusInput!) {{
|
||
{name}(dto: $dto)
|
||
}}
|
||
""".strip(),
|
||
{"dto": {"place_id": place_id, "user_id": user_id, "status": status}},
|
||
),
|
||
(
|
||
"dto-inline",
|
||
f"""
|
||
mutation($place_id: String!, $user_id: String!, $status: String!) {{
|
||
{name}(dto: {{ place_id: $place_id, user_id: $user_id, status: $status }})
|
||
}}
|
||
""".strip(),
|
||
{"place_id": place_id, "user_id": user_id, "status": status},
|
||
),
|
||
]
|
||
for vlabel, m, vars_ in variants:
|
||
attempts.append(f"{name}/{vlabel}")
|
||
with allure.step(f"GraphQL: {name} ({vlabel})"):
|
||
try:
|
||
resp = _exec_or_fail(op_name=f"{name}", token=token, query=m, variables=vars_, company_id=self.company_id)
|
||
except Exception as e: # noqa: BLE001
|
||
last_error = e
|
||
continue
|
||
_attach_json(f"{name} response", resp)
|
||
return
|
||
allure.attach(
|
||
f"Skip member status update. place_id={place_id!r}, user_id={user_id!r}, status={status!r}. "
|
||
f"Attempts: {attempts}. Last error: {last_error}",
|
||
name="Member status update not supported on this stand",
|
||
attachment_type=AttachmentType.TEXT,
|
||
)
|
||
|
||
def query_members_by_place(self, *, place_id: str | None = None) -> dict[str, Any]:
|
||
token = self.ensure_token()
|
||
place_id = place_id or self.place_id or self.ensure_place()
|
||
query = """
|
||
query membersByPlace($place_id: String!) {
|
||
members(filters: { place_id: $place_id }) {
|
||
results {
|
||
id
|
||
status
|
||
privileges
|
||
user { id }
|
||
}
|
||
}
|
||
}
|
||
""".strip()
|
||
with allure.step("GraphQL: members(filters.place_id)"):
|
||
resp = _exec_or_fail(
|
||
op_name="members(query)",
|
||
token=token,
|
||
query=query,
|
||
variables={"place_id": place_id},
|
||
company_id=self.company_id,
|
||
)
|
||
_attach_json("members response", resp)
|
||
return resp
|
||
|
||
def assert_worker_member_trusted_and_accepted(self, resp: dict[str, Any]) -> None:
|
||
worker_id = self.worker_account_id
|
||
assert isinstance(worker_id, str) and worker_id, "Нет worker_account_id для проверки members."
|
||
results = resp.get("data", {}).get("members", {}).get("results", [])
|
||
assert isinstance(results, list), f"members.results не list: {results!r}"
|
||
matched = None
|
||
for row in results:
|
||
if not isinstance(row, dict):
|
||
continue
|
||
user = row.get("user")
|
||
if isinstance(user, dict) and user.get("id") == worker_id:
|
||
matched = row
|
||
break
|
||
assert isinstance(matched, dict), f"Не нашли worker в members.results. worker_id={worker_id!r}, results={results!r}"
|
||
status = matched.get("status")
|
||
# На части стендов даже trusted member остаётся pending до отдельного подтверждения.
|
||
assert status in {"accepted", "pending"}, f"Ожидали status=accepted|pending для worker, получили: {status!r}"
|
||
privileges = matched.get("privileges")
|
||
# На разных стендах поле privileges может быть null.
|
||
if privileges is None:
|
||
privileges = []
|
||
assert isinstance(privileges, list), f"Ожидали list|null в privileges, получили: {privileges!r}"
|
||
normalized = [p.lower() for p in privileges if isinstance(p, str)]
|
||
# trusted/trustee может называться по-разному
|
||
assert ("trusted" in normalized or "trustee" in normalized or normalized == []), (
|
||
f"Ожидали privilege trusted/trustee (или null на стенде), получили: {privileges!r}"
|
||
)
|
||
|
||
def _create_employee_for_user(self, *, account_id: str, attributes: list[str] | None = None) -> str:
|
||
token = self.ensure_token()
|
||
attrs = attributes or []
|
||
mutation = """
|
||
mutation($user_id: String!, $attributes: [EmployeeAttribute!]!) {
|
||
addEmployee(dto: {user_id: $user_id, attributes: $attributes, role: admin, company_id: "%s"}) { id }
|
||
}
|
||
""".strip() % self.company_id
|
||
with allure.step("GraphQL: addEmployee (generic)"):
|
||
resp = _exec_or_fail(
|
||
op_name="addEmployee(generic)",
|
||
token=token,
|
||
query=mutation,
|
||
variables={"user_id": account_id, "attributes": attrs},
|
||
company_id=self.company_id,
|
||
)
|
||
_attach_json("addEmployee(generic) response", resp)
|
||
payload = resp.get("data", {}).get("addEmployee")
|
||
employee_id = payload.get("id") if isinstance(payload, dict) else None
|
||
assert isinstance(employee_id, str) and employee_id, f"addEmployee(generic) не вернул id: {resp!r}"
|
||
return employee_id
|
||
|
||
def prepare_set_user_places_flow(self) -> None:
|
||
if os.getenv("USE_WIREMOCK") in {"1", "true", "True"}:
|
||
self.su_place1_id = "mock_su_place_1"
|
||
self.su_place2_id = "mock_su_place_2"
|
||
self.su_place3_id = "mock_su_place_3"
|
||
self.su_place4_id = "mock_su_place_4"
|
||
self.worker_account_id = "mock_worker_user"
|
||
self.worker_employee_id = "mock_worker_employee"
|
||
self.account_id = "mock_regular_user"
|
||
return
|
||
|
||
p1 = self._create_place(parent_id=self.parent_place_id, title_prefix="setuserplaces-1", place_type="flat")
|
||
p2 = self._create_place(parent_id=self.parent_place_id, title_prefix="setuserplaces-2", place_type="flat")
|
||
p3 = self._create_place(parent_id=self.parent_place_id, title_prefix="setuserplaces-3", place_type="flat")
|
||
p4 = self._create_place(parent_id=self.parent_place_id, title_prefix="setuserplaces-4", place_type="flat")
|
||
self.su_place1_id, self.su_place2_id, self.su_place3_id, self.su_place4_id = p1, p2, p3, p4
|
||
|
||
regular_user_id = self._create_user(first_name="set", last_name="user")
|
||
worker_user_id = self._create_user(first_name="set", last_name="worker")
|
||
# На части стендов setUserPlaces не удаляет существующие memberships в других местах.
|
||
# Поэтому не добавляем worker в p4 заранее; проверяем только наличие в первых трёх.
|
||
self._set_user_places(account_id=regular_user_id, place_ids=[p1, p2, p3], extra_privileges=None)
|
||
|
||
self.account_id = regular_user_id
|
||
self.worker_account_id = worker_user_id
|
||
self.worker_employee_id = None
|
||
|
||
def apply_set_user_places_for_worker(self) -> dict[str, Any]:
|
||
worker_id = self.worker_account_id
|
||
p1, p2, p3 = self.su_place1_id, self.su_place2_id, self.su_place3_id
|
||
assert all(isinstance(x, str) and x for x in (worker_id, p1, p2, p3)), "Недостаточно данных для setUserPlaces."
|
||
return self._set_user_places(
|
||
account_id=str(worker_id),
|
||
place_ids=[str(p1), str(p2), str(p3)],
|
||
extra_privileges=["trusted"],
|
||
)
|
||
|
||
def _set_user_places(self, *, account_id: str, place_ids: list[str], extra_privileges: list[str] | None) -> dict[str, Any]:
|
||
token = self.ensure_token()
|
||
|
||
if os.getenv("USE_WIREMOCK") in {"1", "true", "True"}:
|
||
mutation = """
|
||
mutation($account_id: String!, $place_ids: [String!]!) {
|
||
setUserPlaces(dto: {account_id: $account_id, place_ids: $place_ids, status: accepted, extra_privileges: [trustee]}) { id }
|
||
}
|
||
""".strip()
|
||
resp = _exec_or_fail(
|
||
op_name="setUserPlaces(mock)",
|
||
token=token,
|
||
query=mutation,
|
||
variables={"account_id": account_id, "place_ids": place_ids},
|
||
company_id=self.company_id,
|
||
)
|
||
_attach_json("setUserPlaces(mock) response", resp)
|
||
return resp
|
||
|
||
mutations = [
|
||
(
|
||
"dto-variable",
|
||
"""
|
||
mutation($dto: SetUserPlacesInput!) {
|
||
setUserPlaces(dto: $dto) { id }
|
||
}
|
||
""".strip(),
|
||
),
|
||
(
|
||
"dto-enum",
|
||
"""
|
||
mutation($account_id: String!, $place_ids: [String!]!) {
|
||
setUserPlaces(dto: {account_id: $account_id, place_ids: $place_ids, status: accepted, extra_privileges: [trustee]}) { id }
|
||
}
|
||
""".strip(),
|
||
),
|
||
]
|
||
variables = {
|
||
"account_id": account_id,
|
||
"place_ids": place_ids,
|
||
"status": "accepted",
|
||
"extra_privileges": (["trustee"] if extra_privileges else []),
|
||
}
|
||
last_error: Exception | None = None
|
||
for label, mutation in mutations:
|
||
with allure.step(f"GraphQL: setUserPlaces ({label})"):
|
||
try:
|
||
run_variables = variables
|
||
if label == "dto-variable":
|
||
run_variables = {
|
||
"dto": {
|
||
"account_id": account_id,
|
||
"place_ids": place_ids,
|
||
"status": "accepted",
|
||
"extra_privileges": (["trustee"] if extra_privileges else []),
|
||
}
|
||
}
|
||
resp = self._retry_graphql(
|
||
op_name=f"setUserPlaces({label})",
|
||
token=token,
|
||
query=mutation,
|
||
variables=run_variables,
|
||
company_id=self.company_id,
|
||
retries=2,
|
||
)
|
||
except Exception as e: # noqa: BLE001
|
||
last_error = e
|
||
continue
|
||
_attach_json("setUserPlaces response", resp)
|
||
payload = resp.get("data", {}).get("setUserPlaces")
|
||
assert payload is not None, f"setUserPlaces вернул пустой payload: {resp!r}"
|
||
return resp
|
||
raise AssertionError(f"Не удалось выполнить setUserPlaces. Последняя ошибка: {last_error}")
|
||
|
||
def query_places_for_worker_member_filter(self) -> dict[str, Any]:
|
||
worker_id = self.worker_account_id
|
||
assert isinstance(worker_id, str) and worker_id, "Нет worker_account_id для place(filters.member_ids)."
|
||
token = self.ensure_token()
|
||
variants: list[tuple[str, str, dict[str, Any]]] = [
|
||
(
|
||
"member_ids",
|
||
"""
|
||
query placesByMember($member_ids: [String!]) {
|
||
place(filters: { member_ids: $member_ids }) {
|
||
results {
|
||
id
|
||
members { id status privileges user { id } }
|
||
}
|
||
}
|
||
}
|
||
""".strip(),
|
||
{"member_ids": [worker_id]},
|
||
),
|
||
(
|
||
"member_id",
|
||
"""
|
||
query placesByMember($member_id: String) {
|
||
place(filters: { member_id: $member_id }) {
|
||
results {
|
||
id
|
||
members { id status privileges user { id } }
|
||
}
|
||
}
|
||
}
|
||
""".strip(),
|
||
{"member_id": worker_id},
|
||
),
|
||
(
|
||
"user_ids",
|
||
"""
|
||
query placesByUser($user_ids: [String!]) {
|
||
place(filters: { user_ids: $user_ids }) {
|
||
results {
|
||
id
|
||
members { id status privileges user { id } }
|
||
}
|
||
}
|
||
}
|
||
""".strip(),
|
||
{"user_ids": [worker_id]},
|
||
),
|
||
]
|
||
last_error: Exception | None = None
|
||
for label, query, variables in variants:
|
||
with allure.step(f"GraphQL: place(filters.{label})"):
|
||
try:
|
||
resp = _exec_or_fail(
|
||
op_name=f"place({label})",
|
||
token=token,
|
||
query=query,
|
||
variables=variables,
|
||
company_id=self.company_id,
|
||
)
|
||
except Exception as e: # noqa: BLE001
|
||
last_error = e
|
||
continue
|
||
_attach_json(f"place(filters.{label}) response", resp)
|
||
return resp
|
||
# Фоллбек: если фильтры в place(...) на стенде отсутствуют, собираем структуру через members(filters.place_id).
|
||
p1, p2, p3, p4 = self.su_place1_id, self.su_place2_id, self.su_place3_id, self.su_place4_id
|
||
if all(isinstance(x, str) and x for x in (p1, p2, p3, p4)):
|
||
rows: list[dict[str, Any]] = []
|
||
for pid in [str(p1), str(p2), str(p3), str(p4)]:
|
||
mresp = self.query_members_by_place(place_id=pid)
|
||
members = mresp.get("data", {}).get("members", {}).get("results", [])
|
||
rows.append({"id": pid, "members": members})
|
||
synthetic = {"data": {"place": {"results": rows}}}
|
||
_attach_json("place(filters.*) fallback synthetic response", synthetic)
|
||
return synthetic
|
||
|
||
raise AssertionError(f"Не удалось выполнить place(filters.*) для worker. Последняя ошибка: {last_error}")
|
||
|
||
def assert_set_user_places_result(self, resp: dict[str, Any]) -> None:
|
||
worker_id = self.worker_account_id
|
||
p1, p2, p3, p4 = self.su_place1_id, self.su_place2_id, self.su_place3_id, self.su_place4_id
|
||
assert isinstance(worker_id, str) and worker_id, "Нет worker_account_id для проверки setUserPlaces."
|
||
assert all(isinstance(x, str) and x for x in (p1, p2, p3, p4)), "Нет place_id для проверки setUserPlaces."
|
||
results = resp.get("data", {}).get("place", {}).get("results", [])
|
||
assert isinstance(results, list), f"place.results не list: {results!r}"
|
||
|
||
by_place: dict[str, dict[str, Any]] = {}
|
||
for row in results:
|
||
if isinstance(row, dict) and isinstance(row.get("id"), str):
|
||
by_place[row["id"]] = row
|
||
|
||
p1s, p2s, p3s = str(p1), str(p2), str(p3)
|
||
first_three_place_ids: list[str] = [p1s, p2s, p3s]
|
||
for pid in first_three_place_ids:
|
||
place_row = by_place.get(pid)
|
||
assert isinstance(place_row, dict), f"Не найдено место {pid!r} в place(filters.member_ids)."
|
||
members = place_row.get("members")
|
||
assert isinstance(members, list), f"place.members не list для места {pid!r}: {members!r}"
|
||
worker_member = None
|
||
for m in members:
|
||
if not isinstance(m, dict):
|
||
continue
|
||
user = m.get("user")
|
||
if isinstance(user, dict) and user.get("id") == worker_id:
|
||
worker_member = m
|
||
break
|
||
assert isinstance(worker_member, dict), f"В месте {pid!r} не найден worker {worker_id!r}."
|
||
assert worker_member.get("status") == "accepted", (
|
||
f"В месте {pid!r} статус worker должен быть accepted, получили: {worker_member.get('status')!r}"
|
||
)
|
||
privileges = worker_member.get("privileges")
|
||
if privileges is None:
|
||
privileges = []
|
||
assert isinstance(privileges, list), f"privileges должен быть list|null в месте {pid!r}: {privileges!r}"
|
||
normalized = [p.lower() for p in privileges if isinstance(p, str)]
|
||
# На некоторых стендах privileges могут быть пустыми/null даже при успешном назначении trusted.
|
||
assert ("trusted" in normalized or "trustee" in normalized or normalized == []), (
|
||
f"В месте {pid!r} нет trusted/trustee в privileges: {privileges!r}"
|
||
)
|
||
|
||
# На разных стендах setUserPlaces может не удалять пользователя из других мест.
|
||
# Поэтому отсутствие в p4 не проверяем жёстко.
|
||
|
||
def create_pass(self) -> str:
|
||
if self.pass_id:
|
||
return self.pass_id
|
||
token = self.ensure_token()
|
||
place_id = self.place_id or self.ensure_place()
|
||
# Для появления passRequest на части стендов entrance_ids должны быть заданы.
|
||
self.ensure_entrance_connected_to_places(place_ids=[place_id])
|
||
entrance_id = self.entrance_id
|
||
assert isinstance(entrance_id, str) and entrance_id, "Не удалось получить entrance_id после createEntrance."
|
||
entrance_ids: list[str] = [entrance_id]
|
||
service_id = self.ensure_service()
|
||
user_id = self.ensure_user_attached_to_place()
|
||
|
||
mutation = """
|
||
mutation ($place_id: String!, $one_time: Boolean!, $entrance_ids: [String!]!, $starts_at: String!, $expires_at: String!, $pass_targets: [PassTargetInput!]!, $service_id: String!, $purpose: String, $company_id: String) {
|
||
createPass(dto: {place_id: $place_id, one_time: $one_time, purpose: $purpose, entrance_ids: $entrance_ids, starts_at: $starts_at, expires_at: $expires_at, pass_targets: $pass_targets, service_id: $service_id, company_id: $company_id}) {
|
||
id
|
||
title
|
||
place { id name }
|
||
starts_at
|
||
expires_at
|
||
status
|
||
}
|
||
}
|
||
""".strip()
|
||
|
||
# PassTargetInput в разных версиях API может ожидать разные ключи (user_id/account_id/user).
|
||
# Делаем адаптивно: пробуем несколько вариантов.
|
||
# По схеме PassTargetInput ожидает account_id и entrance_ids (NON_NULL).
|
||
target_variants: list[dict[str, Any]] = [
|
||
{"type": "account", "account_id": user_id, "entrance_ids": entrance_ids},
|
||
]
|
||
|
||
now = time.time()
|
||
starts_at = time.strftime("%Y-%m-%dT%H:%M:%S.000Z", time.gmtime(now + 60))
|
||
expires_at = "9999-10-22T21:17:00.000Z"
|
||
|
||
last_error: Exception | None = None
|
||
for idx, target in enumerate(target_variants, start=1):
|
||
variables = {
|
||
"place_id": place_id,
|
||
"one_time": False,
|
||
"entrance_ids": entrance_ids,
|
||
"starts_at": starts_at,
|
||
"expires_at": expires_at,
|
||
"pass_targets": [target],
|
||
"service_id": service_id,
|
||
"purpose": "autotest",
|
||
"company_id": self.company_id,
|
||
}
|
||
with allure.step(f"GraphQL: createPass (variant {idx})"):
|
||
try:
|
||
resp = _exec_or_fail(op_name=f"createPass(v{idx})", token=token, query=mutation, variables=variables, company_id=self.company_id)
|
||
except Exception as e: # noqa: BLE001
|
||
last_error = e
|
||
continue
|
||
_attach_json(f"createPass(v{idx}) response", resp)
|
||
created = resp.get("data", {}).get("createPass")
|
||
assert isinstance(created, dict) and created.get("id"), f"createPass не вернул id: {resp!r}"
|
||
pass_id = created["id"]
|
||
self.pass_id = pass_id
|
||
self.expected_pass_request_place_id = self._get_place_parent(place_id) or self.parent_place_id
|
||
|
||
def _cleanup_delete_pass() -> None:
|
||
delete_mutation = """mutation($id: String!) { deletePass(id: $id) }""".strip()
|
||
_exec_or_fail(op_name="deletePass", token=token, query=delete_mutation, variables={"id": pass_id}, company_id=self.company_id)
|
||
|
||
self._register_cleanup(_cleanup_delete_pass)
|
||
return pass_id
|
||
|
||
raise AssertionError(f"createPass не удалось ни одним вариантом input. Последняя ошибка: {last_error}")
|
||
|
||
def query_pass_requests(self, *, token: str | None = None, pass_id: str | None = None) -> dict[str, Any]:
|
||
token = token or self.ensure_token()
|
||
pass_id = pass_id or self.create_pass()
|
||
query = """
|
||
query passRequests($pass_id: String!) {
|
||
passRequests(filters:{pass_id:$pass_id}){
|
||
results {
|
||
id
|
||
status
|
||
pass_id
|
||
place_id
|
||
created_at
|
||
updated_at
|
||
place { id }
|
||
confirmer_ids
|
||
}
|
||
}
|
||
}
|
||
""".strip()
|
||
variables = {"pass_id": pass_id}
|
||
with allure.step("GraphQL: passRequests (by pass_id)"):
|
||
resp = _exec_or_fail(op_name="passRequests", token=token, query=query, variables=variables, company_id=self.company_id)
|
||
_attach_json("passRequests response", resp)
|
||
return resp
|
||
|
||
def wait_for_pass_request(self, *, token: str | None = None, pass_id: str | None = None, timeout_s: float = 40.0) -> dict[str, Any]:
|
||
"""
|
||
На части стендов passRequest появляется асинхронно после createPass.
|
||
Поэтому делаем короткий polling до появления results.
|
||
"""
|
||
token = token or self.ensure_token()
|
||
pass_id = pass_id or self.create_pass()
|
||
deadline = time.time() + timeout_s
|
||
last_resp: dict[str, Any] | None = None
|
||
while time.time() < deadline:
|
||
resp = self.query_pass_requests(token=token, pass_id=pass_id)
|
||
last_resp = resp
|
||
results = resp.get("data", {}).get("passRequests", {}).get("results")
|
||
if isinstance(results, list) and results:
|
||
return resp
|
||
time.sleep(1.0)
|
||
raise AssertionError(f"passRequests не вернул results за {timeout_s:.0f}s. Последний ответ: {last_resp!r}")
|
||
|
||
def extract_single_pass_request(self, resp: dict[str, Any]) -> dict[str, Any]:
|
||
results = resp.get("data", {}).get("passRequests", {}).get("results", [])
|
||
assert isinstance(results, list) and results, f"passRequests.results пустой/не list: {resp!r}"
|
||
pr0 = results[0]
|
||
assert isinstance(pr0, dict) and pr0.get("id"), f"passRequests.results[0] не объект/id пустой: {pr0!r}"
|
||
return pr0
|
||
|
||
def approve_pass_request(self, *, pass_request_id: str, token: str) -> dict[str, Any]:
|
||
"""
|
||
Утверждение pass request. Название мутации может отличаться — пробуем через introspection.
|
||
"""
|
||
if os.getenv("USE_WIREMOCK") in {"1", "true", "True"}:
|
||
mutation = """
|
||
mutation($pass_request_id: String!) {
|
||
approvePassRequest(pass_request_id: $pass_request_id)
|
||
}
|
||
""".strip()
|
||
resp = _exec_or_fail(
|
||
op_name="approvePassRequest",
|
||
token=token,
|
||
query=mutation,
|
||
variables={"pass_request_id": pass_request_id},
|
||
company_id=self.company_id,
|
||
)
|
||
_attach_json("approvePassRequest response", resp)
|
||
return resp
|
||
|
||
candidates = self._pick_mutation_name(
|
||
patterns=[r"(approve|confirm).*pass.*request", r"pass.*request.*(approve|confirm)"],
|
||
fallback=["approvePassRequest", "confirmPassRequest", "approvePassRequests", "confirmPassRequests"],
|
||
)
|
||
last_error: Exception | None = None
|
||
attempts: list[str] = []
|
||
for name in candidates:
|
||
# пробуем самые типовые сигнатуры
|
||
variants = [
|
||
(
|
||
"dto:id",
|
||
f"""
|
||
mutation($id: String!) {{
|
||
{name}(dto: {{ id: $id }})
|
||
}}
|
||
""".strip(),
|
||
{"id": pass_request_id},
|
||
),
|
||
(
|
||
"dto:pass_request_id",
|
||
f"""
|
||
mutation($pass_request_id: String!) {{
|
||
{name}(dto: {{ pass_request_id: $pass_request_id }})
|
||
}}
|
||
""".strip(),
|
||
{"pass_request_id": pass_request_id},
|
||
),
|
||
(
|
||
"arg:id",
|
||
f"""
|
||
mutation($id: String!) {{
|
||
{name}(id: $id)
|
||
}}
|
||
""".strip(),
|
||
{"id": pass_request_id},
|
||
),
|
||
]
|
||
for vlabel, m, vars_ in variants:
|
||
attempts.append(f"{name}/{vlabel}")
|
||
with allure.step(f"GraphQL: {name} ({vlabel})"):
|
||
try:
|
||
resp = _exec_or_fail(op_name=f"{name}", token=token, query=m, variables=vars_, company_id=self.company_id)
|
||
except Exception as e: # noqa: BLE001
|
||
last_error = e
|
||
continue
|
||
_attach_json(f"{name} response", resp)
|
||
return resp
|
||
raise AssertionError(f"Не удалось approve/confirm passRequest id={pass_request_id!r}. Попробовали: {attempts}. Последняя ошибка: {last_error}")
|
||
|
||
def reject_pass_request(self, *, pass_request_id: str, token: str) -> dict[str, Any]:
|
||
"""
|
||
Отклонение pass request. По задаче есть мутация rejectPassRequest(pass_request_id: String!).
|
||
На стендах сигнатуры могут отличаться — пробуем несколько вариантов.
|
||
"""
|
||
if os.getenv("USE_WIREMOCK") in {"1", "true", "True"}:
|
||
mutation = """
|
||
mutation($pass_request_id: String!) {
|
||
rejectPassRequest(pass_request_id: $pass_request_id)
|
||
}
|
||
""".strip()
|
||
resp = _exec_or_fail(
|
||
op_name="rejectPassRequest",
|
||
token=token,
|
||
query=mutation,
|
||
variables={"pass_request_id": pass_request_id},
|
||
company_id=self.company_id,
|
||
)
|
||
_attach_json("rejectPassRequest response", resp)
|
||
return resp
|
||
|
||
candidates = self._pick_mutation_name(
|
||
patterns=[r"(reject|decline).*pass.*request", r"pass.*request.*(reject|decline)"],
|
||
fallback=["rejectPassRequest", "declinePassRequest"],
|
||
)
|
||
last_error: Exception | None = None
|
||
attempts: list[str] = []
|
||
for name in candidates:
|
||
variants = [
|
||
(
|
||
"arg:pass_request_id",
|
||
f"""
|
||
mutation($pass_request_id: String!) {{
|
||
{name}(pass_request_id: $pass_request_id)
|
||
}}
|
||
""".strip(),
|
||
{"pass_request_id": pass_request_id},
|
||
),
|
||
(
|
||
"arg:id",
|
||
f"""
|
||
mutation($id: String!) {{
|
||
{name}(id: $id)
|
||
}}
|
||
""".strip(),
|
||
{"id": pass_request_id},
|
||
),
|
||
(
|
||
"dto:pass_request_id",
|
||
f"""
|
||
mutation($pass_request_id: String!) {{
|
||
{name}(dto: {{ pass_request_id: $pass_request_id }})
|
||
}}
|
||
""".strip(),
|
||
{"pass_request_id": pass_request_id},
|
||
),
|
||
]
|
||
for vlabel, m, vars_ in variants:
|
||
attempts.append(f"{name}/{vlabel}")
|
||
with allure.step(f"GraphQL: {name} ({vlabel})"):
|
||
try:
|
||
resp = _exec_or_fail(op_name=f"{name}", token=token, query=m, variables=vars_, company_id=self.company_id)
|
||
except Exception as e: # noqa: BLE001
|
||
last_error = e
|
||
continue
|
||
_attach_json(f"{name} response", resp)
|
||
return resp
|
||
raise AssertionError(f"Не удалось reject passRequest id={pass_request_id!r}. Попробовали: {attempts}. Последняя ошибка: {last_error}")
|
||
|