Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
111 changes: 107 additions & 4 deletions test/conftest.py
Original file line number Diff line number Diff line change
@@ -1,17 +1,32 @@
import os

import asyncio
from datetime import datetime
from functools import wraps
from types import SimpleNamespace
from unittest.mock import AsyncMock, MagicMock

from peewee import SqliteDatabase
from telegram import Bot

from pycamp_bot.models import Pycampista, Slot, Pycamp, WizardAtPycamp, PycampistaAtPycamp
from pycamp_bot.models import (
Pycampista, Slot, Pycamp, WizardAtPycamp, PycampistaAtPycamp, Project, Vote
)

# -----------------------------------------------------------------------------
# Causa raíz de fallos en tests de handlers (PTB v21):
# En python-telegram-bot v21 los objetos Telegram (Message, CallbackQuery, etc.)
# son inmutables ("frozen"): no se puede asignar msg.reply_text = AsyncMock().
# Doc oficial (v20.0+): "Objects of this class (or subclasses) are now immutable.
# This means that you can't set or delete attributes anymore."
# https://docs.python-telegram-bot.org/en/v21.10/telegram.telegramobject.html
# Por eso los builders de updates usan dobles mutables (SimpleNamespace) que
# exponen la misma interfaz que los handlers (message.text, message.reply_text,
# callback_query.answer, etc.) sin tocar objetos reales de la librería.
# -----------------------------------------------------------------------------

# use an in-memory SQLite for tests.
test_db = SqliteDatabase(':memory:')

MODELS = [Pycampista, Slot, Pycamp, WizardAtPycamp, PycampistaAtPycamp]
MODELS = [Pycampista, Slot, Pycamp, WizardAtPycamp, PycampistaAtPycamp, Project, Vote]


def use_test_database(fn):
Expand All @@ -25,3 +40,91 @@ def inner(self):
finally:
test_db.drop_tables(MODELS)
return inner


def use_test_database_async(fn):
"""Bind the given models to the db for the duration of an async test."""
@wraps(fn)
def inner(self):
with test_db.bind_ctx(MODELS):
test_db.create_tables(MODELS)
try:
asyncio.get_event_loop().run_until_complete(fn(self))
finally:
test_db.drop_tables(MODELS)
return inner


def make_user(username="testuser", user_id=12345, first_name="Test"):
"""Doble mutable de telegram.User para tests (PTB v21 usa objetos congelados)."""
return SimpleNamespace(
id=user_id,
first_name=first_name,
is_bot=False,
username=username,
)


def make_chat(chat_id=67890, chat_type="private"):
"""Doble mutable de telegram.Chat para tests."""
return SimpleNamespace(id=chat_id, type=chat_type)


def make_message(text="/start", username="testuser", chat_id=67890,
user_id=12345, message_id=1):
"""Doble mutable de telegram.Message: misma interfaz que usan los handlers."""
user = make_user(username=username, user_id=user_id)
chat = make_chat(chat_id=chat_id)
msg = SimpleNamespace(
message_id=message_id,
date=datetime.now(),
chat=chat,
from_user=user,
text=text,
chat_id=chat_id,
reply_text=AsyncMock(),
)
return msg


def make_update(text="/start", username="testuser", chat_id=67890,
user_id=12345, update_id=1):
"""Doble mutable de telegram.Update con message; interfaz usada por handlers."""
message = make_message(
text=text, username=username, chat_id=chat_id,
user_id=user_id,
)
return SimpleNamespace(update_id=update_id, message=message)


def make_callback_update(data="vote:si", username="testuser", chat_id=67890,
user_id=12345, message_text="Proyecto1", update_id=1):
"""Doble mutable de Update con callback_query para botones inline."""
user = make_user(username=username, user_id=user_id)
chat = make_chat(chat_id=chat_id)
message = SimpleNamespace(
message_id=1,
date=datetime.now(),
chat=chat,
text=message_text,
chat_id=chat_id,
reply_text=AsyncMock(),
)
callback_query = SimpleNamespace(
id="test_callback_1",
from_user=user,
chat_instance="test_instance",
data=data,
message=message,
answer=AsyncMock(),
)
return SimpleNamespace(update_id=update_id, callback_query=callback_query)


