From 92791f1ba9ff094be4074c5158edf411f86ddc5d Mon Sep 17 00:00:00 2001 From: MpakobecEx Date: Mon, 19 Oct 2026 03:58:25 +0500 Subject: [PATCH] =?UTF-8?q?=D0=90=D0=B4=D0=BC=D0=B8=D0=BD=20=D0=BC=D0=BE?= =?UTF-8?q?=D0=B6=D0=B5=D1=82=20=D0=BF=D1=80=D0=B0=D0=B2=D0=B8=D1=82=D1=8C?= =?UTF-8?q?=20=D0=B8=D0=B3=D1=80=D0=BE=D0=BA=D0=BE=D0=B2,=20=D0=BD=D0=B5?= =?UTF-8?q?=20=D0=B7=D0=B0=D1=85=D0=BE=D0=B4=D1=8F=20=D0=B2=20=D0=B1=D0=B0?= =?UTF-8?q?=D0=B7=D1=83?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit В карточке появились ник, звание, подписка на рассылки и корректировка баланса: баланс меняется обычной записью в журнал money с действием «Корректировка админом», поэтому /инфо сразу показывает новую сумму. Все четыре действия пишутся в bot.user_changes — кто, когда, было → стало и причина; история видна в той же карточке. Бот читает эти таблицы напрямую, так что изменения действуют без перезапуска. --- README.md | 7 +- admin/routes/users.py | 156 +++++++++++++++++++--- admin/templates/users/card.html | 130 ++++++++++++++++++ common/repositories/users_admin_repo.py | 170 ++++++++++++++++++++++++ common/version.py | 2 +- sql/V18__user_changes.sql | 22 +++ tests/test_admin_users.py | 150 ++++++++++++++++++++- tests/test_version.py | 2 +- 8 files changed, 618 insertions(+), 21 deletions(-) create mode 100644 sql/V18__user_changes.sql diff --git a/README.md b/README.md index ebeb4af..c854a87 100644 --- a/README.md +++ b/README.md @@ -74,7 +74,10 @@ HttpOnly-cookie (`ADMIN_SESSION_SECRET`), все формы защищены CSR Раздел «Пользователи» — список с поиском по нику/username/имени/Telegram-ID и карточка: уровень, опыт и серия, баланс (`SUM(money.amount)`), яички, сообщения, очищения, -подписка на рассылки, дата вступления и последние операции по балансу. +подписка на рассылки, дата вступления и последние операции по балансу. В карточке можно +сменить ник и звание, включить/выключить подписку и скорректировать баланс (запись в +журнал `money` с действием «Корректировка админом»). Каждое ручное изменение пишется в +`bot.user_changes`: кто, когда, было → стало и причина. ## Аналитика (ClickHouse) @@ -137,7 +140,7 @@ make backup E=test # на домашнем сервере: ./scripts/clickho ## Версионирование -- Единый источник версии — `common/version.py` (`__version__`), текущая — `0.4.6`. +- Единый источник версии — `common/version.py` (`__version__`), текущая — `0.4.7`. - Схема — SemVer-подобная (`MAJOR.MINOR.PATCH`); git-тег = `v` + версия (`v0.1`). - Docker-образы тегируются версией (Блок 2), события ClickHouse получают `bot_version` (Блок 3). - Выпуск: изменить `common/version.py` → коммит → аннотированный тег `vX.Y` → `git push --tags`. diff --git a/admin/routes/users.py b/admin/routes/users.py index 31615e1..658861a 100644 --- a/admin/routes/users.py +++ b/admin/routes/users.py @@ -1,10 +1,11 @@ -"""Раздел «Пользователи»: список с поиском и карточка игрока.""" +"""Раздел «Пользователи»: список с поиском, карточка и управление.""" from __future__ import annotations -from fastapi import APIRouter, HTTPException, Request -from fastapi.responses import HTMLResponse +from fastapi import APIRouter, HTTPException, Request, Response +from fastapi.responses import HTMLResponse, RedirectResponse +from admin.security import check_csrf from common import __version__ from common.repositories import users_admin_repo @@ -14,6 +15,42 @@ router = APIRouter(prefix="/users", tags=["users"]) PAGE_SIZE = 25 +def _editor_id(request: Request) -> int: + """Telegram-ID текущего администратора (ставит middleware).""" + return int(request.state.admin["telegram_id"]) + + +async def _card_response( + request: Request, + user_id: int, + *, + errors: list[str] | None = None, + saved: str | None = None, + status_code: int = 200, +) -> HTMLResponse: + """Отрисовать карточку (общая точка для GET и ошибок валидации).""" + user = await users_admin_repo.get_user(user_id) + if user is None: + raise HTTPException(status_code=404, detail="Пользователь не найден") + + return request.app.state.templates.TemplateResponse( + request=request, + name="users/card.html", + context={ + "version": __version__, + "user": user, + "money": await users_admin_repo.money_history(user_id), + "titles": await users_admin_repo.list_titles(), + "history": await users_admin_repo.user_history(user_id), + "errors": errors or [], + "saved": saved, + "max_nick_length": users_admin_repo.MAX_NICK_LENGTH, + "max_comment_length": users_admin_repo.MAX_COMMENT_LENGTH, + }, + status_code=status_code, + ) + + @router.get("", response_class=HTMLResponse) async def list_users(request: Request, q: str = "", page: int = 1) -> HTMLResponse: """Список пользователей: поиск по нику/username/имени/ID и пагинация.""" @@ -40,18 +77,105 @@ async def list_users(request: Request, q: str = "", page: int = 1) -> HTMLRespon @router.get("/{user_id}", response_class=HTMLResponse) -async def user_card(request: Request, user_id: int) -> HTMLResponse: - """Карточка пользователя со сводкой и последними операциями по балансу.""" - user = await users_admin_repo.get_user(user_id) - if user is None: - raise HTTPException(status_code=404, detail="Пользователь не найден") +async def user_card(request: Request, user_id: int, saved: str | None = None) -> HTMLResponse: + """Карточка пользователя: сводка, управление, история и операции.""" + return await _card_response(request, user_id, saved=saved) - return request.app.state.templates.TemplateResponse( - request=request, - name="users/card.html", - context={ - "version": __version__, - "user": user, - "money": await users_admin_repo.money_history(user_id), - }, + +@router.post("/{user_id}/nick") +async def update_nick(request: Request, user_id: int) -> Response: + """Сменить ник игрока.""" + form = await request.form() + check_csrf(request, str(form.get("csrf_token", ""))) + nick = str(form.get("nick", "")).strip() + + errors: list[str] = [] + if not nick: + errors.append("Ник не может быть пустым") + elif len(nick) > users_admin_repo.MAX_NICK_LENGTH: + errors.append(f"Ник длиннее {users_admin_repo.MAX_NICK_LENGTH} символов") + elif any(char.isspace() for char in nick): + errors.append("Ник не должен содержать пробелы") + + if errors: + return await _card_response(request, user_id, errors=errors, status_code=400) + + if not await users_admin_repo.set_nick(user_id, nick, editor_id=_editor_id(request)): + raise HTTPException(status_code=404, detail="Пользователь не найден") + return RedirectResponse(url=f"/users/{user_id}?saved=nick", status_code=303) + + +@router.post("/{user_id}/title") +async def update_title(request: Request, user_id: int) -> Response: + """Сменить звание игрока.""" + form = await request.form() + check_csrf(request, str(form.get("csrf_token", ""))) + + try: + title_id = int(str(form.get("title_id", ""))) + except (TypeError, ValueError): + return await _card_response(request, user_id, errors=["Выберите звание"], status_code=400) + + titles = await users_admin_repo.list_titles() + if not any(int(title["id_title"]) == title_id for title in titles): + return await _card_response(request, user_id, errors=["Звание не найдено"], status_code=400) + + if not await users_admin_repo.set_title(user_id, title_id, editor_id=_editor_id(request)): + raise HTTPException(status_code=404, detail="Пользователь не найден") + return RedirectResponse(url=f"/users/{user_id}?saved=title", status_code=303) + + +@router.post("/{user_id}/subscription") +async def update_subscription(request: Request, user_id: int) -> Response: + """Подписать или отписать от рассылок.""" + form = await request.form() + check_csrf(request, str(form.get("csrf_token", ""))) + + value = str(form.get("subscribed", "")) + if value not in {"0", "1"}: + return await _card_response(request, user_id, errors=["Некорректное значение подписки"], status_code=400) + + if not await users_admin_repo.set_subscription( + user_id, + value == "1", + editor_id=_editor_id(request), + ): + raise HTTPException(status_code=404, detail="Пользователь не найден") + return RedirectResponse(url=f"/users/{user_id}?saved=subscription", status_code=303) + + +@router.post("/{user_id}/balance") +async def update_balance(request: Request, user_id: int) -> Response: + """Скорректировать баланс записью в журнал money (действие «Корректировка админом»).""" + form = await request.form() + check_csrf(request, str(form.get("csrf_token", ""))) + + raw_amount = str(form.get("amount", "")).strip() + reason = str(form.get("reason", "")).strip() + + errors: list[str] = [] + try: + amount = int(raw_amount) + except (TypeError, ValueError): + amount = 0 + errors.append("Сумма должна быть целым числом") + + if amount == 0 and "Сумма должна быть целым числом" not in errors: + errors.append("Сумма не может быть нулевой") + if not reason: + errors.append("Укажите причину корректировки") + elif len(reason) > users_admin_repo.MAX_COMMENT_LENGTH: + errors.append(f"Причина длиннее {users_admin_repo.MAX_COMMENT_LENGTH} символов") + + if errors: + return await _card_response(request, user_id, errors=errors, status_code=400) + + balance = await users_admin_repo.adjust_balance( + user_id, + amount, + reason, + editor_id=_editor_id(request), ) + if balance is None: + raise HTTPException(status_code=404, detail="Пользователь не найден") + return RedirectResponse(url=f"/users/{user_id}?saved=balance", status_code=303) diff --git a/admin/templates/users/card.html b/admin/templates/users/card.html index 7b78798..88f2b49 100644 --- a/admin/templates/users/card.html +++ b/admin/templates/users/card.html @@ -28,6 +28,22 @@ +{% if saved %} +
+ {% if saved == 'nick' %}Ник обновлён. + {% elif saved == 'title' %}Звание обновлено. + {% elif saved == 'subscription' %}Подписка обновлена. + {% elif saved == 'balance' %}Баланс скорректирован. + {% else %}Изменения сохранены.{% endif %} +
+{% endif %} + +{% if errors %} + +{% endif %} +
Уровень
@@ -72,6 +88,120 @@
+
+

