From b90ae707840651c837eb212eba550c19721c8808 Mon Sep 17 00:00:00 2001 From: Stepan Pilipenko Date: Mon, 27 Oct 2025 21:04:36 +0300 Subject: [PATCH] server_api + aerver_api_tests --- README.md | 37 +++++- app/__init__.py | 4 + app/api/admin_api.py | 2 +- app/api/api.py | 10 +- app/consts/__init__.py | 2 +- app/consts/{const.py => consts.py} | 0 app/server/urls.py | 7 +- app/server/views.py | 63 +++++++--- app/tests/{tests.py => api_test.py} | 20 ++- app/tests/server_api_test.py | 182 ++++++++++++++++++++++++++++ 10 files changed, 296 insertions(+), 31 deletions(-) create mode 100644 app/__init__.py rename app/consts/{const.py => consts.py} (100%) rename app/tests/{tests.py => api_test.py} (93%) create mode 100644 app/tests/server_api_test.py diff --git a/README.md b/README.md index 1a1bfd3..b807584 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,36 @@ -# game_shop +Напиши unittest-ы -game_shop \ No newline at end of file +в случае фошибки вернуть JsonResponse({"error": format(error)}, status=500) + +1 -http://localhost:8090/shop/ +Запрос GET возвращает ответ JsonResponse c формата JsonResponse({"OK": products}, status=200) +пример products: +[{'id': 2, 'name': 'Серебрянный паровоз', 'count': 23, 'reserved': 0, 'picture_url': 'https://git.softwarrior.ru/stepik104/game_shop/raw/branch/main/images/silver_train.png ', 'description': 'Серебро - благородный метал, такой паровоз не стыдно выкатить на рельсы', 'type': 'skin', 'cost': Decimal('50.00')}, {'id': 4, 'name': 'Золотая башня', 'count': 10, 'reserved': 0, 'picture_url': 'https://git.softwarrior.ru/stepik104/game_shop/raw/branch/main/images/golden_tower.png ', 'description': 'Целая башня из чистого золота, кто-то скажет - непрактично, но мы ответим - да', 'type': 'skin', 'cost': Decimal('100.00')}, {'id': 6, 'name': 'Бронзовая башня', 'count': 100, 'reserved': 0, 'picture_url': 'https://git.softwarrior.ru/stepik104/game_shop/raw/branch/main/images/bronze_tower.png ', 'description': 'Бронзовая башня - великая классика, как оловянный солдатик', 'type': 'skin', 'cost': Decimal('10.00')}, {'id': 7, 'name': 'Капибара', 'count': 30, 'reserved': 0, 'picture_url': 'https://git.softwarrior.ru/stepik104/game_shop/raw/branch/main/images/capi_avatar.png ', 'description': 'Почему нет', 'type': 'avatar', 'cost': Decimal('25.00')}, {'id': 9, 'name': 'Штурмовик', 'count': 30, 'reserved': 0, 'picture_url': 'https://git.softwarrior.ru/stepik104/game_shop/raw/branch/main/images/starwars_avatar.png ', 'description': 'Не всегда попадает в цель, зато всегда лоялен императору', 'type': 'avatar', 'cost': Decimal('25.00')}, {'id': 3, 'name': 'Бронзовый паровоз', 'count': 98, 'reserved': 2, 'picture_url': 'https://git.softwarrior.ru/stepik104/game_shop/raw/branch/main/images/bronze_train.png ', 'description': 'Развитие цивилизации начиналось с бронзы, этот паровоз символизирует начало прогресса', 'type': 'skin', 'cost': Decimal('10.00')}, {'id': 8, 'name': 'Капитан-черепаха', 'count': 30, 'reserved': 1, 'picture_url': 'https://git.softwarrior.ru/stepik104/game_shop/raw/branch/main/images/tortule_avatar.png ', 'description': 'Настоящий пират никуда не торопиться', 'type': 'avatar', 'cost': Decimal('25.00')}, {'id': 5, 'name': 'Серебрянная башня', 'count': 18, 'reserved': 2, 'picture_url': 'https://git.softwarrior.ru/stepik104/game_shop/raw/branch/main/images/silver_tower.png ', 'description': 'Серебряная башня, такая серая и холодная, так бы и заточил туда принцессу', 'type': 'skin', 'cost': Decimal('50.00')}, {'id': 1, 'name': 'Золотой паровоз', 'count': 9, 'reserved': 0, 'picture_url': 'https://git.softwarrior.ru/stepik104/game_shop/raw/branch/main/images/golden_train.png ', 'description': 'Золотой, блестящий, ну разве не чудо', 'type': 'skin', 'cost': Decimal('100.00')}, {'id': 28, 'name': 'Тестовый товар 1', 'count': 8, 'reserved': 0, 'picture_url': '', 'description': 'Описание 1', 'type': 'test', 'cost': Decimal('50.00')}, {'id': 29, 'name': 'Тестовый товар 2', 'count': 4, 'reserved': 0, 'picture_url': '', 'description': 'Описание 2', 'type': 'test', 'cost': Decimal('30.00')}] + +Проверить что не пустой + +2 - http://localhost:8090/login/ +Запрос POST с body +login: str +password: str +email: str +возвращает JsonResponse({"OK": token}, status=200) +пример token +token формата: uuid.uuid4(), пример: 44decdbc-7120-4838-abf8-7528a11467a9 + +3 - http://localhost:8090/logout/ +Запрос POST с header: +Token: str пример: 44decdbc-7120-4838-abf8-7528a11467a9 +возвращает JsonResponse({"OK": token}, status=200) + +4 - http://localhost:8090/basket/ +Запрос POST с header: +Token: str пример: 44decdbc-7120-4838-abf8-7528a11467a9 +возвращает JsonResponse({"OK": basket}, status=200) +ошибка если токен невалидный, напиши тест на невалидный токен + +5 - http://localhost:8090/history/ +Запрос POST с header: +Token: str пример: 44decdbc-7120-4838-abf8-7528a11467a9 +возвращает JsonResponse({"OK": histories}, status=200) +ошибка если токен невалидный, напиши тест на невалидный токен diff --git a/app/__init__.py b/app/__init__.py new file mode 100644 index 0000000..4c9f0d7 --- /dev/null +++ b/app/__init__.py @@ -0,0 +1,4 @@ +from .api import * +from .type import * +from .consts import * +from .utils import * \ No newline at end of file diff --git a/app/api/admin_api.py b/app/api/admin_api.py index 0e1f93c..9c77a1e 100644 --- a/app/api/admin_api.py +++ b/app/api/admin_api.py @@ -1,6 +1,6 @@ import psycopg2 -from consts import DB_CONFIG +from ..consts import DB_CONFIG def add_product_to_shop( name: str, diff --git a/app/api/api.py b/app/api/api.py index a4e977f..8b50995 100644 --- a/app/api/api.py +++ b/app/api/api.py @@ -7,9 +7,9 @@ from psycopg2 import IntegrityError from collections import Counter from decimal import Decimal, InvalidOperation -from utils import * -from type import * -from consts import * +from ..utils import * +from ..type import * +from ..consts import * def update_token_expiry_date(token: str, expiry_date: date) -> None: connection = psycopg2.connect(**DB_CONFIG) @@ -123,11 +123,7 @@ def unregister(token: str) -> None: connection = psycopg2.connect(**DB_CONFIG) cursor = connection.cursor() try: - if not check_token(cursor, token): - raise AuthError("Пользователь не авторизован") - user_id: int = get_user_id(cursor, token) - # Получаем массив histories_id пользователя cursor.execute("SELECT histories_id FROM users WHERE id = %s", (user_id,)) row = cursor.fetchone() diff --git a/app/consts/__init__.py b/app/consts/__init__.py index 983f876..24e02ec 100644 --- a/app/consts/__init__.py +++ b/app/consts/__init__.py @@ -1 +1 @@ -from .const import * \ No newline at end of file +from .consts import * \ No newline at end of file diff --git a/app/consts/const.py b/app/consts/consts.py similarity index 100% rename from app/consts/const.py rename to app/consts/consts.py diff --git a/app/server/urls.py b/app/server/urls.py index 42ffbde..1f73713 100644 --- a/app/server/urls.py +++ b/app/server/urls.py @@ -1,11 +1,14 @@ #from django.contrib import admin from django.urls import path -from server.views import (get_shop, get_user, get_basket, get_history) +from .views import (get_shop, login, logout, register, unregister, get_basket, get_history) urlpatterns = [ path("shop/", get_shop, name="get_shop"), - path("user/", get_user, name="get_user"), + path("login/", login, name="login"), + path("logout/", logout, name="logout"), + path("register/", register, name="register"), + path("unregister/", unregister, name="unregister"), path("basket/", get_basket, name="get_basket"), path("history/", get_history, name="get_history"), ] diff --git a/app/server/views.py b/app/server/views.py index 72fc1ca..cf3e194 100644 --- a/app/server/views.py +++ b/app/server/views.py @@ -2,15 +2,15 @@ import json from django.http import JsonResponse from django.views.decorators.csrf import csrf_exempt -from api import get_products -from utils import decimal_to_float +from app.api import api +from app.utils import decimal_to_float @csrf_exempt async def get_shop(request): try: products = None if request.method == 'GET': - products = get_products() + products = api.get_products() user_agent = request.headers.get('User-Agent') products = decimal_to_float(products) print("get_shop", user_agent) @@ -19,35 +19,68 @@ async def get_shop(request): return JsonResponse({"ERROR": format(error)}, status=500) @csrf_exempt -async def get_user(request): +async def login(request): try: + token = None if request.method == 'POST': body: dict = json.loads(request.body) - # TODO: вызвать API метод и вернуть JSON - print("get_user", body) - return JsonResponse({"OK": "JSON cleared"}, status=200) + token = api.login(body["email"], body["password"]) + return JsonResponse({"OK": token}, status=200) except Exception as error: return JsonResponse({"error": format(error)}, status=500) @csrf_exempt -async def get_basket(request): +async def logout(request): try: + token = None + if request.method == 'POST': + token = request.headers.get("Token") + api.logout(token) + return JsonResponse({"OK": token}, status=200) + except Exception as error: + return JsonResponse({"error": format(error)}, status=500) + +@csrf_exempt +async def register(request): + try: + token = None if request.method == 'POST': body: dict = json.loads(request.body) - # TODO: вызвать API метод и вернуть JSON - print("get_basket", body) - return JsonResponse({"OK": "JSON cleared"}, status=200) + token = api.registration(body["nickname"], body["password"], body["email"]) + return JsonResponse({"OK": token}, status=200) + except Exception as error: + return JsonResponse({"error": format(error)}, status=500) + +@csrf_exempt +async def unregister(request): + try: + token = None + if request.method == 'POST': + token = request.headers.get("Token") + api.unregister(token) + return JsonResponse({"OK": token}, status=200) + except Exception as error: + return JsonResponse({"error": format(error)}, status=500) + +@csrf_exempt +async def get_basket(request): + try: + basket = None + if request.method == 'POST': + token = request.headers.get("Token") + basket = api.get_basket(token) + return JsonResponse({"OK": basket}, status=200) except Exception as error: return JsonResponse({"error": format(error)}, status=500) @csrf_exempt async def get_history(request): try: + histories = None if request.method == 'POST': - body: dict = json.loads(request.body) - # TODO: вызвать API метод и вернуть JSON - print("get_history", body) - return JsonResponse({"OK": "JSON cleared"}, status=200) + token = request.headers.get("Token") + histories = api.get_histories(token) + return JsonResponse({"OK": histories}, status=200) except Exception as error: return JsonResponse({"error": format(error)}, status=500) diff --git a/app/tests/tests.py b/app/tests/api_test.py similarity index 93% rename from app/tests/tests.py rename to app/tests/api_test.py index 53d8ee8..1d2b7b0 100644 --- a/app/tests/tests.py +++ b/app/tests/api_test.py @@ -1,16 +1,17 @@ import unittest import psycopg2 -from api import ( +from app.api import ( registration, add_product_to_basket, buy_products, get_basket, get_histories, unregister, + get_products, add_money # Импортируем напрямую ) -from api import add_product_to_shop, delete_product_from_shop -from consts import DB_CONFIG +from app.api import add_product_to_shop, delete_product_from_shop +from app.consts import * class TestFullUserFlow(unittest.TestCase): @@ -182,6 +183,19 @@ class TestFullUserFlow(unittest.TestCase): cur.close() conn.close() + def test_15_check_products(self): + """15. Проверка, что товары есть""" + conn = psycopg2.connect(**DB_CONFIG) + cur = conn.cursor() + try: + products = get_products() + print(products) + self.assertIsNotNone(products) + print("✅ Товары действительно есть") + finally: + cur.close() + conn.close() + # Вспомогательные методы def _get_user_balance(self) -> float: conn = psycopg2.connect(**DB_CONFIG) diff --git a/app/tests/server_api_test.py b/app/tests/server_api_test.py new file mode 100644 index 0000000..f251ebd --- /dev/null +++ b/app/tests/server_api_test.py @@ -0,0 +1,182 @@ +import unittest +import requests +import uuid + +BASE_URL = "http://localhost:8090" + + +def get_error_message(response: requests.Response) -> str: + """Извлекает сообщение об ошибке из ответа сервера или возвращает понятную заглушку.""" + try: + if response.headers.get('Content-Type', '').startswith('application/json'): + data = response.json() + return data.get("error", "No 'error' field in JSON response") + else: + return f"Non-JSON response: {response.text[:200]}" + except Exception as e: + return f"Failed to parse error: {e} | Raw: {response.text[:200]}" + + +class TestGameShopAPI(unittest.TestCase): + + valid_token = None + test_nickname = "autotest_user" + test_password = "secure_password_123" + test_email = "autotest@example.com" + + @classmethod + def setUpClass(cls): + """Регистрируем пользователя один раз перед всеми тестами.""" + super().setUpClass() + register_payload = { + "nickname": cls.test_nickname, + "password": cls.test_password, + "email": cls.test_email + } + try: + response = requests.post(f"{BASE_URL}/register/", json=register_payload, timeout=10) + if response.status_code != 200: + error_msg = get_error_message(response) + raise RuntimeError(f"Registration failed ({response.status_code}): {error_msg}") + data = response.json() + cls.valid_token = data.get("OK") + if not cls.valid_token: + raise ValueError("Response missing 'OK' field with token") + uuid.UUID(cls.valid_token) + except Exception as e: + raise unittest.SkipTest(f"Не удалось зарегистрировать пользователя: {e}") + + @classmethod + def tearDownClass(cls): + """Удаляем пользователя после всех тестов.""" + if cls.valid_token: + try: + headers = {"Token": cls.valid_token} + response = requests.post(f"{BASE_URL}/unregister/", headers=headers, timeout=10) + if response.status_code != 200: + error_msg = get_error_message(response) + print(f"⚠️ Внимание: unregister завершился с кодом {response.status_code}: {error_msg}") + except Exception as e: + print(f"⚠️ Ошибка при unregister: {e}") + super().tearDownClass() + + def test_1_get_shop_products(self): + """GET /shop/ — должен вернуть непустой список товаров""" + response = requests.get(f"{BASE_URL}/shop/") + if response.status_code != 200: + error_msg = get_error_message(response) + self.fail(f"GET /shop/ failed ({response.status_code}): {error_msg}") + + data = response.json() + self.assertIn("OK", data, f"Ответ не содержит 'OK'. Получено: {data}") + products = data["OK"] + self.assertIsInstance(products, list, f"'OK' должен быть списком, получено: {type(products)}") + self.assertGreater(len(products), 0, "Список товаров пуст") + + product = products[0] + required = {"id", "name", "count", "reserved", "picture_url", "description", "type", "cost"} + missing = required - set(product.keys()) + self.assertFalse(missing, f"В товаре отсутствуют поля: {missing}. Товар: {product}") + self.assertIsInstance(product["cost"], (int, float), f"'cost' должен быть числом, получено: {type(product['cost'])}") + + def test_2_registration_returns_valid_token(self): + """Проверка, что токен — валидный UUID""" + self.assertIsNotNone(self.valid_token, "Токен не был получен при регистрации") + try: + uuid.UUID(self.valid_token) + except ValueError: + self.fail(f"Токен не является валидным UUID: {self.valid_token}") + + def test_3_basket_success(self): + """POST /basket/ с валидным токеном → возвращает '[]'""" + headers = {"Token": self.valid_token} + response = requests.post(f"{BASE_URL}/basket/", headers=headers) + if response.status_code != 200: + error_msg = get_error_message(response) + self.fail(f"POST /basket/ failed ({response.status_code}): {error_msg}") + + data = response.json() + self.assertIn("OK", data, f"Ответ не содержит 'OK'. Получено: {data}") + self.assertEqual(data["OK"], '[]', f"Ожидалась пустая корзина ('[]'), получено: {data['OK']}") + + def test_4_history_success(self): + """POST /history/ с валидным токеном → возвращает '[]'""" + headers = {"Token": self.valid_token} + response = requests.post(f"{BASE_URL}/history/", headers=headers) + if response.status_code != 200: + error_msg = get_error_message(response) + self.fail(f"POST /history/ failed ({response.status_code}): {error_msg}") + + data = response.json() + self.assertIn("OK", data, f"Ответ не содержит 'OK'. Получено: {data}") + self.assertEqual(data["OK"], '[]', f"Ожидалась пустая история ('[]'), получено: {data['OK']}") + + def test_5_basket_invalid_token(self): + """POST /basket/ с невалидным токеном → ошибка 500""" + headers = {"Token": "invalid-token-12345"} + response = requests.post(f"{BASE_URL}/basket/", headers=headers) + if response.status_code != 500: + error_msg = get_error_message(response) + self.fail(f"Ожидался статус 500 для невалидного токена, получен {response.status_code}. Ошибка: {error_msg}") + + data = response.json() + self.assertIn("error", data, f"Ответ должен содержать 'error', получено: {data}") + self.assertIsInstance(data["error"], str, f"'error' должен быть строкой, получено: {type(data['error'])}") + self.assertGreater(len(data["error"]), 0, "Сообщение об ошибке пустое") + + def test_6_history_invalid_token(self): + """POST /history/ с невалидным токеном → ошибка 500""" + headers = {"Token": str(uuid.uuid4())} + response = requests.post(f"{BASE_URL}/history/", headers=headers) + if response.status_code != 500: + error_msg = get_error_message(response) + self.fail(f"Ожидался статус 500 для невалидного токена, получен {response.status_code}. Ошибка: {error_msg}") + + data = response.json() + self.assertIn("error", data, f"Ответ должен содержать 'error', получено: {data}") + + def test_7_login_success(self): + """POST /login/ — успешная аутентификация зарегистрированного пользователя""" + login_payload = { + "email": self.test_email, + "password": self.test_password + } + response = requests.post(f"{BASE_URL}/login/", json=login_payload) + if response.status_code != 200: + error_msg = get_error_message(response) + self.fail(f"POST /login/ failed ({response.status_code}): {error_msg}") + + data = response.json() + self.assertIn("OK", data, f"Ответ не содержит 'OK'. Получено: {data}") + login_token = data["OK"] + + try: + uuid.UUID(login_token) + except ValueError: + self.fail(f"Токен от /login/ не является валидным UUID: {login_token}") + + # Проверка, что токен работает + basket_resp = requests.post(f"{BASE_URL}/basket/", headers={"Token": login_token}) + if basket_resp.status_code != 200: + error_msg = get_error_message(basket_resp) + self.fail(f"Токен от /login/ не работает: {error_msg}") + + def test_8_logout_success(self): + """POST /logout/ с валидным токеном — успешный выход""" + headers = {"Token": self.valid_token} + response = requests.post(f"{BASE_URL}/logout/", headers=headers) + if response.status_code != 200: + error_msg = get_error_message(response) + self.fail(f"POST /logout/ failed ({response.status_code}): {error_msg}") + + data = response.json() + self.assertIn("OK", data, f"Ответ не содержит 'OK'. Получено: {data}") + self.assertEqual(data["OK"], self.valid_token, "В ответе должен быть тот же токен") + + def test_9_all_scenarios_completed(self): + """Финальная проверка: все тесты пройдены""" + pass + + +if __name__ == "__main__": + unittest.main(verbosity=2)