def make_context():
"""Crea un mock de CallbackContext con bot mockeado según la API v21."""
context = MagicMock()
context.bot = AsyncMock()
context.bot.send_message = AsyncMock()
context.bot.edit_message_text = AsyncMock()
return context
56 changes: 56 additions & 0 deletions test/test_auth.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
from pycamp_bot.models import Pycampista
from pycamp_bot.commands.auth import get_admins_username
from test.conftest import use_test_database, test_db, MODELS


def setup_module(module):
test_db.bind(MODELS, bind_refs=False, bind_backrefs=False)
test_db.connect()


def teardown_module(module):
test_db.drop_tables(MODELS)
test_db.close()


class TestGetAdminsUsername:

@use_test_database
def test_returns_empty_when_no_admins(self):
Pycampista.create(username="pepe", admin=False)
assert get_admins_username() == []

@use_test_database
def test_returns_admin_usernames(self):
Pycampista.create(username="admin1", admin=True)
result = get_admins_username()
assert result == ["admin1"]

@use_test_database
def test_excludes_non_admin_users(self):
Pycampista.create(username="admin1", admin=True)
Pycampista.create(username="user1", admin=False)
result = get_admins_username()
assert "admin1" in result
assert "user1" not in result

@use_test_database
def test_multiple_admins(self):
Pycampista.create(username="admin1", admin=True)
Pycampista.create(username="admin2", admin=True)
Pycampista.create(username="user1", admin=False)
result = get_admins_username()
assert len(result) == 2
assert "admin1" in result
assert "admin2" in result

@use_test_database
def test_admin_with_null_flag(self):
Pycampista.create(username="pepe", admin=None)
result = get_admins_username()
assert result == []

@use_test_database
def test_no_users_returns_empty(self):
result = get_admins_username()
assert result == []
92 changes: 92 additions & 0 deletions test/test_conftest_builders.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
"""Tests de los builders de conftest: garantizan la interfaz que usan los handlers.

Si PTB o los handlers cambian de atributos, estos tests fallan y evitan
regresiones silenciosas en los tests de handlers.
"""
import pytest

from test.conftest import (
make_user,
make_chat,
make_message,
make_update,
make_callback_update,
make_context,
)


class TestMakeUser:
def test_exposes_username_id_first_name(self):
u = make_user(username="pepe", user_id=999, first_name="Pepe")
assert u.username == "pepe"
assert u.id == 999
assert u.first_name == "Pepe"
assert u.is_bot is False

def test_accepts_username_none(self):
u = make_user(username=None)
assert u.username is None


class TestMakeChat:
def test_exposes_id_and_type(self):
c = make_chat(chat_id=111, chat_type="private")
assert c.id == 111
assert c.type == "private"


class TestMakeMessage:
def test_exposes_text_chat_id_from_user_reply_text(self):
msg = make_message(text="/start", username="u1", chat_id=222)
assert msg.text == "/start"
assert msg.chat_id == 222
assert msg.from_user.username == "u1"
assert msg.chat.id == 222
assert callable(msg.reply_text)

def test_reply_text_is_awaitable(self):
msg = make_message()
import asyncio
asyncio.get_event_loop().run_until_complete(msg.reply_text("hi"))


class TestMakeUpdate:
def test_exposes_message_with_same_interface(self):
up = make_update(text="/cmd", username="alice", chat_id=333)
assert up.message.text == "/cmd"
assert up.message.chat_id == 333
assert up.message.from_user.username == "alice"
assert callable(up.message.reply_text)

def test_update_id_set(self):
up = make_update(update_id=42)
assert up.update_id == 42


