- 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 репозитория
This commit is contained in:
1 parent
da5b259d06
commit
881ef45be5
8 files changed
+469
No files matched your search
@@ -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"
|
||||
@@ -0,0 +1 @@
|
||||
pytest>=8,<10
|
||||
@@ -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()
|
||||
@@ -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"]
|
||||
@@ -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
|
||||
@@ -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 # вторник
|
||||
@@ -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)
|
||||
@@ -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
|
||||
Reference in new issue
Block a user