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 %}
+
+ {% for error in errors %}- {{ error }}
{% endfor %}
+
+{% endif %}
+
Уровень
@@ -72,6 +88,120 @@
+
+
+
+
История изменений
+ {% if history %}
+
+
+
+
+ | Когда |
+ Что |
+ Было → стало |
+ Комментарий |
+ Админ |
+
+
+
+ {% set field_labels = {"nick": "Ник", "title": "Звание", "subscription": "Подписка", "balance": "Баланс"} %}
+ {% for item in history %}
+
+ | {{ 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 "—" }} |
+
+ {% endfor %}
+
+
+
+ {% 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"