class TestMakeCallbackUpdate:
def test_exposes_callback_query_data_and_message(self):
up = make_callback_update(
data="vote:si",
username="voter",
message_text="ProyectoX",
chat_id=444,
)
assert up.callback_query.data == "vote:si"
assert up.callback_query.from_user.username == "voter"
assert up.callback_query.message.text == "ProyectoX"
assert up.callback_query.message.chat_id == 444
assert up.callback_query.message.chat.id == 444
assert callable(up.callback_query.answer)

def test_answer_is_awaitable(self):
up = make_callback_update()
import asyncio
asyncio.get_event_loop().run_until_complete(up.callback_query.answer())


class TestMakeContext:
def test_exposes_bot_send_message_and_edit_message_text(self):
ctx = make_context()
assert ctx.bot is not None
assert callable(ctx.bot.send_message)
assert callable(ctx.bot.edit_message_text)
88 changes: 88 additions & 0 deletions test/test_db_to_json.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
from datetime import datetime
from pycamp_bot.models import Pycampista, Project, Slot, Vote
from pycamp_bot.scheduler.db_to_json import export_db_2_json
from test.conftest import use_test_database, test_db, MODELS


def setup_module(module):
test_db.bind(MODELS, bind_refs=False, bind_backrefs=False)
test_db.connect()


def teardown_module(module):
test_db.drop_tables(MODELS)
test_db.close()


class TestExportDb2Json:

@use_test_database
def test_empty_projects_returns_empty_structure(self):
result = export_db_2_json()
assert result["projects"] == {}
assert result["available_slots"] == []
assert result["responsable_available_slots"] == {}

@use_test_database
def test_exports_project_with_votes(self):
owner = Pycampista.create(username="pepe")
voter = Pycampista.create(username="juan")
slot = Slot.create(code="A1", start=datetime(2024, 6, 21, 10, 0), current_wizard=owner)
project = Project.create(name="MiProyecto", owner=owner, topic="django", difficult_level=2)
Vote.create(
project=project, pycampista=voter, interest=True,
_project_pycampista_id=f"{project.id}-{voter.id}",
)

result = export_db_2_json()
assert "MiProyecto" in result["projects"]
proj_data = result["projects"]["MiProyecto"]
assert proj_data["responsables"] == ["pepe"]
assert "juan" in proj_data["votes"]
assert proj_data["difficult_level"] == 2
assert proj_data["theme"] == "django"

@use_test_database
def test_exports_available_slots(self):
owner = Pycampista.create(username="pepe")
Slot.create(code="A1", start=datetime(2024, 6, 21, 10, 0), current_wizard=owner)
Slot.create(code="A2", start=datetime(2024, 6, 21, 11, 0), current_wizard=owner)
Slot.create(code="B1", start=datetime(2024, 6, 22, 10, 0), current_wizard=owner)

result = export_db_2_json()
assert "A1" in result["available_slots"]
assert "A2" in result["available_slots"]
assert "B1" in result["available_slots"]
assert len(result["available_slots"]) == 3

@use_test_database
def test_responsable_available_slots_includes_all(self):
owner = Pycampista.create(username="pepe")
Slot.create(code="A1", start=datetime(2024, 6, 21, 10, 0), current_wizard=owner)
Slot.create(code="A2", start=datetime(2024, 6, 21, 11, 0), current_wizard=owner)
Project.create(name="MiProyecto", owner=owner)

result = export_db_2_json()
assert "pepe" in result["responsable_available_slots"]
assert result["responsable_available_slots"]["pepe"] == ["A1", "A2"]

@use_test_database
def test_vote_interest_filter(self):
owner = Pycampista.create(username="pepe")
voter1 = Pycampista.create(username="juan")
voter2 = Pycampista.create(username="maria")
project = Project.create(name="MiProyecto", owner=owner)

Vote.create(
project=project, pycampista=voter1, interest=True,
_project_pycampista_id=f"{project.id}-{voter1.id}",
)
Vote.create(
project=project, pycampista=voter2, interest=False,
_project_pycampista_id=f"{project.id}-{voter2.id}",
)

result = export_db_2_json()
votes = result["projects"]["MiProyecto"]["votes"]
assert "juan" in votes
assert "maria" not in votes
Loading