Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
b2d7c3d
feat: Added empty seed raise Exception
spuerta10 Oct 13, 2025
094286c
chore: Added init modules
spuerta10 Oct 13, 2025
79df9dd
chore: Added no cover to __name__ == __main__
spuerta10 Oct 13, 2025
85ea219
feat: Added tests for client application
spuerta10 Oct 13, 2025
5734217
chore: Added tests dependencies
spuerta10 Oct 13, 2025
0cc9d06
feat: Added install dependencies step to pipeline
spuerta10 Oct 14, 2025
bde206d
fix: Pass --system to uv dependencies install
spuerta10 Oct 14, 2025
dab5cdc
fix: Created venv since runners dont allow system package modifications
spuerta10 Oct 14, 2025
a9e22d1
fix: Fixed env vars for DB user, password and name
spuerta10 Oct 15, 2025
4b9824d
fix: Fixed API execution with fixed imports
spuerta10 Oct 15, 2025
68144bf
fix: Fixed imports to work with pytest
spuerta10 Oct 15, 2025
b146078
chore: Added python version file
spuerta10 Oct 15, 2025
a3367dd
chore: Added pytest path resolve file
spuerta10 Oct 15, 2025
527f937
chore: Added init modules for API tests
spuerta10 Oct 15, 2025
457c60d
chore: Added empty file for testing ticket repository
spuerta10 Oct 15, 2025
5c4e718
feat: Added unit tests for ticket repository
spuerta10 Oct 15, 2025
210f3be
feat: Added unit tests for ticket repository
spuerta10 Oct 16, 2025
bcec13e
chore: Added option explanation comment
spuerta10 Oct 16, 2025
d69dad0
feat: Added unit test for ticket service
spuerta10 Oct 16, 2025
19b30e0
chore: Added init module
spuerta10 Oct 16, 2025
2be6e07
chore: Added pytest asyncio to dependencies
spuerta10 Oct 16, 2025
5fe1dad
feat: Added unit tests for user repository
spuerta10 Oct 16, 2025
0b4163a
chore: Added tools cache folders to ignore
spuerta10 Oct 16, 2025
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
5 changes: 5 additions & 0 deletions .github/workflows/static-code-analysis.yml
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,11 @@ jobs:
- name: Run Static Code Analysis
run: uvx pre-commit run --all-files --show-diff-on-failure --color=always

- name: Install dependencies
run: |
uv venv
uv pip install -r requirements.txt

- name: Run unit tests (with coverage report)
if: ${{ inputs.coverage_artifact }}
run: uv run pytest --cov=./src --cov-report=xml --cov-report=term
Expand Down
7 changes: 6 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
@@ -1,2 +1,7 @@
__pycache__*
*.env*
*.env*
.mypy_cache/
.pytest_cache/
.ruff_cache/
.venv/
venv/
1 change: 1 addition & 0 deletions .python-version
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
3.11
4 changes: 2 additions & 2 deletions docker/Dockerfile.register-ticket-api
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,6 @@ COPY requirements.txt .

RUN pip install --no-cache-dir -r requirements.txt

COPY src/register_ticket_api/ ./register_tickets_api/
COPY src/register_ticket_api/ ./register_ticket_api/

CMD ["python", "register_tickets_api/main.py"]
CMD ["python", "-m", "register_ticket_api.main"]
8 changes: 5 additions & 3 deletions docker/docker-compose.yml
Original file line number Diff line number Diff line change
@@ -1,11 +1,13 @@
# docker compose -f docker/docker-compose.yml --env-file .env up --build

