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