From 881ef45be5e4223af6e8644894df2ffab46ee38f Mon Sep 17 00:00:00 2001 From: Merrow <5x3.root@gmail.com> Date: Sun, 11 Oct 2026 23:00:20 +0500 Subject: [PATCH] =?UTF-8?q?CI/CD:=20Gitea=20Actions=20=E2=80=94=20=D1=82?= =?UTF-8?q?=D0=B5=D1=81=D1=82=D1=8B=20CRM=20(sqlite)=20=D0=B8=20=D0=B0?= =?UTF-8?q?=D0=B2=D1=82=D0=BE=D0=B4=D0=B5=D0=BF=D0=BB=D0=BE=D0=B9=20=D0=BD?= =?UTF-8?q?=D0=B0=20VPS?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - crm/tests: 27 тестов через настоящий server.main на sqlite-бэкенде (валидация заказов, цена только из каталога, honeypot, дедупликация, трекинг и отмена гостем, стоп-лист, аутентификация, гигиена текста, гейт расписания бара) - .gitea/workflows/ci.yml: test-джоба + deploy (только push в main, после тестов): rsync кода -> pre-flight docker compose config -> pg_dump бэкап БД (ротация 7) -> up -d --build -> health-check - деплой на neon-bar86.ru идёт деплой-ключом из secrets репозитория --- .gitea/workflows/ci.yml | 85 +++++++++++++++++++++++ crm/requirements-dev.txt | 1 + crm/tests/_server_main.py | 15 ++++ crm/tests/conftest.py | 115 ++++++++++++++++++++++++++++++ crm/tests/test_auth.py | 35 ++++++++++ crm/tests/test_gate_and_text.py | 73 ++++++++++++++++++++ crm/tests/test_orders.py | 119 ++++++++++++++++++++++++++++++++ crm/tests/test_public.py | 26 +++++++ 8 files changed, 469 insertions(+) create mode 100644 .gitea/workflows/ci.yml create mode 100644 crm/requirements-dev.txt create mode 100644 crm/tests/_server_main.py create mode 100644 crm/tests/conftest.py create mode 100644 crm/tests/test_auth.py create mode 100644 crm/tests/test_gate_and_text.py create mode 100644 crm/tests/test_orders.py create mode 100644 crm/tests/test_public.py diff --git a/.gitea/workflows/ci.yml b/.gitea/workflows/ci.yml new file mode 100644 index 0000000..e455406 --- /dev/null +++ b/.gitea/workflows/ci.yml @@ -0,0 +1,85 @@ +name: CI/CD + +on: + push: + +jobs: + test: + name: Тесты CRM + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Синтаксис Python + run: python3 -m compileall -q crm + + - name: Тесты CRM (sqlite-бэкенд, чистый каталог данных) + run: | + cd crm + rm -rf data + python3 -m pip install --break-system-packages -q -r requirements-dev.txt + python3 -m pytest tests -q + + deploy: + name: Деплой neon-bar86.ru + runs-on: ubuntu-latest + needs: test + if: github.event_name == 'push' && github.ref == 'refs/heads/main' + steps: + - uses: actions/checkout@v4 + + - name: SSH-окружение + run: | + mkdir -p ~/.ssh + printf '%s\n' "${{ secrets.DEPLOY_KNOWN_HOSTS }}" > ~/.ssh/known_hosts + printf '%s\n' "${{ secrets.DEPLOY_SSH_KEY }}" > ~/.ssh/deploy_key + chmod 600 ~/.ssh/deploy_key ~/.ssh/known_hosts + + - name: Код на сервер (rsync; .env, media, data и серверные файлы не трогаем) + run: | + command -v rsync >/dev/null || { apt-get update -qq && apt-get install -y -qq rsync; } + rsync -az --delete \ + -e "ssh -i ~/.ssh/deploy_key" \ + --exclude .git/ --exclude .env --exclude .agents/ \ + --exclude crm/media/ --exclude crm/data/ --exclude crm/tests/ \ + --exclude '*.zip' --exclude README.md --exclude SEO-REGISTRATIONS.md \ + --exclude backups/ \ + ./ ${{ secrets.DEPLOY_USER }}@${{ secrets.DEPLOY_HOST }}:${{ secrets.DEPLOY_DIR }}/ + + # Гейт №1: сломанный compose/.env падает здесь, живые контейнеры не тронуты + - name: Pre-flight — валидация compose и env + run: | + ssh -i ~/.ssh/deploy_key ${{ secrets.DEPLOY_USER }}@${{ secrets.DEPLOY_HOST }} \ + "cd ${{ secrets.DEPLOY_DIR }} && docker compose config -q && echo 'compose+env ok'" + + # Гейт №2: дамп БД до любых изменений — деплой всегда обратим по данным + - name: Бэкап БД перед деплоем (ротация: храним 7) + run: | + ssh -i ~/.ssh/deploy_key ${{ secrets.DEPLOY_USER }}@${{ secrets.DEPLOY_HOST }} \ + "cd ${{ secrets.DEPLOY_DIR }} && mkdir -p backups \ + && docker exec neon-db sh -c 'pg_dump -U \$POSTGRES_USER \$POSTGRES_DB' | gzip > backups/db-pre-deploy-\$(date +%F-%H%M%S).sql.gz \ + && test \$(stat -c%s backups/db-pre-deploy-*.sql.gz | tail -1) -gt 200 \ + && ls -1t backups/db-pre-deploy-*.sql.gz | tail -n +8 | xargs -r rm -f \ + && echo 'db backup ok'" + + - name: Сборка и перезапуск + run: | + ssh -i ~/.ssh/deploy_key ${{ secrets.DEPLOY_USER }}@${{ secrets.DEPLOY_HOST }} \ + "cd ${{ secrets.DEPLOY_DIR }} && docker compose up -d --build 2>&1 | tail -15" + + # Гейт №3: не поднялось — пайплайн красный (данные в томе pgdata целы, + # откат кода: git revert + перезапуск пайплайна; откат данных: дамп из backups/) + - name: Health-check (сайт + API сквозь nginx) + run: | + ssh -i ~/.ssh/deploy_key ${{ secrets.DEPLOY_USER }}@${{ secrets.DEPLOY_HOST }} \ + 'for i in $(seq 1 24); do + curl -fsS --max-time 5 http://127.0.0.1:8080/api/healthz >/dev/null 2>&1 && + curl -fsS --max-time 5 http://127.0.0.1:8080/ >/dev/null 2>&1 && exit 0 + sleep 5 + done + echo "health check failed"; exit 1' + + - name: Чистка dangling-образов (диск 82%) + if: always() + run: | + ssh -i ~/.ssh/deploy_key ${{ secrets.DEPLOY_USER }}@${{ secrets.DEPLOY_HOST }} "docker image prune -f" diff --git a/crm/requirements-dev.txt b/crm/requirements-dev.txt new file mode 100644 index 0000000..e96a830 --- /dev/null +++ b/crm/requirements-dev.txt @@ -0,0 +1 @@ +pytest>=8,<10 diff --git a/crm/tests/_server_main.py b/crm/tests/_server_main.py new file mode 100644 index 0000000..c44877c --- /dev/null +++ b/crm/tests/_server_main.py @@ -0,0 +1,15 @@ +# -*- coding: utf-8 -*- +"""Тестовый запуск CRM: фиксированное барное время (пятница 21:00 — сессия +открыта) поверх обычного server.main() — инициализация схемы, сид каталога, +проверка паролей и лимитеры работают ровно как в бою.""" +import os +import sys +from datetime import datetime + +sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) + +import server + +server.bar_now = lambda: datetime(2026, 10, 9, 21, 0, tzinfo=server.BAR_TZ) + +server.main() diff --git a/crm/tests/conftest.py b/crm/tests/conftest.py new file mode 100644 index 0000000..4568b60 --- /dev/null +++ b/crm/tests/conftest.py @@ -0,0 +1,115 @@ +# -*- coding: utf-8 -*- +"""Поднимает CRM как настоящий процесс (server.main) на sqlite-бэкенде. + +- CRM_DB=sqlite — тот же режим, что и локальная разработка; файл базы + crm/data/crm.db (в CI каталог данных пересоздаётся с нуля — шаг в workflow). +- tests/_server_main.py фиксирует барное время на пятнице 21:00 (бар открыт), + иначе гейт «заказы по времени» делает тесты зависимыми от часа запуска CI. + +Гейт расписания и гигиена гостевого текста тестируются отдельно — как чистые +функции, без сервера (test_gate_and_text.py). +""" +import json +import os +import socket +import subprocess +import sys +import tempfile +import time +import urllib.error +import urllib.request +from pathlib import Path + +import pytest + +CRM_DIR = Path(__file__).resolve().parents[1] + +# Пароли первого старта — только из окружения (F-2), дефолтные запрещены (F-13) +os.environ.setdefault("CRM_OWNER_PASSWORD", "ci-owner-pass-1") +os.environ.setdefault("CRM_ADMIN_PASSWORD", "ci-admin-pass-1") +os.environ.setdefault("CRM_WAITER_PASSWORD", "ci-waiter-pass-1") +os.environ["CRM_ALLOW_DEFAULT_PASSWORDS"] = "0" +os.environ["CRM_SEED_DEMO_ORDERS"] = "0" +# и в самом процессе тестов (юнит-тесты импортируют server/storage напрямую): +# sqlite-ветка storage не требует psycopg +os.environ["CRM_DB"] = "sqlite" + + +def _free_port() -> int: + s = socket.socket() + s.bind(("127.0.0.1", 0)) + port = s.getsockname()[1] + s.close() + return port + + +class Api: + """Минимальный HTTP-клиент: возвращает (status, json-тело) для любого ответа, + включая 4xx/5xx — тестам нужен именно статус, а не исключение.""" + + def __init__(self, base: str): + self.base = base + + def request(self, method, path, body=None, token=None): + data = None + headers = {} + if body is not None: + data = json.dumps(body).encode("utf-8") + headers["Content-Type"] = "application/json" + if token: + headers["X-Auth-Token"] = token + req = urllib.request.Request(self.base + path, data=data, headers=headers, method=method) + try: + with urllib.request.urlopen(req, timeout=10) as resp: + raw = resp.read() + return resp.status, (json.loads(raw) if raw else None) + except urllib.error.HTTPError as e: + raw = e.read() + try: + return e.code, (json.loads(raw) if raw else None) + except ValueError: + return e.code, None + + +@pytest.fixture(scope="session") +def api(): + port = _free_port() + env = {**os.environ, "CRM_DB": "sqlite", "CRM_PORT": str(port)} + log = tempfile.TemporaryFile() + proc = subprocess.Popen( + [sys.executable, str(CRM_DIR / "tests" / "_server_main.py")], + cwd=CRM_DIR, env=env, stdout=log, stderr=subprocess.STDOUT, + ) + base = f"http://127.0.0.1:{port}" + for _ in range(150): + if proc.poll() is not None: + log.seek(0) + raise RuntimeError("CRM не поднялся:\n" + log.read().decode("utf-8", "replace")) + try: + with urllib.request.urlopen(base + "/api/healthz", timeout=2) as r: + if r.status == 200: + break + except Exception: + time.sleep(0.2) + else: + proc.terminate() + raise RuntimeError("CRM не ответил на /api/healthz за 30 секунд") + yield Api(base) + proc.terminate() + try: + proc.wait(timeout=5) + except subprocess.TimeoutExpired: + proc.kill() + proc.wait(timeout=5) + log.close() + + +@pytest.fixture(scope="session") +def owner_token(api): + status, body = api.request( + "POST", "/api/login", + {"login": "owner", "password": os.environ["CRM_OWNER_PASSWORD"]}, + ) + assert status == 200, body + assert body["role"] == "owner" + return body["token"] diff --git a/crm/tests/test_auth.py b/crm/tests/test_auth.py new file mode 100644 index 0000000..f38cb04 --- /dev/null +++ b/crm/tests/test_auth.py @@ -0,0 +1,35 @@ +# -*- coding: utf-8 -*- +"""Аутентификация и защита приватных эндпоинтов.""" + + +def test_login_wrong_creds(api): + status, body = api.request("POST", "/api/login", {"login": "owner", "password": "nope"}) + assert status == 401 + assert "error" in body + + +def test_login_owner_returns_session(owner_token): + assert isinstance(owner_token, str) and len(owner_token) > 20 + + +def test_protected_endpoint_requires_token(api): + status, body = api.request("GET", "/api/menu") + assert status == 401 + assert "error" in body + + +def test_protected_endpoint_with_token(api, owner_token): + status, body = api.request("GET", "/api/menu", token=owner_token) + assert status == 200 + + +def test_healthz_public(api): + status, body = api.request("GET", "/api/healthz") + assert status == 200 + assert body.get("ok") is True + + +def test_unknown_api_path_is_json_404(api): + status, body = api.request("GET", "/api/definitely-not-a-route") + assert status == 404 + assert "error" in body diff --git a/crm/tests/test_gate_and_text.py b/crm/tests/test_gate_and_text.py new file mode 100644 index 0000000..91d5572 --- /dev/null +++ b/crm/tests/test_gate_and_text.py @@ -0,0 +1,73 @@ +# -*- coding: utf-8 -*- +"""Чистые функции без сервера: гигиена гостевого текста и гейт «бар открыт».""" +import sys +from datetime import datetime, timedelta +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parents[1])) + +from server import BAR_TZ, bar_is_open, next_bar_opening # noqa: E402 +from storage import clean_guest_text # noqa: E402 + +# 2026-10-05 — понедельник; от него строим любой день недели +MONDAY = datetime(2026, 10, 5, 12, 0, tzinfo=BAR_TZ) + + +def dt(day: str, hour: int, minute: int = 0) -> datetime: + shift = "mon tue wed thu fri sat sun".split().index(day) + return MONDAY.replace(hour=hour, minute=minute) + timedelta(days=shift) + + +# ------------------------------------------------------------- clean_guest_text + +def test_clean_text_removes_hidden_chars(): + assert clean_guest_text("Кола\u200b", 60) == "Кола" # zero-width space + assert clean_guest_text("\ufeffпиво\u200e", 60) == "пиво" # BOM + LRM + assert clean_guest_text("a\x00b\x1f[c]\x7f", 60) == "ab[c]" # control chars + assert clean_guest_text("ab\u202edc", 60) == "abdc" # bidi override + + +def test_clean_text_keeps_normal_text_and_emoji(): + text = "Столик у окна, пиво 🍺 и — тире" + assert clean_guest_text(text, 60) == text + + +def test_clean_text_truncates_to_limit(): + assert len(clean_guest_text("х" * 500, 60)) == 60 + + +# ------------------------------------------------------------------ бар открыт? + +def test_bar_open_in_the_evening(): + assert bar_is_open(dt("fri", 21)) is True + assert bar_is_open(dt("sat", 21)) is True + assert bar_is_open(dt("sun", 20)) is True + + +def test_bar_closed_monday_evening(): + # понедельник — вечерних сессий нет (BAR_CLOSED_WEEKDAY) + assert bar_is_open(dt("mon", 21)) is False + + +def test_bar_tail_after_midnight(): + assert bar_is_open(dt("sat", 2)) is True # хвост пятницы до 03:00 + assert bar_is_open(dt("sun", 2)) is True # хвост субботы до 03:00 + assert bar_is_open(dt("tue", 0, 30)) is False # во вторник хвоста нет + assert bar_is_open(dt("sat", 3, 30)) is False # хвост закончился + + +def test_preorder_window_is_one_hour(): + now = dt("fri", 18, 30) + opening = next_bar_opening(now) + assert opening is not None and (opening - now) <= timedelta(hours=1) + + now2 = dt("fri", 17, 0) + opening2 = next_bar_opening(now2) + assert (opening2 - now2) > timedelta(hours=1) + + +def test_next_opening_skips_monday(): + # воскресенье 21:00 → ближайшее открытие во вторник 19:00, не в понедельник + opening = next_bar_opening(dt("sun", 21)) + assert opening is not None + assert opening.weekday() == 1 # вторник diff --git a/crm/tests/test_orders.py b/crm/tests/test_orders.py new file mode 100644 index 0000000..0af7497 --- /dev/null +++ b/crm/tests/test_orders.py @@ -0,0 +1,119 @@ +# -*- coding: utf-8 -*- +"""Публичное оформление заказа с сайта: валидация состава, honeypot, +цена только из каталога, трекинг и отмена гостем. + +Барное время зафиксировано на пятнице 21:00 (см. tests/_server_main.py), +поэтому гейт «бар закрыт» не мешает проверять валидацию в любое время суток. +""" +import pytest + + +@pytest.fixture() +def table(api): + status, body = api.request("GET", "/api/halls/names") + assert status == 200 and body.get("tables") + return body["tables"][0] + + +_ORDER_SEQ = [0] + + +def _order(api, table, items, **extra): + # уникальный guest_phone на каждый заказ: дедупликация (тот же состав за + # минуту → 409) не должна превращать быстрые тесты в ложные конфликты + _ORDER_SEQ[0] += 1 + payload = { + "table_name": table, + "items": items, + "guest_phone": f"+7900000{_ORDER_SEQ[0]:04d}", + } + payload.update(extra) + return api.request("POST", "/api/orders", payload) + + +def test_order_requires_items(api, table): + status, body = api.request("POST", "/api/orders", {"table_name": table}) + assert status == 400 + assert "items" in body["error"] + + +def test_order_unknown_item_rejected(api, table): + # состав заказа валидируется строго по каталогу CRM — клиентские + # name/price игнорируются, неизвестные позиции отклоняют заказ + status, body = _order(api, table, [{"id": "no-such-item", "qty": 1}]) + assert status == 400 + + +def test_order_unknown_table_rejected(api): + status, body = _order(api, "несуществующий-стол-12345", [{"id": "blackberry-mint", "qty": 1}]) + assert status == 400 + + +def test_honeypot_swallows_bot_order(api, table): + # скрытое поле hp_check: отвечаем боту успехом, заказ не создаём + status, body = _order(api, table, [{"id": "blackberry-mint", "qty": 1}], hp_check="spam") + assert status == 200 and body.get("ok") is True + + +def test_order_price_comes_from_catalog(api, table): + # 2 × 350 ₽ из crm/seed/menu.json: подмена цены клиентом невозможна + status, order = _order(api, table, [{"id": "blackberry-mint", "qty": 2, "price": 1}]) + assert status == 201 + assert order["status"] == "new" + assert order["price"] == 700 + assert order["code"] + + +def test_qty_clamped_to_50(api, table): + status, order = _order(api, table, [{"id": "blackberry-mint", "qty": 999}]) + assert status == 201 + item = next(i for i in order["items"] if i["id"] == "blackberry-mint") + assert item["qty"] == 50 + + +def test_track_then_guest_cancel(api, table): + status, order = _order(api, table, [{"id": "blackberry-mint", "qty": 1}]) + assert status == 201 + code = order["code"] + + status, tracked = api.request("GET", f"/api/track/{code}") + assert status == 200 + assert tracked["status"] == "new" + + # гость отменяет случайный заказ, пока он «new» + status, _ = api.request("POST", f"/api/track/{code}/cancel") + assert status in (200, 201) + + status, tracked = api.request("GET", f"/api/track/{code}") + assert status == 200 + assert tracked["status"] == "cancelled" + + +def test_double_cancel_conflict(api, table): + status, order = _order(api, table, [{"id": "blackberry-mint", "qty": 1}]) + assert status == 201 + code = order["code"] + + status, _ = api.request("POST", f"/api/track/{code}/cancel") + assert status in (200, 201) + status, body = api.request("POST", f"/api/track/{code}/cancel") + assert status == 409 + + +def test_stoplisted_item_rejects_order(api, table, owner_token): + # владелец уводит позицию в стоп-лист → заказ с ней отклоняется целиком + status, _ = api.request( + "PATCH", "/api/menu/blackberry-mint", + {"stopped": True}, token=owner_token, + ) + assert status in (200, 201) + try: + status, body = _order(api, table, [{"id": "blackberry-mint", "qty": 1}]) + assert status == 400 + assert "стоп" in body["error"] + finally: + status, _ = api.request( + "PATCH", "/api/menu/blackberry-mint", + {"stopped": False}, token=owner_token, + ) + assert status in (200, 201) diff --git a/crm/tests/test_public.py b/crm/tests/test_public.py new file mode 100644 index 0000000..f8b9307 --- /dev/null +++ b/crm/tests/test_public.py @@ -0,0 +1,26 @@ +# -*- coding: utf-8 -*- +"""Публичные (безавторизационные) эндпоинты сайта.""" + + +def test_catalog_public(api): + status, body = api.request("GET", "/api/catalog") + assert status == 200 + # «новинки» от владельца — на свежей базе список внутри может быть пуст + assert body is not None + + +def test_stoplist_public(api): + status, body = api.request("GET", "/api/stoplist") + assert status == 200 + + +def test_hall_names_public(api): + status, body = api.request("GET", "/api/halls/names") + assert status == 200 + assert isinstance(body.get("tables"), list) and body["tables"] + + +def test_track_unknown_code_404(api): + status, body = api.request("GET", "/api/track/ZZZZZZZZ") + assert status == 404 + assert "error" in body