version: '3.9'
services:
postgres:
image: postgres:latest
environment:
POSTGRES_USER: {POSTGRES_USER}
POSTGRES_PASSWORD: {POSTGRES_PASSWORD} # define in execution time
POSTGRES_DB: {POSTGRES_DB}
POSTGRES_USER: ${DB_USER}
POSTGRES_PASSWORD: ${DB_PASSWORD} # define in execution time
POSTGRES_DB: ${DB_NAME}
volumes:
- ../src/db/scripts:/docker-entrypoint-initdb.d
ports:
Expand Down
5 changes: 5 additions & 0 deletions pytest.ini
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
[pytest]
pythonpath = src
testpaths = tests
# avoid marking each async test
asyncio_mode = auto
17 changes: 17 additions & 0 deletions requirements.txt
Original file line number Diff line number Diff line change
Expand Up @@ -2,24 +2,41 @@ annotated-types==0.7.0
anyio==4.11.0
asyncpg==0.30.0
certifi==2025.8.3
cfgv==3.4.0
click==8.3.0
coverage==7.10.7
distlib==0.4.0
dotenv==0.9.9
elastic-transport==9.1.0
elasticsearch==9.1.1
fastapi==0.117.1
filelock==3.20.0
h11==0.16.0
identify==2.6.15
idna==3.10
iniconfig==2.1.0
loguru==0.7.3
nodeenv==1.9.1
packaging==25.0
platformdirs==4.5.0
pluggy==1.6.0
pre-commit==4.3.0
pydantic==2.11.9
pydantic-core==2.33.2
pydantic-settings==2.10.1
pygments==2.19.2
pyotp==2.9.0
pytest==8.4.2
pytest-asyncio==1.2.0
pytest-cov==7.0.0
python-dateutil==2.9.0.post0
python-dotenv==1.1.1
pyyaml==6.0.3
six==1.17.0
sniffio==1.3.1
starlette==0.48.0
typing-extensions==4.15.0
typing-inspection==0.4.1
urllib3==2.5.0
uvicorn==0.37.0
virtualenv==20.35.3
Empty file added src/__init__.py
Empty file.
Empty file added src/client/__init__.py
Empty file.
4 changes: 3 additions & 1 deletion src/client/totp_generator.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,8 @@ class TOTPGenerator:
TOTP_INTERVAL_SECONDS: ClassVar[int] = 60

def __init__(self, seed_base64: str) -> None:
if not seed_base64:
raise ValueError("Seed cannot be empty")
bytes_seed: bytes = b64decode(seed_base64)
seed_base32 = b32encode(bytes_seed).decode("utf-8")
self.__totp = TOTP(seed_base32, interval=self.TOTP_INTERVAL_SECONDS)
Expand All @@ -17,7 +19,7 @@ def generate_code(self) -> str:
return str(self.__totp.now())


if __name__ == "__main__":
if __name__ == "__main__": # pragma: no cover
parser = ArgumentParser(description="Genera un código TOTP desde una semilla hexadecimal")
parser.add_argument("seed", type=str, help="Seed en hexadecimal (sin 0x)")
args = parser.parse_args()
Expand Down
2 changes: 1 addition & 1 deletion src/register_ticket_api/controllers/__init__.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
from controllers.tickets_controller import TicketsController
from register_ticket_api.controllers.tickets_controller import TicketsController

__all__ = ["TicketsController"]
7 changes: 4 additions & 3 deletions src/register_ticket_api/controllers/tickets_controller.py
Original file line number Diff line number Diff line change
@@ -1,8 +1,9 @@
from entities import AttendanceLog, Ticket
from exceptions import AppValidationException
from fastapi import APIRouter, HTTPException, status
from loguru import logger
from services import TicketService

from register_ticket_api.entities import AttendanceLog, Ticket
from register_ticket_api.exceptions import AppValidationException
from register_ticket_api.services import TicketService


class TicketsController:
Expand Down
3 changes: 2 additions & 1 deletion src/register_ticket_api/controllers/users_controller.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
from entities import User
from fastapi import APIRouter, HTTPException, status
from services import UserService

from register_ticket_api.entities import User


class UsersController:
def __init__(self, user_service: UserService) -> None:
Expand Down
6 changes: 3 additions & 3 deletions src/register_ticket_api/entities/__init__.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
from entities.attedance_log import AttendanceLog
from entities.ticket import Ticket
from entities.user import User
from register_ticket_api.entities.attedance_log import AttendanceLog
from register_ticket_api.entities.ticket import Ticket
from register_ticket_api.entities.user import User

__all__ = ["AttendanceLog", "Ticket", "User"]
5 changes: 2 additions & 3 deletions src/register_ticket_api/exceptions/__init__.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,4 @@
from exceptions.app_validation_exception import AppValidationException

from .db_operation_exception import DbOperationException
from register_ticket_api.exceptions.app_validation_exception import AppValidationException
from register_ticket_api.exceptions.db_operation_exception import DbOperationException