Управление

+
+
+ + +
+ + +
+
+ +
+ + +
+ + +
+
+ +
+ + +
Рассылки
+

+ {% if user.subscribed %}Сейчас подписан.{% else %}Сейчас не подписан.{% endif %} +

+ +
+ +
+ +
Корректировка баланса
+
+ + + +
+

Запись уйдёт в журнал денег с действием «Корректировка админом».

+
+
+
+ +
+

История изменений

+ {% if history %} +
+ + + + + + + + + + + + {% set field_labels = {"nick": "Ник", "title": "Звание", "subscription": "Подписка", "balance": "Баланс"} %} + {% for item in history %} + + + + + + + + {% endfor %} + +
КогдаЧтоБыло → сталоКомментарийАдмин
{{ item.changed_at.strftime("%d.%m.%Y %H:%M") }}{{ field_labels.get(item.field, item.field) }}{{ item.old_value or "—" }} → {{ item.new_value or "—" }}{{ item.comment or "—" }}{{ item.editor_id or "—" }}
+
+ {% else %} +

Ручных изменений ещё не было.

+ {% endif %} +
+

Последние операции по балансу

{% if money %} diff --git a/common/repositories/users_admin_repo.py b/common/repositories/users_admin_repo.py index b6d0d12..5bdf2c2 100644 --- a/common/repositories/users_admin_repo.py +++ b/common/repositories/users_admin_repo.py @@ -37,6 +37,7 @@ _FROM = """ _SELECT = """ SELECT tg.id_user, tg.username, tg.first_name, tg.last_name, du.nick, du.lvl, du.expr, du.expr_streak, du.dt_create, + du.id_title, t.title, coalesce(msg.cnt, 0) AS msg_count, msg.last_dt AS last_message_at, @@ -111,3 +112,172 @@ async def money_history(user_id: int, *, limit: int = 10) -> list[dict[str, Any] (user_id, limit), ) return await cur.fetchall() + + +# --- Управление пользователем (Блок 4.3) ------------------------------------- + +#: Действие «Корректировка админом» из V18 (ручное изменение баланса). +ADMIN_ACTION_ID = 6 + +#: Ограничение длины ника (как в команде /ник). +MAX_NICK_LENGTH = 32 + +#: Ограничение длины комментария к корректировке баланса. +MAX_COMMENT_LENGTH = 200 + + +async def list_titles() -> list[dict[str, Any]]: + """Доступные звания для выбора в карточке.""" + async with get_pool().connection() as conn: + cur = await conn.execute("SELECT id_title, title FROM bot.titles WHERE visible ORDER BY price DESC, id_title") + return await cur.fetchall() + + +async def user_history(user_id: int, *, limit: int = 20) -> list[dict[str, Any]]: + """История ручных изменений пользователя.""" + async with get_pool().connection() as conn: + cur = await conn.execute( + """ + SELECT id, field, old_value, new_value, comment, editor_id, changed_at + FROM bot.user_changes + WHERE id_user = %s + ORDER BY changed_at DESC + LIMIT %s + """, + (user_id, limit), + ) + return await cur.fetchall() + + +async def _log_change( + conn: Any, + user_id: int, + field: str, + old_value: str | None, + new_value: str | None, + editor_id: int | None, + comment: str | None = None, +) -> None: + await conn.execute( + """ + INSERT INTO bot.user_changes (id_user, field, old_value, new_value, comment, editor_id) + VALUES (%s, %s, %s, %s, %s, %s) + """, + (user_id, field, old_value, new_value, comment, editor_id), + ) + + +async def set_nick(user_id: int, nick: str, *, editor_id: int | None = None) -> bool: + """Сменить ник (создаёт запись de_users, если её ещё нет).""" + async with get_pool().connection() as conn: + cur = await conn.execute("SELECT nick FROM bot.de_users WHERE id_user = %s FOR UPDATE", (user_id,)) + row = await cur.fetchone() + + if row is None: + await conn.execute("INSERT INTO bot.de_users (id_user, nick) VALUES (%s, %s)", (user_id, nick)) + old_nick = None + else: + old_nick = row["nick"] + if old_nick == nick: + return True + await conn.execute("UPDATE bot.de_users SET nick = %s WHERE id_user = %s", (nick, user_id)) + + await _log_change(conn, user_id, "nick", old_nick, nick, editor_id) + return True + + +async def set_title(user_id: int, title_id: int, *, editor_id: int | None = None) -> bool: + """Сменить звание пользователя. False — если пользователя или звания нет.""" + async with get_pool().connection() as conn: + cur = await conn.execute( + """ + SELECT du.id_title, coalesce(t.title, '') AS title + FROM bot.de_users AS du + LEFT JOIN bot.titles AS t ON t.id_title = du.id_title + WHERE du.id_user = %s + FOR UPDATE OF du + """, + (user_id,), + ) + current = await cur.fetchone() + if current is None: + return False + + cur = await conn.execute("SELECT title FROM bot.titles WHERE id_title = %s", (title_id,)) + title = await cur.fetchone() + if title is None: + return False + + if current["id_title"] == title_id: + return True + + await conn.execute("UPDATE bot.de_users SET id_title = %s WHERE id_user = %s", (title_id, user_id)) + await _log_change(conn, user_id, "title", current["title"], title["title"], editor_id) + return True + + +async def set_subscription(user_id: int, subscribed: bool, *, editor_id: int | None = None) -> bool: + """Подписать или отписать пользователя от рассылок.""" + async with get_pool().connection() as conn: + cur = await conn.execute("SELECT 1 FROM bot.tg_users WHERE id_user = %s", (user_id,)) + if await cur.fetchone() is None: + return False + + cur = await conn.execute("SELECT 1 FROM bot.unsubscribe WHERE id_user = %s", (user_id,)) + was_subscribed = await cur.fetchone() is None + if was_subscribed == subscribed: + return True + + if subscribed: + await conn.execute("DELETE FROM bot.unsubscribe WHERE id_user = %s", (user_id,)) + else: + await conn.execute( + "INSERT INTO bot.unsubscribe (id_user) VALUES (%s) ON CONFLICT DO NOTHING", + (user_id,), + ) + + await _log_change( + conn, + user_id, + "subscription", + "да" if was_subscribed else "нет", + "да" if subscribed else "нет", + editor_id, + ) + return True + + +async def adjust_balance( + user_id: int, + amount: int, + reason: str, + *, + editor_id: int | None = None, +) -> int | None: + """Изменить баланс записью в money. Возвращает новый баланс или None.""" + async with get_pool().connection() as conn: + cur = await conn.execute("SELECT 1 FROM bot.tg_users WHERE id_user = %s", (user_id,)) + if await cur.fetchone() is None: + return None + + cur = await conn.execute( + "SELECT coalesce(sum(amount), 0) AS balance FROM bot.money WHERE id_user = %s", (user_id,) + ) + row = await cur.fetchone() + old_balance = int(row["balance"]) + + await conn.execute( + "INSERT INTO bot.money (id_user, amount, id_action) VALUES (%s, %s, %s)", + (user_id, amount, ADMIN_ACTION_ID), + ) + new_balance = old_balance + amount + await _log_change( + conn, + user_id, + "balance", + str(old_balance), + str(new_balance), + editor_id, + comment=reason, + ) + return new_balance diff --git a/common/version.py b/common/version.py index 2fc6bee..4dd7a26 100644 --- a/common/version.py +++ b/common/version.py @@ -7,4 +7,4 @@ from __future__ import annotations -__version__ = "0.4.6" +__version__ = "0.4.7" diff --git a/sql/V18__user_changes.sql b/sql/V18__user_changes.sql new file mode 100644 index 0000000..71bdccb --- /dev/null +++ b/sql/V18__user_changes.sql @@ -0,0 +1,22 @@ +-- История ручных изменений пользователей из админки (Блок 4.3). +CREATE TABLE IF NOT EXISTS bot.user_changes ( + id bigserial PRIMARY KEY, + id_user bigint NOT NULL, + field text NOT NULL CHECK (field IN ('nick', 'title', 'subscription', 'balance')), + old_value text, + new_value text, + comment text, + editor_id bigint, + changed_at timestamptz NOT NULL DEFAULT now() +); + +CREATE INDEX IF NOT EXISTS idx_user_changes_user_time + ON bot.user_changes (id_user, changed_at DESC); + +COMMENT ON TABLE bot.user_changes IS 'Аудит ручных изменений пользователей администратором'; +COMMENT ON COLUMN bot.user_changes.field IS 'Что изменено: nick, title, subscription или balance'; + +-- Отдельное действие для ручных корректировок баланса (видно в истории операций). +INSERT INTO bot.actions (id_action, title) +VALUES (6, 'Корректировка админом') +ON CONFLICT (id_action) DO NOTHING; diff --git a/tests/test_admin_users.py b/tests/test_admin_users.py index 4d2e512..0a6e448 100644 --- a/tests/test_admin_users.py +++ b/tests/test_admin_users.py @@ -1,4 +1,4 @@ -"""Тесты раздела «Пользователи» (без БД — репозиторий подменяется).""" +"""Тесты раздела «Пользователи»: список, карточка и управление (без БД).""" from __future__ import annotations @@ -6,6 +6,7 @@ from datetime import UTC, datetime from typing import Any import pytest +from fastapi.testclient import TestClient from common.repositories import users_admin_repo @@ -19,6 +20,7 @@ USER: dict[str, Any] = { "expr": 120, "expr_streak": 2, "dt_create": datetime(2026, 1, 1, 12, 0, tzinfo=UTC), + "id_title": 1, "title": "Ветеран", "msg_count": 42, "last_message_at": datetime(2026, 10, 18, 20, 0, tzinfo=UTC), @@ -34,6 +36,23 @@ MONEY: list[dict[str, Any]] = [ {"id_money": 1, "amount": -20, "dt_action": datetime(2026, 10, 17, 12, 0, tzinfo=UTC), "action": "Покупка"}, ] +TITLES: list[dict[str, Any]] = [ + {"id_title": 1, "title": "Ветеран"}, + {"id_title": 2, "title": "Новичок"}, +] + +HISTORY: list[dict[str, Any]] = [ + { + "id": 1, + "field": "nick", + "old_value": "Старый", + "new_value": "ТестовыйНик", + "comment": None, + "editor_id": 1, + "changed_at": datetime(2026, 10, 18, 12, 0, tzinfo=UTC), + } +] + @pytest.fixture def fake_repo(monkeypatch: pytest.MonkeyPatch) -> dict[str, Any]: @@ -53,13 +72,51 @@ def fake_repo(monkeypatch: pytest.MonkeyPatch) -> dict[str, Any]: async def money_history(user_id: int, *, limit: int = 10) -> list[dict[str, Any]]: return MONEY if user_id == USER["id_user"] else [] + async def list_titles() -> list[dict[str, Any]]: + return TITLES + + async def user_history(user_id: int, *, limit: int = 20) -> list[dict[str, Any]]: + return HISTORY if user_id == USER["id_user"] else [] + + async def set_nick(user_id: int, nick: str, *, editor_id: int | None = None) -> bool: + state["nick"] = (user_id, nick, editor_id) + return user_id == USER["id_user"] + + async def set_title(user_id: int, title_id: int, *, editor_id: int | None = None) -> bool: + state["title"] = (user_id, title_id, editor_id) + return user_id == USER["id_user"] + + async def set_subscription(user_id: int, subscribed: bool, *, editor_id: int | None = None) -> bool: + state["subscription"] = (user_id, subscribed, editor_id) + return user_id == USER["id_user"] + + async def adjust_balance( + user_id: int, + amount: int, + reason: str, + *, + editor_id: int | None = None, + ) -> int | None: + state["balance"] = (user_id, amount, reason, editor_id) + return USER["balance"] + amount if user_id == USER["id_user"] else None + monkeypatch.setattr(users_admin_repo, "count_users", count_users) monkeypatch.setattr(users_admin_repo, "list_users", list_users) monkeypatch.setattr(users_admin_repo, "get_user", get_user) monkeypatch.setattr(users_admin_repo, "money_history", money_history) + monkeypatch.setattr(users_admin_repo, "list_titles", list_titles) + monkeypatch.setattr(users_admin_repo, "user_history", user_history) + monkeypatch.setattr(users_admin_repo, "set_nick", set_nick) + monkeypatch.setattr(users_admin_repo, "set_title", set_title) + monkeypatch.setattr(users_admin_repo, "set_subscription", set_subscription) + monkeypatch.setattr(users_admin_repo, "adjust_balance", adjust_balance) return state +def _csrf(client: TestClient, csrf_token) -> str: + return csrf_token(client.get("/users/42").text) + + def test_users_list(fake_repo: dict[str, Any], client_factory) -> None: response = client_factory().get("/users") @@ -89,10 +146,101 @@ def test_user_card(fake_repo: dict[str, Any], client_factory) -> None: assert response.status_code == 200 assert "Ветеран" in response.text + assert "Управление" in response.text assert "Очищение" in response.text assert "+50" in response.text assert "-20" in response.text + assert "Старый → ТестовыйНик" in response.text def test_user_card_404(fake_repo: dict[str, Any], client_factory) -> None: assert client_factory().get("/users/99").status_code == 404 + + +def test_update_nick(fake_repo: dict[str, Any], client_factory, csrf_token) -> None: + client: TestClient = client_factory() + token = _csrf(client, csrf_token) + + response = client.post("/users/42/nick", data={"nick": "НовыйНик", "csrf_token": token}) + + assert response.status_code == 200 + assert "Ник обновлён" in response.text + assert fake_repo["nick"] == (42, "НовыйНик", 1) + + +def test_update_nick_rejects_empty(fake_repo: dict[str, Any], client_factory, csrf_token) -> None: + client: TestClient = client_factory() + token = _csrf(client, csrf_token) + + response = client.post("/users/42/nick", data={"nick": " ", "csrf_token": token}) + + assert response.status_code == 400 + assert "Ник не может быть пустым" in response.text + assert "nick" not in fake_repo + + +def test_update_title(fake_repo: dict[str, Any], client_factory, csrf_token) -> None: + client: TestClient = client_factory() + token = _csrf(client, csrf_token) + + response = client.post("/users/42/title", data={"title_id": "2", "csrf_token": token}) + + assert response.status_code == 200 + assert "Звание обновлено" in response.text + assert fake_repo["title"] == (42, 2, 1) + + +def test_update_title_rejects_unknown(fake_repo: dict[str, Any], client_factory, csrf_token) -> None: + client: TestClient = client_factory() + token = _csrf(client, csrf_token) + + response = client.post("/users/42/title", data={"title_id": "999", "csrf_token": token}) + + assert response.status_code == 400 + assert "Звание не найдено" in response.text + + +def test_update_subscription(fake_repo: dict[str, Any], client_factory, csrf_token) -> None: + client: TestClient = client_factory() + token = _csrf(client, csrf_token) + + response = client.post("/users/42/subscription", data={"subscribed": "0", "csrf_token": token}) + + assert response.status_code == 200 + assert "Подписка обновлена" in response.text + assert fake_repo["subscription"] == (42, False, 1) + + +def test_update_balance(fake_repo: dict[str, Any], client_factory, csrf_token) -> None: + client: TestClient = client_factory() + token = _csrf(client, csrf_token) + + response = client.post( + "/users/42/balance", + data={"amount": "-100", "reason": "Штраф", "csrf_token": token}, + ) + + assert response.status_code == 200 + assert "Баланс скорректирован" in response.text + assert fake_repo["balance"] == (42, -100, "Штраф", 1) + + +def test_update_balance_requires_reason(fake_repo: dict[str, Any], client_factory, csrf_token) -> None: + client: TestClient = client_factory() + token = _csrf(client, csrf_token) + + response = client.post("/users/42/balance", data={"amount": "100", "csrf_token": token}) + + assert response.status_code == 400 + assert "Укажите причину" in response.text + assert "balance" not in fake_repo + + +def test_update_balance_rejects_zero(fake_repo: dict[str, Any], client_factory, csrf_token) -> None: + client: TestClient = client_factory() + token = _csrf(client, csrf_token) + + response = client.post("/users/42/balance", data={"amount": "0", "reason": "Тест", "csrf_token": token}) + + assert response.status_code == 400 + assert "Сумма не может быть нулевой" in response.text diff --git a/tests/test_version.py b/tests/test_version.py index aff00da..ccedb57 100644 --- a/tests/test_version.py +++ b/tests/test_version.py @@ -7,4 +7,4 @@ from common import __version__ def test_version_is_set(): assert __version__ - assert __version__ == "0.4.6" + assert __version__ == "0.4.7"