Noob_test/Pass_request/testdata/pass_request_test_data.py
2026-05-15 11:34:24 +03:00

1666 lines
78 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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}")