__all__ = ["AppValidationException", "DbOperationException"]
2 changes: 1 addition & 1 deletion src/register_ticket_api/infraestructure/__init__.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
from infraestructure.postgresql_db_context import PostgreSQLDbContext
from register_ticket_api.infraestructure.postgresql_db_context import PostgreSQLDbContext

__all__ = ["PostgreSQLDbContext"]
4 changes: 2 additions & 2 deletions src/register_ticket_api/interfaces/__init__.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
from interfaces.i_ticket_repository import ITicketRepository
from interfaces.i_user_repository import IUserRepository
from register_ticket_api.interfaces.i_ticket_repository import ITicketRepository
from register_ticket_api.interfaces.i_user_repository import IUserRepository

__all__ = ["ITicketRepository", "IUserRepository"]
2 changes: 1 addition & 1 deletion src/register_ticket_api/interfaces/i_ticket_repository.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
from abc import ABC, abstractmethod
from uuid import UUID

from entities import Ticket, User
from register_ticket_api.entities import Ticket, User


class ITicketRepository(ABC):
Expand Down
2 changes: 1 addition & 1 deletion src/register_ticket_api/interfaces/i_user_repository.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
from abc import ABC, abstractmethod

from entities import User
from register_ticket_api.entities import User


class IUserRepository(ABC):
Expand Down
9 changes: 5 additions & 4 deletions src/register_ticket_api/main.py
Original file line number Diff line number Diff line change
@@ -1,8 +1,9 @@
from controllers import TicketsController
from fastapi import FastAPI
from infraestructure import PostgreSQLDbContext
from repositories import TicketRepository, UserRepository
from services import TicketService

from register_ticket_api.controllers import TicketsController
from register_ticket_api.infraestructure import PostgreSQLDbContext
from register_ticket_api.repositories import TicketRepository, UserRepository
from register_ticket_api.services import TicketService

app = FastAPI()

Expand Down
4 changes: 2 additions & 2 deletions src/register_ticket_api/repositories/__init__.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
from repositories.ticket_repository import TicketRepository
from repositories.user_repository import UserRepository
from register_ticket_api.repositories.ticket_repository import TicketRepository
from register_ticket_api.repositories.user_repository import UserRepository

__all__ = ["TicketRepository", "UserRepository"]
10 changes: 5 additions & 5 deletions src/register_ticket_api/repositories/ticket_repository.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,10 @@
from typing import Any
from uuid import UUID

from entities import Ticket, User
from exceptions import DbOperationException
from infraestructure import PostgreSQLDbContext
from interfaces import ITicketRepository
from register_ticket_api.entities import Ticket, User
from register_ticket_api.exceptions import DbOperationException
from register_ticket_api.infraestructure import PostgreSQLDbContext
from register_ticket_api.interfaces import ITicketRepository


@dataclass
Expand Down Expand Up @@ -53,7 +53,7 @@ async def get_by_ticket_details(self, seat: str, gate: str) -> Ticket | None:
return Ticket(**row)
return None

async def mark_ticket_as_used(self, ticket_id: UUID) -> Any[bool]:
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

suggestion: Return type annotation is now less specific; consider restoring type clarity.

If the function always returns a boolean, please annotate the return type as 'bool' for better maintainability and static analysis.

Suggested change
async def mark_ticket_as_used(self, ticket_id: UUID) -> Any[bool]:
async def mark_ticket_as_used(self, ticket_id: UUID) -> bool:

async def mark_ticket_as_used(self, ticket_id: UUID) -> Any: # bool
FN_NAME: str = "fn_mark_ticket_as_used"
try:
db_conn = await self.db_context.get_connection()
Expand Down
8 changes: 4 additions & 4 deletions src/register_ticket_api/repositories/user_repository.py
Original file line number Diff line number Diff line change
@@ -1,9 +1,9 @@
from dataclasses import dataclass

from entities import User
from exceptions import DbOperationException
from infraestructure import PostgreSQLDbContext
from interfaces import IUserRepository
from register_ticket_api.entities import User
from register_ticket_api.exceptions import DbOperationException
from register_ticket_api.infraestructure import PostgreSQLDbContext
from register_ticket_api.interfaces import IUserRepository


