Files
core/tests/test_community_delete_e2e_browser.py
Untone 5876995838
Some checks failed
Deploy on push / deploy (push) Failing after 2m34s
ci-mypy-fixes
2025-08-12 18:23:53 +03:00

768 lines
40 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.
"""
Настоящий E2E тест для удаления сообщества через браузер.
Использует Playwright для автоматизации браузера и тестирует:
1. Запуск сервера
2. Открытие админ-панели в браузере
3. Авторизацию
4. Переход на страницу сообществ
5. Удаление сообщества
6. Проверку результата
"""
import pytest
import time
import asyncio
from playwright.async_api import async_playwright, Page, Browser, BrowserContext
import subprocess
import signal
import os
import sys
import requests
from dotenv import load_dotenv
# Загружаем переменные окружения для E2E тестов
load_dotenv()
# Добавляем путь к проекту для импорта
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
from auth.orm import Author
from orm.community import Community, CommunityAuthor
from services.db import local_session
class TestCommunityDeleteE2EBrowser:
"""E2E тесты для удаления сообщества через браузер"""
@pytest.fixture
async def browser_setup(self):
"""Настройка браузера и запуск серверов"""
# Запускаем бэкенд сервер в фоне
backend_process = None
frontend_process = None
try:
# Проверяем, не запущен ли уже сервер
try:
response = requests.get("http://localhost:8000/", timeout=2)
if response.status_code == 200:
print("✅ Бэкенд сервер уже запущен")
backend_running = True
else:
backend_running = False
except:
backend_running = False
if not backend_running:
# Запускаем бэкенд сервер в CI/CD среде
print("🔄 Запускаем бэкенд сервер...")
try:
# В CI/CD используем uv run python
backend_process = subprocess.Popen(
["uv", "run", "python", "dev.py"],
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
cwd=os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
)
# Ждем запуска бэкенда
print("⏳ Ждем запуска бэкенда...")
for i in range(20): # Ждем максимум 20 секунд
try:
response = requests.get("http://localhost:8000/", timeout=2)
if response.status_code == 200:
print("✅ Бэкенд сервер запущен")
break
except:
pass
await asyncio.sleep(1)
else:
# Если сервер не запустился, выводим логи и завершаем тест
print("❌ Бэкенд сервер не запустился за 20 секунд")
# Логи процесса не собираем, чтобы не блокировать выполнение
raise Exception("Бэкенд сервер не запустился за 20 секунд")
except Exception as e:
print(f"❌ Ошибка запуска сервера: {e}")
raise Exception(f"Не удалось запустить бэкенд сервер: {e}")
# Проверяем фронтенд
try:
response = requests.get("http://localhost:8000", timeout=2)
if response.status_code == 200:
print("✅ Фронтенд сервер уже запущен")
frontend_running = True
else:
frontend_running = False
except:
frontend_running = False
if not frontend_running:
# Проверяем, находимся ли мы в CI/CD окружении
is_ci = os.getenv("PLAYWRIGHT_HEADLESS", "false").lower() == "true"
if is_ci:
print("🔧 CI/CD окружение - фронтенд собран и обслуживается бэкендом")
# В CI/CD фронтенд уже собран и обслуживается бэкендом на порту 8000
try:
response = requests.get("http://localhost:8000/", timeout=2)
if response.status_code == 200:
print("✅ Бэкенд готов обслуживать фронтенд")
frontend_running = True
frontend_process = None
else:
print(f"⚠️ Бэкенд вернул статус {response.status_code}")
frontend_process = None
except Exception as e:
print(f"⚠️ Не удалось проверить бэкенд: {e}")
frontend_process = None
else:
# Локальная разработка - запускаем фронтенд сервер
print("🔄 Запускаем фронтенд сервер...")
try:
frontend_process = subprocess.Popen(
["npm", "run", "dev"],
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
cwd=os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
)
# Ждем запуска фронтенда
print("⏳ Ждем запуска фронтенда...")
for i in range(15): # Ждем максимум 15 секунд
try:
# В локальной разработке фронтенд работает на порту 3000
response = requests.get("http://localhost:3000", timeout=2)
if response.status_code == 200:
print("✅ Фронтенд сервер запущен")
break
except:
pass
await asyncio.sleep(1)
else:
# Если фронтенд не запустился, выводим логи
print("❌ Фронтенд сервер не запустился за 15 секунд")
# Логи процесса не собираем, чтобы не блокировать выполнение
print("⚠️ Продолжаем тест без фронтенда (только API тесты)")
frontend_process = None
except Exception as e:
print(f"⚠️ Не удалось запустить фронтенд сервер: {e}")
print("🔄 Продолжаем тест без фронтенда (только API тесты)")
frontend_process = None
# Запускаем браузер
print("🔄 Запускаем браузер...")
playwright = await async_playwright().start()
# Определяем headless режим из переменной окружения
headless_mode = os.getenv("PLAYWRIGHT_HEADLESS", "false").lower() == "true"
print(f"🔧 Headless режим: {headless_mode}")
browser = await playwright.chromium.launch(
headless=headless_mode, # Используем переменную окружения для CI/CD
args=["--no-sandbox", "--disable-dev-shm-usage"]
)
context = await browser.new_context()
page = await context.new_page()
yield {
"playwright": playwright,
"browser": browser,
"context": context,
"page": page,
"backend_process": backend_process,
"frontend_process": frontend_process
}
finally:
# Очистка
print("🧹 Очистка ресурсов...")
if frontend_process:
frontend_process.terminate()
try:
frontend_process.wait(timeout=5)
except subprocess.TimeoutExpired:
frontend_process.kill()
if backend_process:
backend_process.terminate()
try:
backend_process.wait(timeout=5)
except subprocess.TimeoutExpired:
backend_process.kill()
try:
if 'browser' in locals():
await browser.close()
if 'playwright' in locals():
await playwright.stop()
except Exception as e:
print(f"⚠️ Ошибка при закрытии браузера: {e}")
@pytest.fixture
def test_community_for_browser(self, db_session, test_users):
"""Создает тестовое сообщество для удаления через браузер"""
community = Community(
id=888,
name="Browser Test Community",
slug="browser-test-community",
desc="Test community for browser E2E tests",
created_by=test_users[0].id,
created_at=int(time.time())
)
db_session.add(community)
db_session.commit()
return community
@pytest.fixture
def admin_user_for_browser(self, db_session, test_users, test_community_for_browser):
"""Создает администратора с правами на удаление"""
user = test_users[0]
# Создаем CommunityAuthor с правами администратора
ca = CommunityAuthor(
community_id=test_community_for_browser.id,
author_id=user.id,
roles="admin,editor,author"
)
db_session.add(ca)
db_session.commit()
return user
async def test_community_delete_browser_workflow(self, browser_setup, test_users, frontend_url):
"""Полный E2E тест удаления сообщества через браузер"""
page = browser_setup["page"]
# Серверы уже запущены в browser_setup фикстуре
print("✅ Серверы запущены и готовы к тестированию")
# Используем существующее сообщество для тестирования удаления
# Берем первое доступное сообщество из БД
test_community_name = "Test Editor Community" # Существующее сообщество из БД
test_community_slug = "test-editor-community-test-902f937f" # Конкретный slug для удаления
print(f"🔍 Будем тестировать удаление сообщества: {test_community_name}")
try:
# 1. Открываем админ-панель
print(f"🌐 Открываем админ-панель на {frontend_url}...")
await page.goto(frontend_url)
# Ждем загрузки страницы и JavaScript
await page.wait_for_load_state("networkidle")
await page.wait_for_load_state("domcontentloaded")
# Дополнительное ожидание для загрузки React приложения
await page.wait_for_timeout(3000)
print("✅ Страница загружена")
# 2. Авторизуемся через форму входа
print("🔐 Авторизуемся через форму входа...")
# Ждем появления формы входа с увеличенным таймаутом
await page.wait_for_selector('input[type="email"]', timeout=30000)
await page.wait_for_selector('input[type="password"]', timeout=10000)
# Заполняем форму входа
await page.fill('input[type="email"]', 'test_admin@discours.io')
await page.fill('input[type="password"]', 'password123')
# Нажимаем кнопку входа
await page.click('button[type="submit"]')
# Ждем успешной авторизации (редирект на главную страницу админки)
await page.wait_for_url(f"{frontend_url}/admin/**", timeout=10000)
print("✅ Авторизация успешна")
# Проверяем что мы действительно в админ-панели
await page.wait_for_selector('button:has-text("Сообщества")', timeout=30000)
print("✅ Админ-панель загружена")
# 3. Переходим на страницу сообществ
print("📋 Переходим на страницу сообществ...")
# Ищем кнопку "Сообщества" в навигации
await page.wait_for_selector('button:has-text("Сообщества")', timeout=30000)
await page.click('button:has-text("Сообщества")')
# Ждем загрузки страницы сообществ
await page.wait_for_load_state("networkidle")
print("✅ Страница сообществ загружена")
# Проверяем что мы на правильной странице
current_url = page.url
print(f"📍 Текущий URL: {current_url}")
if "/admin/communities" not in current_url:
print("⚠️ Не на странице управления сообществами, переходим...")
await page.goto(f"{frontend_url}/admin/communities")
await page.wait_for_load_state("networkidle")
print("✅ Перешли на страницу управления сообществами")
# 4. Ищем наше тестовое сообщество
print(f"🔍 Ищем сообщество: {test_community_name}")
# Сначала делаем скриншот для отладки
await page.screenshot(path="test-results/debug_page.png")
print("📸 Скриншот страницы сохранен для отладки")
# Получаем HTML страницы для отладки
page_html = await page.content()
print(f"📄 Размер HTML страницы: {len(page_html)} символов")
# Ищем любые таблицы на странице
tables = await page.query_selector_all('table')
print(f"🔍 Найдено таблиц на странице: {len(tables)}")
# Ищем другие возможные селекторы для списка сообществ
possible_selectors = [
'table',
'[data-testid="communities-table"]',
'.communities-table',
'.communities-list',
'[class*="table"]',
'[class*="list"]'
]
found_element = None
for selector in possible_selectors:
try:
element = await page.wait_for_selector(selector, timeout=2000)
if element:
print(f"✅ Найден элемент с селектором: {selector}")
found_element = element
break
except:
continue
if not found_element:
print("Не найдена таблица сообществ")
print("🔍 Доступные элементы на странице:")
# Получаем список всех элементов с классами
elements_with_classes = await page.evaluate("""
() => {
const elements = document.querySelectorAll('*[class]');
const classes = {};
elements.forEach(el => {
const classList = Array.from(el.classList);
classList.forEach(cls => {
if (!classes[cls]) classes[cls] = 0;
classes[cls]++;
});
});
return classes;
}
""")
print(f"📋 Классы элементов: {elements_with_classes}")
raise Exception("Не найдена таблица сообществ на странице")
print("✅ Элемент со списком сообществ найден")
# Ждем загрузки данных в найденном элементе
# Используем найденный элемент вместо жестко заданного селектора
print("⏳ Ждем загрузки данных...")
# Ждем дольше для загрузки данных
await page.wait_for_timeout(5000)
try:
# Ищем строки в найденном элементе
rows = await found_element.query_selector_all('tr, [class*="row"], [class*="item"], [class*="card"], [class*="community"]')
if rows:
print(f"✅ Найдено строк в элементе: {len(rows)}")
# Выводим содержимое первых нескольких строк для отладки
for i, row in enumerate(rows[:3]):
try:
text = await row.text_content()
print(f"📋 Строка {i+1}: {text[:100]}...")
except:
print(f"📋 Строка {i+1}: [не удалось прочитать]")
else:
print("⚠️ Строки данных не найдены")
# Пробуем найти любые элементы с текстом
all_elements = await found_element.query_selector_all('*')
print(f"🔍 Всего элементов в найденном элементе: {len(all_elements)}")
# Ищем элементы с текстом
text_elements = []
for elem in all_elements[:10]: # Проверяем первые 10
try:
text = await elem.text_content()
if text and text.strip() and len(text.strip()) > 3:
text_elements.append(text.strip()[:50])
except:
pass
print(f"📋 Элементы с текстом: {text_elements}")
except Exception as e:
print(f"⚠️ Ошибка при поиске строк: {e}")
print("✅ Данные загружены")
# Ищем строку с нашим конкретным сообществом по slug
# Используем найденный элемент и ищем по тексту
community_row = None
# Ищем в найденном элементе
try:
community_row = await found_element.query_selector(f'*:has-text("{test_community_slug}")')
if community_row:
print(f"✅ Найдено сообщество {test_community_slug} в элементе")
else:
# Если не найдено, ищем по всему содержимому
print(f"🔍 Ищем сообщество {test_community_slug} по всему содержимому...")
all_text = await found_element.text_content()
if test_community_slug in all_text:
print(f"✅ Текст сообщества {test_community_slug} найден в содержимом")
# Ищем родительский элемент, содержащий этот текст
community_row = await found_element.query_selector(f'*:has-text("{test_community_slug}")')
else:
print(f"❌ Сообщество {test_community_slug} не найдено в содержимом")
except Exception as e:
print(f"⚠️ Ошибка при поиске сообщества: {e}")
if not community_row:
# Делаем скриншот для отладки
await page.screenshot(path="test-results/communities_table.png")
# Получаем список всех сообществ в таблице
all_communities = await page.evaluate("""
() => {
const rows = document.querySelectorAll('table tbody tr');
return Array.from(rows).map(row => {
const cells = row.querySelectorAll('td');
return {
id: cells[0]?.textContent?.trim(),
name: cells[1]?.textContent?.trim(),
slug: cells[2]?.textContent?.trim()
};
});
}
""")
print(f"📋 Найденные сообщества в таблице: {all_communities}")
raise Exception(f"Сообщество {test_community_name} не найдено в таблице")
print(f"✅ Найдено сообщество: {test_community_name}")
# 5. Удаляем сообщество
print("🗑️ Удаляем сообщество...")
# Ищем кнопку удаления в строке с нашим конкретным сообществом
# Кнопка удаления содержит символ '×' и находится в последней ячейке
delete_button = await page.wait_for_selector(
f'table tbody tr:has-text("{test_community_slug}") button:has-text("×")',
timeout=10000
)
if not delete_button:
# Альтернативный поиск - найти кнопку в последней ячейке строки
delete_button = await page.wait_for_selector(
f'table tbody tr:has-text("{test_community_slug}") td:last-child button',
timeout=10000
)
if not delete_button:
# Еще один способ - найти кнопку по CSS модулю классу
delete_button = await page.wait_for_selector(
f'table tbody tr:has-text("{test_community_slug}") button[class*="delete-button"]',
timeout=10000
)
if not delete_button:
# Делаем скриншот для отладки
await page.screenshot(path="test-results/delete_button_not_found.png")
raise Exception("Кнопка удаления не найдена")
print("✅ Кнопка удаления найдена")
# Нажимаем кнопку удаления
await delete_button.click()
# Ждем появления диалога подтверждения
# Модальное окно использует CSS модули, поэтому ищем по backdrop
await page.wait_for_selector('[class*="backdrop"]', timeout=10000)
# Подтверждаем удаление
# Ищем кнопку "Удалить" в модальном окне
confirm_button = await page.wait_for_selector(
'[class*="backdrop"] button:has-text("Удалить")',
timeout=10000
)
if not confirm_button:
# Альтернативный поиск
confirm_button = await page.wait_for_selector(
'[class*="modal"] button:has-text("Удалить")',
timeout=10000
)
if not confirm_button:
# Еще один способ - найти кнопку с variant="danger"
confirm_button = await page.wait_for_selector(
'[class*="backdrop"] button[class*="danger"]',
timeout=10000
)
if not confirm_button:
# Делаем скриншот для отладки
await page.screenshot(path="test-results/confirm_button_not_found.png")
raise Exception("Кнопка подтверждения не найдена")
print("✅ Кнопка подтверждения найдена")
await confirm_button.click()
# Ждем исчезновения диалога и обновления страницы
await page.wait_for_load_state("networkidle")
print("✅ Сообщество удалено")
# Ждем исчезновения модального окна
try:
await page.wait_for_selector('[class*="backdrop"]', timeout=5000, state='hidden')
print("✅ Модальное окно закрылось")
except:
print("⚠️ Модальное окно не закрылось автоматически")
# Ждем обновления таблицы
await page.wait_for_timeout(3000) # Ждем 3 секунды для обновления
# 6. Проверяем что сообщество действительно удалено
print("🔍 Проверяем что сообщество удалено...")
# Ждем немного для обновления списка
await asyncio.sleep(2)
# Проверяем что конкретное сообщество больше не отображается в таблице
community_still_exists = await page.query_selector(f'table tbody tr:has-text("{test_community_slug}")')
if community_still_exists:
# Попробуем обновить страницу и проверить еще раз
print("🔄 Обновляем страницу и проверяем еще раз...")
await page.reload()
await page.wait_for_load_state("networkidle")
await page.wait_for_selector('table tbody tr', timeout=10000)
# Проверяем еще раз после обновления
community_still_exists = await page.query_selector(f'table tbody tr:has-text("{test_community_slug}")')
if community_still_exists:
# Делаем скриншот для отладки
await page.screenshot(path="test-results/community_still_exists.png")
# Получаем список всех сообществ для отладки
all_communities = await page.evaluate("""
() => {
const rows = document.querySelectorAll('table tbody tr');
return Array.from(rows).map(row => {
const cells = row.querySelectorAll('td');
return {
id: cells[0]?.textContent?.trim(),
name: cells[1]?.textContent?.trim(),
slug: cells[2]?.textContent?.trim()
};
});
}
""")
print(f"📋 Сообщества в таблице после обновления: {all_communities}")
raise Exception(f"Сообщество {test_community_name} (slug: {test_community_slug}) все еще отображается после удаления и обновления страницы")
else:
print("✅ Сообщество удалено после обновления страницы")
print("✅ Сообщество действительно удалено из списка")
# 7. Делаем скриншот результата
await page.screenshot(path="test-results/community_deleted_success.png")
print("📸 Скриншот сохранен: test-results/community_deleted_success.png")
print("🎉 E2E тест удаления сообщества прошел успешно!")
except Exception as e:
print(f"❌ Ошибка в E2E тесте: {e}")
# Делаем скриншот при ошибке
try:
await page.screenshot(path=f"test-results/error_{int(time.time())}.png")
print("📸 Скриншот ошибки сохранен")
except Exception as screenshot_error:
print(f"⚠️ Не удалось сделать скриншот при ошибке: {screenshot_error}")
raise
async def test_community_delete_without_permissions_browser(self, browser_setup, test_community_for_browser, frontend_url):
"""Тест попытки удаления без прав через браузер"""
page = browser_setup["page"]
try:
# 1. Открываем админ-панель
print("🔄 Открываем админ-панель...")
await page.goto(f"{frontend_url}/admin")
await page.wait_for_load_state("networkidle")
# 2. Авторизуемся как обычный пользователь (без прав admin)
print("🔐 Авторизуемся как обычный пользователь...")
import os
regular_username = os.getenv("TEST_REGULAR_USERNAME", "user2@example.com")
password = os.getenv("E2E_TEST_PASSWORD", "password123")
await page.fill("input[type='email']", regular_username)
await page.fill("input[type='password']", password)
await page.click("button[type='submit']")
await page.wait_for_load_state("networkidle")
# 3. Переходим на страницу сообществ
print("🏘️ Переходим на страницу сообществ...")
await page.click("a[href='/admin/communities']")
await page.wait_for_load_state("networkidle")
# 4. Ищем сообщество
print(f"🔍 Ищем сообщество: {test_community_for_browser.name}")
community_row = await page.wait_for_selector(
f"tr:has-text('{test_community_for_browser.name}')",
timeout=10000
)
if not community_row:
print("❌ Сообщество не найдено")
await page.screenshot(path="test-results/community_not_found_no_permissions.png")
raise Exception("Сообщество не найдено")
# 5. Проверяем что кнопка удаления недоступна или отсутствует
print("🔒 Проверяем доступность кнопки удаления...")
delete_button = await community_row.query_selector("button:has-text('Удалить')")
if delete_button:
# Если кнопка есть, пробуем нажать и проверяем ошибку
print("⚠️ Кнопка удаления найдена, пробуем нажать...")
await delete_button.click()
# Ждем появления ошибки
await page.wait_for_selector("[role='alert']", timeout=5000)
error_message = await page.text_content("[role='alert']")
if "Недостаточно прав" in error_message or "permission" in error_message.lower():
print("✅ Ошибка доступа получена корректно")
else:
print(f"❌ Неожиданная ошибка: {error_message}")
await page.screenshot(path="test-results/unexpected_error.png")
raise Exception(f"Неожиданная ошибка: {error_message}")
else:
print("✅ Кнопка удаления недоступна (как и должно быть)")
# 6. Проверяем что сообщество осталось в БД
print("🗄️ Проверяем что сообщество осталось в БД...")
with local_session() as session:
community = session.query(Community).filter_by(
slug=test_community_for_browser.slug
).first()
if not community:
print("❌ Сообщество было удалено без прав")
raise Exception("Сообщество было удалено без соответствующих прав")
print("✅ Сообщество осталось в БД (как и должно быть)")
print("🎉 E2E тест проверки прав доступа прошел успешно!")
except Exception as e:
try:
await page.screenshot(path=f"test-results/error_permissions_{int(time.time())}.png")
except:
print("⚠️ Не удалось сделать скриншот при ошибке")
print(f"❌ Ошибка в E2E тесте прав доступа: {e}")
raise
async def test_community_delete_ui_validation(self, browser_setup, test_community_for_browser, admin_user_for_browser, frontend_url):
"""Тест UI валидации при удалении сообщества"""
page = browser_setup["page"]
try:
# 1. Авторизуемся как админ
print("🔐 Авторизуемся как админ...")
await page.goto(f"{frontend_url}/admin")
await page.wait_for_load_state("networkidle")
import os
username = os.getenv("E2E_TEST_USERNAME", "test_admin@discours.io")
password = os.getenv("E2E_TEST_PASSWORD", "password123")
await page.fill("input[type='email']", username)
await page.fill("input[type='password']", password)
await page.click("button[type='submit']")
await page.wait_for_load_state("networkidle")
# 2. Переходим на страницу сообществ
print("🏘️ Переходим на страницу сообществ...")
await page.click("a[href='/admin/communities']")
await page.wait_for_load_state("networkidle")
# 3. Ищем сообщество и нажимаем удаление
print(f"🔍 Ищем сообщество: {test_community_for_browser.name}")
community_row = await page.wait_for_selector(
f"tr:has-text('{test_community_for_browser.name}')",
timeout=10000
)
delete_button = await community_row.query_selector("button:has-text('Удалить')")
await delete_button.click()
# 4. Проверяем модальное окно
print("⚠️ Проверяем модальное окно...")
modal = await page.wait_for_selector("[role='dialog']", timeout=10000)
# Проверяем текст предупреждения
modal_text = await modal.text_content()
if "удалить" not in modal_text.lower() and "delete" not in modal_text.lower():
print(f"❌ Неожиданный текст в модальном окне: {modal_text}")
await page.screenshot(path="test-results/unexpected_modal_text.png")
raise Exception("Неожиданный текст в модальном окне")
# 5. Отменяем удаление
print("❌ Отменяем удаление...")
cancel_button = await page.query_selector("button:has-text('Отмена')")
if not cancel_button:
cancel_button = await page.query_selector("button:has-text('Cancel')")
if cancel_button:
await cancel_button.click()
# Проверяем что модальное окно закрылось
await page.wait_for_selector("[role='dialog']", state="hidden", timeout=5000)
# Проверяем что сообщество осталось в таблице
community_still_exists = await page.query_selector(
f"tr:has-text('{test_community_for_browser.name}')"
)
if not community_still_exists:
print("❌ Сообщество исчезло после отмены")
await page.screenshot(path="community_disappeared_after_cancel.png")
raise Exception("Сообщество исчезло после отмены удаления")
print("✅ Сообщество осталось после отмены")
else:
print("⚠️ Кнопка отмены не найдена")
print("🎉 E2E тест UI валидации прошел успешно!")
except Exception as e:
try:
await page.screenshot(path=f"test-results/error_ui_validation_{int(time.time())}.png")
except:
print("⚠️ Не удалось сделать скриншот при ошибке")
print(f"❌ Ошибка в E2E тесте UI валидации: {e}")
raise