@dataclass
Expand Down
4 changes: 2 additions & 2 deletions src/register_ticket_api/services/__init__.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
from services.ticket_service import TicketService
from services.user_service import UserService
from register_ticket_api.services.ticket_service import TicketService
from register_ticket_api.services.user_service import UserService

__all__ = ["TicketService", "UserService"]
7 changes: 4 additions & 3 deletions src/register_ticket_api/services/ticket_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,11 +3,12 @@
from typing import ClassVar

import pyotp
from entities import AttendanceLog, Ticket, User
from exceptions import AppValidationException, DbOperationException
from interfaces import ITicketRepository, IUserRepository
from loguru import logger

from register_ticket_api.entities import AttendanceLog, Ticket, User
from register_ticket_api.exceptions import AppValidationException, DbOperationException
from register_ticket_api.interfaces import ITicketRepository, IUserRepository


@dataclass
class TicketService:
Expand Down
6 changes: 3 additions & 3 deletions src/register_ticket_api/services/user_service.py
Original file line number Diff line number Diff line change
@@ -1,9 +1,9 @@
import re
from dataclasses import dataclass

from entities import User
from exceptions import AppValidationException, DbOperationException
from interfaces import IUserRepository
from register_ticket_api.entities import User
from register_ticket_api.exceptions import AppValidationException, DbOperationException
from register_ticket_api.interfaces import IUserRepository


@dataclass
Expand Down
Empty file added tests/__init__.py
Empty file.
Empty file added tests/unit/__init__.py
Empty file.
Empty file added tests/unit/client/__init__.py
Empty file.
73 changes: 73 additions & 0 deletions tests/unit/client/test_totp_generator.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
from base64 import b64encode
from datetime import datetime, timezone
from unittest.mock import patch

import pytest
from pyotp import TOTP

from src.client.totp_generator import TOTPGenerator

VALID_SEED_BYTES: bytes = b"test_secret_key_"
VALID_SEED_BASE64: str = b64encode(VALID_SEED_BYTES).decode("utf-8")
INVALID_BASE_64_SEED: str = "invalid!@#$"
EXPECTED_CODE_LENGTH: int = 6


@pytest.fixture
def totp_generator() -> TOTPGenerator:
return TOTPGenerator(seed_base64=VALID_SEED_BASE64)


@pytest.fixture
def base_time() -> datetime:
"""Fixed base time for tests."""
return datetime(2024, 1, 1, 12, 0, 0, tzinfo=timezone.utc)


def test_init_with_valid_base64() -> None:
"""Test TOTPGenerator initialization with valid seed."""
gen = TOTPGenerator(seed_base64=VALID_SEED_BASE64)

assert gen._TOTPGenerator__totp is not None # type: ignore[attr-defined]
assert isinstance(gen._TOTPGenerator__totp, TOTP) # type: ignore[attr-defined]


def test_init_with_invalid_base64() -> None:
"""Test TOTPGenerator initialization with invalid base64 seed raises exception."""
with pytest.raises(ValueError): # b64decode raises binascii.Error
TOTPGenerator(seed_base64=INVALID_BASE_64_SEED)


def test_empty_seed_raises_error() -> None:
"""Test that empty seed raises an error."""
with pytest.raises(ValueError):
TOTPGenerator(seed_base64="")


def test_generate_code_returns_string(totp_generator: TOTPGenerator) -> None:
"""Test that generate_code returns a string."""
code: str = totp_generator.generate_code()

assert isinstance(code, str)
assert len(code) == EXPECTED_CODE_LENGTH
assert code.isdigit()


def test_generate_code_changes_over_time(
base_time: datetime, totp_generator: TOTPGenerator
) -> None:
"""Test that code changes at the exact interval boundary."""
with patch("pyotp.totp.datetime") as mock_time:
from datetime import timedelta

# first code at time 0
mock_time.datetime.now.return_value = base_time
code1 = totp_generator.generate_code()

# second code at t=TOTP_INTERVAL_SECONDS
mock_time.datetime.now.return_value = base_time + timedelta(
seconds=totp_generator.TOTP_INTERVAL_SECONDS
)
code2 = totp_generator.generate_code()

assert code1 != code2
Empty file.
Empty file.
Loading