From d9babc5da1902c06821bbf0a970f2d48dda0bcc6 Mon Sep 17 00:00:00 2001 From: ws2016 Date: Wed, 1 Jul 2026 20:22:45 +0000 Subject: [PATCH] Add Python implementation of BankWeb API with 100% test coverage Implement models, validators, and services based on the OpenAPI 3.0 spec in README.md. Add 300 unit tests covering all modules. Modules: - bankweb/models: UserModel, TransactionDBModel, TransactionResp, StoreItem, BuyProductReq, PurchaseHistoryItemResp, and related response types - bankweb/validators: UserValidator, TransactionValidator, StoreValidator - bankweb/services: AuthService, TransactionService, StoreService, UserService, SearchService Tests: - tests/test_models: serialization roundtrips, computed properties, defaults - tests/test_validators: boundary values, required fields, format checks - tests/test_services: full business logic including balance, auth flows, search, pagination, purchase history Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- .gitignore | 7 + bankweb/__init__.py | 1 + bankweb/models/__init__.py | 24 ++ bankweb/models/store.py | 113 +++++++ bankweb/models/transaction.py | 168 +++++++++++ bankweb/models/user.py | 129 ++++++++ bankweb/services/__init__.py | 15 + bankweb/services/auth_service.py | 111 +++++++ bankweb/services/search_service.py | 48 +++ bankweb/services/store_service.py | 111 +++++++ bankweb/services/transaction_service.py | 123 ++++++++ bankweb/services/user_service.py | 57 ++++ bankweb/validators/__init__.py | 7 + bankweb/validators/store_validator.py | 67 +++++ bankweb/validators/transaction_validator.py | 67 +++++ bankweb/validators/user_validator.py | 119 ++++++++ pyproject.toml | 21 ++ tests/__init__.py | 0 tests/test_models/__init__.py | 0 tests/test_models/test_store_model.py | 188 ++++++++++++ tests/test_models/test_transaction_model.py | 278 ++++++++++++++++++ tests/test_models/test_user_model.py | 219 ++++++++++++++ tests/test_services/__init__.py | 0 tests/test_services/test_auth_service.py | 252 ++++++++++++++++ tests/test_services/test_search_service.py | 99 +++++++ tests/test_services/test_store_service.py | 200 +++++++++++++ .../test_services/test_transaction_service.py | 209 +++++++++++++ tests/test_services/test_user_service.py | 117 ++++++++ tests/test_validators/__init__.py | 0 tests/test_validators/test_store_validator.py | 129 ++++++++ .../test_transaction_validator.py | 142 +++++++++ tests/test_validators/test_user_validator.py | 219 ++++++++++++++ 32 files changed, 3240 insertions(+) create mode 100644 .gitignore create mode 100644 bankweb/__init__.py create mode 100644 bankweb/models/__init__.py create mode 100644 bankweb/models/store.py create mode 100644 bankweb/models/transaction.py create mode 100644 bankweb/models/user.py create mode 100644 bankweb/services/__init__.py create mode 100644 bankweb/services/auth_service.py create mode 100644 bankweb/services/search_service.py create mode 100644 bankweb/services/store_service.py create mode 100644 bankweb/services/transaction_service.py create mode 100644 bankweb/services/user_service.py create mode 100644 bankweb/validators/__init__.py create mode 100644 bankweb/validators/store_validator.py create mode 100644 bankweb/validators/transaction_validator.py create mode 100644 bankweb/validators/user_validator.py create mode 100644 pyproject.toml create mode 100644 tests/__init__.py create mode 100644 tests/test_models/__init__.py create mode 100644 tests/test_models/test_store_model.py create mode 100644 tests/test_models/test_transaction_model.py create mode 100644 tests/test_models/test_user_model.py create mode 100644 tests/test_services/__init__.py create mode 100644 tests/test_services/test_auth_service.py create mode 100644 tests/test_services/test_search_service.py create mode 100644 tests/test_services/test_store_service.py create mode 100644 tests/test_services/test_transaction_service.py create mode 100644 tests/test_services/test_user_service.py create mode 100644 tests/test_validators/__init__.py create mode 100644 tests/test_validators/test_store_validator.py create mode 100644 tests/test_validators/test_transaction_validator.py create mode 100644 tests/test_validators/test_user_validator.py diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..b0276bc --- /dev/null +++ b/.gitignore @@ -0,0 +1,7 @@ +__pycache__/ +*.pyc +.coverage +*.egg-info/ +dist/ +build/ +.pytest_cache/ diff --git a/bankweb/__init__.py b/bankweb/__init__.py new file mode 100644 index 0000000..8587113 --- /dev/null +++ b/bankweb/__init__.py @@ -0,0 +1 @@ +"""BankWeb API - Python implementation based on OpenAPI 3.0 specification.""" diff --git a/bankweb/models/__init__.py b/bankweb/models/__init__.py new file mode 100644 index 0000000..f3a415b --- /dev/null +++ b/bankweb/models/__init__.py @@ -0,0 +1,24 @@ +"""Data models for the BankWeb API.""" + +from bankweb.models.user import UserModel, NewImageModel, AccountBalanceResp, AdminUserInfoResp +from bankweb.models.transaction import ( + TransactionDBModel, + TransactionResp, + TransactionRespDataTableResp, + AdminUserInfoRespDataTableResp, +) +from bankweb.models.store import StoreItem, BuyProductReq, PurchaseHistoryItemResp + +__all__ = [ + "UserModel", + "NewImageModel", + "AccountBalanceResp", + "AdminUserInfoResp", + "TransactionDBModel", + "TransactionResp", + "TransactionRespDataTableResp", + "AdminUserInfoRespDataTableResp", + "StoreItem", + "BuyProductReq", + "PurchaseHistoryItemResp", +] diff --git a/bankweb/models/store.py b/bankweb/models/store.py new file mode 100644 index 0000000..9ea7839 --- /dev/null +++ b/bankweb/models/store.py @@ -0,0 +1,113 @@ +"""Store-related data models.""" + +from __future__ import annotations + +from dataclasses import dataclass +from datetime import datetime +from typing import Optional + + +@dataclass +class StoreItem: + """Represents an item available in the store.""" + + id: int = 0 + name: Optional[str] = None + description: Optional[str] = None + price: float = 0.0 + installments: int = 0 + + def to_dict(self) -> dict: + return { + "id": self.id, + "name": self.name, + "description": self.description, + "price": self.price, + "installments": self.installments, + } + + @classmethod + def from_dict(cls, data: dict) -> StoreItem: + return cls( + id=data.get("id", 0), + name=data.get("name"), + description=data.get("description"), + price=data.get("price", 0.0), + installments=data.get("installments", 0), + ) + + @property + def installment_amount(self) -> float: + if self.installments <= 0: + return self.price + return round(self.price / self.installments, 2) + + +@dataclass +class BuyProductReq: + """Request model for purchasing a product.""" + + id: int = 0 + quantity: int = 0 + price: float = 0.0 + + def to_dict(self) -> dict: + return { + "id": self.id, + "quantity": self.quantity, + "price": self.price, + } + + @classmethod + def from_dict(cls, data: dict) -> BuyProductReq: + return cls( + id=data.get("id", 0), + quantity=data.get("quantity", 0), + price=data.get("price", 0.0), + ) + + @property + def total_cost(self) -> float: + return round(self.price * self.quantity, 2) + + +@dataclass +class PurchaseHistoryItemResp: + """Response model for purchase history items.""" + + purchase_time: Optional[datetime] = None + name: Optional[str] = None + description: Optional[str] = None + quantity: int = 0 + price: float = 0.0 + user_name: Optional[str] = None + + def to_dict(self) -> dict: + return { + "purchaseTime": ( + self.purchase_time.isoformat() if self.purchase_time else None + ), + "name": self.name, + "description": self.description, + "quantity": self.quantity, + "price": self.price, + "userName": self.user_name, + } + + @classmethod + def from_dict(cls, data: dict) -> PurchaseHistoryItemResp: + pt = data.get("purchaseTime") + if isinstance(pt, str): + pt = datetime.fromisoformat(pt) + return cls( + purchase_time=pt, + name=data.get("name"), + description=data.get("description"), + quantity=data.get("quantity", 0), + price=data.get("price", 0.0), + user_name=data.get("userName"), + ) + + @property + def total_cost(self) -> float: + return round(self.price * self.quantity, 2) diff --git a/bankweb/models/transaction.py b/bankweb/models/transaction.py new file mode 100644 index 0000000..cb027b8 --- /dev/null +++ b/bankweb/models/transaction.py @@ -0,0 +1,168 @@ +"""Transaction-related data models.""" + +from __future__ import annotations + +from dataclasses import dataclass +from datetime import datetime +from typing import List, Optional + + +@dataclass +class TransactionDBModel: + """Database model for a transaction.""" + + id: int = 0 + sender_id: Optional[str] = None + receiver_id: Optional[str] = None + transaction_date_time: Optional[datetime] = None + reason: Optional[str] = None + amount: float = 0.0 + reference: Optional[str] = None + + def to_dict(self) -> dict: + return { + "id": self.id, + "senderId": self.sender_id, + "receiverId": self.receiver_id, + "transactionDateTime": ( + self.transaction_date_time.isoformat() + if self.transaction_date_time + else None + ), + "reason": self.reason, + "amount": self.amount, + "reference": self.reference, + } + + @classmethod + def from_dict(cls, data: dict) -> TransactionDBModel: + dt = data.get("transactionDateTime") + if isinstance(dt, str): + dt = datetime.fromisoformat(dt) + return cls( + id=data.get("id", 0), + sender_id=data.get("senderId"), + receiver_id=data.get("receiverId"), + transaction_date_time=dt, + reason=data.get("reason"), + amount=data.get("amount", 0.0), + reference=data.get("reference"), + ) + + +@dataclass +class TransactionResp: + """Response model for transaction data.""" + + id: int = 0 + sender_id: Optional[str] = None + receiver_id: Optional[str] = None + date_time: Optional[str] = None + reason: Optional[str] = None + amount: float = 0.0 + reference: Optional[str] = None + sender_name: Optional[str] = None + sender_surname: Optional[str] = None + receiver_name: Optional[str] = None + receiver_surname: Optional[str] = None + + def to_dict(self) -> dict: + return { + "id": self.id, + "senderId": self.sender_id, + "receiverId": self.receiver_id, + "dateTime": self.date_time, + "reason": self.reason, + "amount": self.amount, + "reference": self.reference, + "senderName": self.sender_name, + "senderSurname": self.sender_surname, + "receiverName": self.receiver_name, + "receiverSurname": self.receiver_surname, + } + + @classmethod + def from_dict(cls, data: dict) -> TransactionResp: + return cls( + id=data.get("id", 0), + sender_id=data.get("senderId"), + receiver_id=data.get("receiverId"), + date_time=data.get("dateTime"), + reason=data.get("reason"), + amount=data.get("amount", 0.0), + reference=data.get("reference"), + sender_name=data.get("senderName"), + sender_surname=data.get("senderSurname"), + receiver_name=data.get("receiverName"), + receiver_surname=data.get("receiverSurname"), + ) + + @property + def sender_full_name(self) -> str: + parts = [p for p in (self.sender_name, self.sender_surname) if p] + return " ".join(parts) + + @property + def receiver_full_name(self) -> str: + parts = [p for p in (self.receiver_name, self.receiver_surname) if p] + return " ".join(parts) + + +@dataclass +class TransactionRespDataTableResp: + """Paginated response for transaction listings.""" + + records_total: int = 0 + records_filtered: int = 0 + data: Optional[List[TransactionResp]] = None + + def to_dict(self) -> dict: + return { + "recordsTotal": self.records_total, + "recordsFiltered": self.records_filtered, + "data": [t.to_dict() for t in self.data] if self.data else None, + } + + @classmethod + def from_dict(cls, data: dict) -> TransactionRespDataTableResp: + items = data.get("data") + parsed_items = ( + [TransactionResp.from_dict(i) for i in items] if items else None + ) + return cls( + records_total=data.get("recordsTotal", 0), + records_filtered=data.get("recordsFiltered", 0), + data=parsed_items, + ) + + +@dataclass +class AdminUserInfoRespDataTableResp: + """Paginated response for admin user info listings.""" + + records_total: int = 0 + records_filtered: int = 0 + data: Optional[list] = None + + def to_dict(self) -> dict: + from bankweb.models.user import AdminUserInfoResp + + return { + "recordsTotal": self.records_total, + "recordsFiltered": self.records_filtered, + "data": [u.to_dict() for u in self.data] if self.data else None, + } + + @classmethod + def from_dict(cls, data: dict) -> AdminUserInfoRespDataTableResp: + from bankweb.models.user import AdminUserInfoResp + + items = data.get("data") + parsed_items = ( + [AdminUserInfoResp.from_dict(i) for i in items] if items else None + ) + return cls( + records_total=data.get("recordsTotal", 0), + records_filtered=data.get("recordsFiltered", 0), + data=parsed_items, + ) diff --git a/bankweb/models/user.py b/bankweb/models/user.py new file mode 100644 index 0000000..e657e13 --- /dev/null +++ b/bankweb/models/user.py @@ -0,0 +1,129 @@ +"""User-related data models.""" + +from __future__ import annotations + +from dataclasses import dataclass, field +from typing import Optional + + +@dataclass +class UserModel: + """Represents a user in the banking system.""" + + id: int = 0 + user_name: Optional[str] = None + password: Optional[str] = None + name: Optional[str] = None + surname: Optional[str] = None + user_right: int = 0 + account_number: Optional[str] = None + cookie: Optional[str] = None + status: Optional[str] = None + site_action: Optional[str] = None + token: Optional[str] = None + + def to_dict(self) -> dict: + return { + "id": self.id, + "userName": self.user_name, + "password": self.password, + "name": self.name, + "surname": self.surname, + "userRight": self.user_right, + "accountNumber": self.account_number, + "cookie": self.cookie, + "status": self.status, + "siteAction": self.site_action, + "token": self.token, + } + + @classmethod + def from_dict(cls, data: dict) -> UserModel: + return cls( + id=data.get("id", 0), + user_name=data.get("userName"), + password=data.get("password"), + name=data.get("name"), + surname=data.get("surname"), + user_right=data.get("userRight", 0), + account_number=data.get("accountNumber"), + cookie=data.get("cookie"), + status=data.get("status"), + site_action=data.get("siteAction"), + token=data.get("token"), + ) + + @property + def full_name(self) -> str: + parts = [p for p in (self.name, self.surname) if p] + return " ".join(parts) + + @property + def is_admin(self) -> bool: + return self.user_right == 1 + + +@dataclass +class NewImageModel: + """Model for updating a user's profile image.""" + + username: Optional[str] = None + image_url: Optional[str] = None + + def to_dict(self) -> dict: + return { + "username": self.username, + "imageUrl": self.image_url, + } + + @classmethod + def from_dict(cls, data: dict) -> NewImageModel: + return cls( + username=data.get("username"), + image_url=data.get("imageUrl"), + ) + + +@dataclass +class AccountBalanceResp: + """Response model for account balance queries.""" + + balance: float = 0.0 + + def to_dict(self) -> dict: + return {"balance": self.balance} + + @classmethod + def from_dict(cls, data: dict) -> AccountBalanceResp: + return cls(balance=data.get("balance", 0.0)) + + +@dataclass +class AdminUserInfoResp: + """Response model for admin user info queries.""" + + name: Optional[str] = None + surname: Optional[str] = None + username: Optional[str] = None + role: int = 0 + + def to_dict(self) -> dict: + return { + "name": self.name, + "surname": self.surname, + "username": self.username, + "role": self.role, + } + + @classmethod + def from_dict(cls, data: dict) -> AdminUserInfoResp: + return cls( + name=data.get("name"), + surname=data.get("surname"), + username=data.get("username"), + role=data.get("role", 0), + ) + + @property + def is_admin(self) -> bool: + return self.role == 1 diff --git a/bankweb/services/__init__.py b/bankweb/services/__init__.py new file mode 100644 index 0000000..8c80646 --- /dev/null +++ b/bankweb/services/__init__.py @@ -0,0 +1,15 @@ +"""Service layer for the BankWeb API.""" + +from bankweb.services.auth_service import AuthService +from bankweb.services.transaction_service import TransactionService +from bankweb.services.store_service import StoreService +from bankweb.services.user_service import UserService +from bankweb.services.search_service import SearchService + +__all__ = [ + "AuthService", + "TransactionService", + "StoreService", + "UserService", + "SearchService", +] diff --git a/bankweb/services/auth_service.py b/bankweb/services/auth_service.py new file mode 100644 index 0000000..732e033 --- /dev/null +++ b/bankweb/services/auth_service.py @@ -0,0 +1,111 @@ +"""Authentication service for user login, registration, and recovery.""" + +from __future__ import annotations + +import hashlib +import secrets +import uuid +from typing import Dict, List, Optional, Tuple + +from bankweb.models.user import UserModel +from bankweb.validators.user_validator import UserValidator + + +class AuthService: + """Handles authentication operations.""" + + def __init__(self) -> None: + self._users: Dict[str, UserModel] = {} + self._tokens: Dict[str, str] = {} + self._recovery_tokens: Dict[str, str] = {} + + def register(self, user: UserModel) -> Tuple[bool, List[str]]: + errors = UserValidator.validate_registration(user) + if errors: + return False, errors + + if user.user_name in self._users: + return False, ["Username already exists."] + + user.password = self._hash_password(user.password) + user.account_number = self._generate_account_number() + user.id = len(self._users) + 1 + self._users[user.user_name] = user + return True, [] + + def login(self, user: UserModel) -> Tuple[Optional[UserModel], List[str]]: + errors = UserValidator.validate_login(user) + if errors: + return None, errors + + stored = self._users.get(user.user_name) + if not stored: + return None, ["Invalid username or password."] + + if stored.password != self._hash_password(user.password): + return None, ["Invalid username or password."] + + token = secrets.token_hex(32) + self._tokens[token] = stored.user_name + result = UserModel( + id=stored.id, + user_name=stored.user_name, + name=stored.name, + surname=stored.surname, + user_right=stored.user_right, + account_number=stored.account_number, + token=token, + ) + return result, [] + + def logout(self, token: str) -> bool: + if token in self._tokens: + del self._tokens[token] + return True + return False + + def password_recovery(self, user: UserModel) -> Tuple[bool, List[str]]: + errors = UserValidator.validate_password_recovery(user) + if errors: + return False, errors + + if user.user_name not in self._users: + return True, [] + + recovery_token = secrets.token_hex(16) + self._recovery_tokens[recovery_token] = user.user_name + return True, [] + + def recover(self, user: UserModel) -> Tuple[bool, List[str]]: + errors = UserValidator.validate_recover(user) + if errors: + return False, errors + + username = self._recovery_tokens.get(user.token) + if not username: + return False, ["Invalid or expired recovery token."] + + stored = self._users.get(username) + if not stored: + return False, ["User not found."] + + stored.password = self._hash_password(user.password) + del self._recovery_tokens[user.token] + return True, [] + + def get_user_by_token(self, token: str) -> Optional[UserModel]: + username = self._tokens.get(token) + if username: + return self._users.get(username) + return None + + def get_user_by_username(self, username: str) -> Optional[UserModel]: + return self._users.get(username) + + @staticmethod + def _hash_password(password: str) -> str: + return hashlib.sha256(password.encode()).hexdigest() + + @staticmethod + def _generate_account_number() -> str: + return str(uuid.uuid4().int)[:12] diff --git a/bankweb/services/search_service.py b/bankweb/services/search_service.py new file mode 100644 index 0000000..f8b7a7e --- /dev/null +++ b/bankweb/services/search_service.py @@ -0,0 +1,48 @@ +"""Search service for finding users and portal content.""" + +from __future__ import annotations + +from typing import Dict, List, Optional + +from bankweb.models.user import UserModel + + +class SearchService: + """Handles search operations.""" + + def __init__(self) -> None: + self._users: Dict[str, UserModel] = {} + self._portal_content: Dict[str, str] = {} + + def add_user(self, user: UserModel) -> None: + if user.user_name: + self._users[user.user_name] = user + + def add_portal_content(self, key: str, content: str) -> None: + self._portal_content[key] = content + + def find_user(self, term: str) -> List[str]: + if not term: + return [] + + term_lower = term.lower() + results: List[str] = [] + for username, user in self._users.items(): + if ( + term_lower in username.lower() + or (user.name and term_lower in user.name.lower()) + or (user.surname and term_lower in user.surname.lower()) + ): + results.append(username) + return results + + def portal_search(self, search_string: Optional[str]) -> List[str]: + if not search_string: + return list(self._portal_content.keys()) + + search_lower = search_string.lower() + return [ + key + for key, content in self._portal_content.items() + if search_lower in key.lower() or search_lower in content.lower() + ] diff --git a/bankweb/services/store_service.py b/bankweb/services/store_service.py new file mode 100644 index 0000000..a675eb1 --- /dev/null +++ b/bankweb/services/store_service.py @@ -0,0 +1,111 @@ +"""Store service for managing store items and purchases.""" + +from __future__ import annotations + +from datetime import datetime, timezone +from typing import Dict, List, Optional, Tuple + +from bankweb.models.store import BuyProductReq, PurchaseHistoryItemResp, StoreItem +from bankweb.validators.store_validator import StoreValidator + + +class StoreService: + """Handles store operations.""" + + def __init__(self) -> None: + self._items: Dict[int, StoreItem] = {} + self._next_id: int = 1 + self._purchases: List[PurchaseHistoryItemResp] = [] + self._balances: Dict[str, float] = {} + + def set_balance(self, user_id: str, balance: float) -> None: + self._balances[user_id] = balance + + def get_balance(self, user_id: str) -> float: + return self._balances.get(user_id, 0.0) + + def create_item(self, item: StoreItem) -> Tuple[bool, List[str]]: + errors = StoreValidator.validate_store_item(item) + if errors: + return False, errors + + item.id = self._next_id + self._next_id += 1 + self._items[item.id] = item + return True, [] + + def get_item(self, item_id: int) -> Optional[StoreItem]: + errors = StoreValidator.validate_item_id(item_id) + if errors: + return None + return self._items.get(item_id) + + def get_all_items(self) -> List[StoreItem]: + return list(self._items.values()) + + def edit_item( + self, item_id: int, item: StoreItem + ) -> Tuple[bool, List[str]]: + errors = StoreValidator.validate_item_id(item_id) + if errors: + return False, errors + + if item_id not in self._items: + return False, ["Item not found."] + + errors = StoreValidator.validate_store_item(item) + if errors: + return False, errors + + item.id = item_id + self._items[item_id] = item + return True, [] + + def delete_item(self, item: StoreItem) -> Tuple[bool, List[str]]: + if item.id not in self._items: + return False, ["Item not found."] + del self._items[item.id] + return True, [] + + def buy_product( + self, req: BuyProductReq, user_name: str + ) -> Tuple[bool, List[str]]: + errors = StoreValidator.validate_buy_product(req) + if errors: + return False, errors + + item = self._items.get(req.id) + if not item: + return False, ["Product not found."] + + total_cost = req.price * req.quantity + balance = self.get_balance(user_name) + if balance < total_cost: + return False, ["Insufficient funds."] + + self._balances[user_name] = balance - total_cost + + purchase = PurchaseHistoryItemResp( + purchase_time=datetime.now(timezone.utc), + name=item.name, + description=item.description, + quantity=req.quantity, + price=req.price, + user_name=user_name, + ) + self._purchases.append(purchase) + return True, [] + + def get_purchase_history( + self, user_name: Optional[str] = None + ) -> List[PurchaseHistoryItemResp]: + if user_name: + return [p for p in self._purchases if p.user_name == user_name] + return list(self._purchases) + + def get_all_purchases(self) -> List[List[PurchaseHistoryItemResp]]: + grouped: Dict[str, List[PurchaseHistoryItemResp]] = {} + for p in self._purchases: + key = p.user_name or "" + grouped.setdefault(key, []).append(p) + return list(grouped.values()) diff --git a/bankweb/services/transaction_service.py b/bankweb/services/transaction_service.py new file mode 100644 index 0000000..cad99f8 --- /dev/null +++ b/bankweb/services/transaction_service.py @@ -0,0 +1,123 @@ +"""Transaction service for creating and querying transactions.""" + +from __future__ import annotations + +from datetime import datetime, timezone +from typing import Dict, List, Optional, Tuple + +from bankweb.models.transaction import ( + TransactionDBModel, + TransactionResp, + TransactionRespDataTableResp, +) +from bankweb.models.user import UserModel +from bankweb.validators.transaction_validator import TransactionValidator + + +class TransactionService: + """Handles transaction operations.""" + + def __init__(self) -> None: + self._transactions: Dict[int, TransactionDBModel] = {} + self._next_id: int = 1 + self._balances: Dict[str, float] = {} + + def set_balance(self, user_id: str, balance: float) -> None: + self._balances[user_id] = balance + + def get_balance(self, user_id: str) -> float: + return self._balances.get(user_id, 0.0) + + def create( + self, txn: TransactionDBModel + ) -> Tuple[bool, List[str]]: + errors = TransactionValidator.validate_create(txn) + if errors: + return False, errors + + sender_balance = self.get_balance(txn.sender_id) + if sender_balance < txn.amount: + return False, ["Insufficient funds."] + + txn.id = self._next_id + self._next_id += 1 + txn.transaction_date_time = txn.transaction_date_time or datetime.now(timezone.utc) + + self._balances[txn.sender_id] = sender_balance - txn.amount + receiver_balance = self.get_balance(txn.receiver_id) + self._balances[txn.receiver_id] = receiver_balance + txn.amount + + self._transactions[txn.id] = txn + return True, [] + + def get(self, transaction_id: int) -> Optional[TransactionDBModel]: + errors = TransactionValidator.validate_get(transaction_id) + if errors: + return None + return self._transactions.get(transaction_id) + + def get_transactions( + self, + start: int = 0, + length: int = 10, + search_value: Optional[str] = None, + ) -> TransactionRespDataTableResp: + all_txns = list(self._transactions.values()) + + if search_value: + search_lower = search_value.lower() + all_txns = [ + t + for t in all_txns + if (t.reason and search_lower in t.reason.lower()) + or (t.reference and search_lower in t.reference.lower()) + or (t.sender_id and search_lower in t.sender_id.lower()) + or (t.receiver_id and search_lower in t.receiver_id.lower()) + ] + + total = len(all_txns) + page = all_txns[start : start + length] + + resp_items = [ + TransactionResp( + id=t.id, + sender_id=t.sender_id, + receiver_id=t.receiver_id, + date_time=( + t.transaction_date_time.isoformat() + if t.transaction_date_time + else None + ), + reason=t.reason, + amount=t.amount, + reference=t.reference, + ) + for t in page + ] + + return TransactionRespDataTableResp( + records_total=len(self._transactions), + records_filtered=total, + data=resp_items, + ) + + def get_transaction_history( + self, user_name: str + ) -> List[TransactionResp]: + return [ + TransactionResp( + id=t.id, + sender_id=t.sender_id, + receiver_id=t.receiver_id, + date_time=( + t.transaction_date_time.isoformat() + if t.transaction_date_time + else None + ), + reason=t.reason, + amount=t.amount, + reference=t.reference, + ) + for t in self._transactions.values() + if t.sender_id == user_name or t.receiver_id == user_name + ] diff --git a/bankweb/services/user_service.py b/bankweb/services/user_service.py new file mode 100644 index 0000000..9e7d5bd --- /dev/null +++ b/bankweb/services/user_service.py @@ -0,0 +1,57 @@ +"""User service for managing user profiles and balances.""" + +from __future__ import annotations + +from typing import Dict, List, Optional, Tuple + +from bankweb.models.user import ( + AccountBalanceResp, + AdminUserInfoResp, + NewImageModel, + UserModel, +) +from bankweb.validators.user_validator import UserValidator + + +class UserService: + """Handles user profile operations.""" + + def __init__(self) -> None: + self._profile_images: Dict[str, str] = {} + self._balances: Dict[str, float] = {} + self._users: Dict[str, UserModel] = {} + + def add_user(self, user: UserModel) -> None: + if user.user_name: + self._users[user.user_name] = user + + def get_profile_image(self, username: str) -> Optional[str]: + return self._profile_images.get(username) + + def set_profile_image( + self, image: NewImageModel + ) -> Tuple[bool, List[str]]: + errors = UserValidator.validate_profile_image(image) + if errors: + return False, errors + + self._profile_images[image.username] = image.image_url + return True, [] + + def get_available_funds(self, username: str) -> AccountBalanceResp: + balance = self._balances.get(username, 0.0) + return AccountBalanceResp(balance=balance) + + def set_balance(self, username: str, balance: float) -> None: + self._balances[username] = balance + + def get_all_users(self) -> List[AdminUserInfoResp]: + return [ + AdminUserInfoResp( + name=u.name, + surname=u.surname, + username=u.user_name, + role=u.user_right, + ) + for u in self._users.values() + ] diff --git a/bankweb/validators/__init__.py b/bankweb/validators/__init__.py new file mode 100644 index 0000000..a02285c --- /dev/null +++ b/bankweb/validators/__init__.py @@ -0,0 +1,7 @@ +"""Validators for the BankWeb API.""" + +from bankweb.validators.user_validator import UserValidator +from bankweb.validators.transaction_validator import TransactionValidator +from bankweb.validators.store_validator import StoreValidator + +__all__ = ["UserValidator", "TransactionValidator", "StoreValidator"] diff --git a/bankweb/validators/store_validator.py b/bankweb/validators/store_validator.py new file mode 100644 index 0000000..72ff60f --- /dev/null +++ b/bankweb/validators/store_validator.py @@ -0,0 +1,67 @@ +"""Validation logic for store-related operations.""" + +from __future__ import annotations + +from typing import List + +from bankweb.models.store import BuyProductReq, StoreItem + + +class StoreValidator: + """Validates store item and purchase data.""" + + MAX_NAME_LENGTH = 200 + MAX_DESCRIPTION_LENGTH = 2000 + MAX_INSTALLMENTS = 60 + + @classmethod + def validate_store_item(cls, item: StoreItem) -> List[str]: + errors: List[str] = [] + + if not item.name or not item.name.strip(): + errors.append("Item name is required.") + elif len(item.name) > cls.MAX_NAME_LENGTH: + errors.append( + f"Item name must be at most {cls.MAX_NAME_LENGTH} characters." + ) + + if item.description and len(item.description) > cls.MAX_DESCRIPTION_LENGTH: + errors.append( + f"Description must be at most {cls.MAX_DESCRIPTION_LENGTH} characters." + ) + + if item.price < 0: + errors.append("Price cannot be negative.") + + if item.installments < 0: + errors.append("Installments cannot be negative.") + elif item.installments > cls.MAX_INSTALLMENTS: + errors.append( + f"Installments cannot exceed {cls.MAX_INSTALLMENTS}." + ) + + return errors + + @classmethod + def validate_buy_product(cls, req: BuyProductReq) -> List[str]: + errors: List[str] = [] + + if req.id <= 0: + errors.append("Product ID must be a positive integer.") + + if req.quantity <= 0: + errors.append("Quantity must be a positive integer.") + elif req.quantity > 999: + errors.append("Quantity cannot exceed 999.") + + if req.price < 0: + errors.append("Price cannot be negative.") + + return errors + + @classmethod + def validate_item_id(cls, item_id: int) -> List[str]: + errors: List[str] = [] + if item_id <= 0: + errors.append("Item ID must be a positive integer.") + return errors diff --git a/bankweb/validators/transaction_validator.py b/bankweb/validators/transaction_validator.py new file mode 100644 index 0000000..12f6b30 --- /dev/null +++ b/bankweb/validators/transaction_validator.py @@ -0,0 +1,67 @@ +"""Validation logic for transaction-related operations.""" + +from __future__ import annotations + +from typing import List + +from bankweb.models.transaction import TransactionDBModel + + +class TransactionValidator: + """Validates transaction data.""" + + MAX_REASON_LENGTH = 500 + MAX_REFERENCE_LENGTH = 100 + MAX_TRANSACTION_AMOUNT = 1_000_000.0 + + @classmethod + def validate_create(cls, txn: TransactionDBModel) -> List[str]: + errors: List[str] = [] + + if not txn.sender_id or not txn.sender_id.strip(): + errors.append("Sender ID is required.") + + if not txn.receiver_id or not txn.receiver_id.strip(): + errors.append("Receiver ID is required.") + + if txn.sender_id and txn.receiver_id and txn.sender_id == txn.receiver_id: + errors.append("Sender and receiver cannot be the same.") + + if txn.amount <= 0: + errors.append("Amount must be greater than zero.") + elif txn.amount > cls.MAX_TRANSACTION_AMOUNT: + errors.append( + f"Amount cannot exceed {cls.MAX_TRANSACTION_AMOUNT:,.2f}." + ) + + if txn.reason and len(txn.reason) > cls.MAX_REASON_LENGTH: + errors.append( + f"Reason must be at most {cls.MAX_REASON_LENGTH} characters." + ) + + if txn.reference and len(txn.reference) > cls.MAX_REFERENCE_LENGTH: + errors.append( + f"Reference must be at most {cls.MAX_REFERENCE_LENGTH} characters." + ) + + return errors + + @classmethod + def validate_get(cls, transaction_id: int) -> List[str]: + errors: List[str] = [] + if transaction_id <= 0: + errors.append("Transaction ID must be a positive integer.") + return errors + + @classmethod + def validate_get_transactions( + cls, start: int, length: int + ) -> List[str]: + errors: List[str] = [] + if start < 0: + errors.append("Start must be non-negative.") + if length <= 0: + errors.append("Length must be a positive integer.") + elif length > 100: + errors.append("Length cannot exceed 100.") + return errors diff --git a/bankweb/validators/user_validator.py b/bankweb/validators/user_validator.py new file mode 100644 index 0000000..e19934c --- /dev/null +++ b/bankweb/validators/user_validator.py @@ -0,0 +1,119 @@ +"""Validation logic for user-related operations.""" + +from __future__ import annotations + +import re +from typing import List, Optional + +from bankweb.models.user import NewImageModel, UserModel + + +class UserValidator: + """Validates user model data.""" + + MIN_USERNAME_LENGTH = 3 + MAX_USERNAME_LENGTH = 50 + MIN_PASSWORD_LENGTH = 8 + MAX_PASSWORD_LENGTH = 128 + ALLOWED_IMAGE_EXTENSIONS = (".jpg", ".jpeg", ".png", ".gif", ".webp") + + @classmethod + def validate_login(cls, user: UserModel) -> List[str]: + errors: List[str] = [] + if not user.user_name or not user.user_name.strip(): + errors.append("Username is required.") + if not user.password or not user.password.strip(): + errors.append("Password is required.") + return errors + + @classmethod + def validate_registration(cls, user: UserModel) -> List[str]: + errors: List[str] = [] + + if not user.user_name or not user.user_name.strip(): + errors.append("Username is required.") + elif len(user.user_name) < cls.MIN_USERNAME_LENGTH: + errors.append( + f"Username must be at least {cls.MIN_USERNAME_LENGTH} characters." + ) + elif len(user.user_name) > cls.MAX_USERNAME_LENGTH: + errors.append( + f"Username must be at most {cls.MAX_USERNAME_LENGTH} characters." + ) + elif not re.match(r"^[a-zA-Z0-9_]+$", user.user_name): + errors.append( + "Username may only contain letters, digits, and underscores." + ) + + errors.extend(cls._validate_password(user.password)) + + if not user.name or not user.name.strip(): + errors.append("Name is required.") + + if not user.surname or not user.surname.strip(): + errors.append("Surname is required.") + + return errors + + @classmethod + def validate_password_recovery(cls, user: UserModel) -> List[str]: + errors: List[str] = [] + if not user.user_name or not user.user_name.strip(): + errors.append("Username is required for password recovery.") + return errors + + @classmethod + def validate_recover(cls, user: UserModel) -> List[str]: + errors: List[str] = [] + if not user.token or not user.token.strip(): + errors.append("Recovery token is required.") + errors.extend(cls._validate_password(user.password)) + return errors + + @classmethod + def validate_contact_us(cls, user: UserModel) -> List[str]: + errors: List[str] = [] + if not user.name or not user.name.strip(): + errors.append("Name is required.") + if not user.user_name or not user.user_name.strip(): + errors.append("Email/username is required.") + if not user.site_action or not user.site_action.strip(): + errors.append("Message is required.") + return errors + + @classmethod + def validate_profile_image(cls, image: NewImageModel) -> List[str]: + errors: List[str] = [] + if not image.username or not image.username.strip(): + errors.append("Username is required.") + if not image.image_url or not image.image_url.strip(): + errors.append("Image URL is required.") + elif not any( + image.image_url.lower().endswith(ext) + for ext in cls.ALLOWED_IMAGE_EXTENSIONS + ): + errors.append( + f"Image must be one of: {', '.join(cls.ALLOWED_IMAGE_EXTENSIONS)}." + ) + return errors + + @classmethod + def _validate_password(cls, password: Optional[str]) -> List[str]: + errors: List[str] = [] + if not password or not password.strip(): + errors.append("Password is required.") + elif len(password) < cls.MIN_PASSWORD_LENGTH: + errors.append( + f"Password must be at least {cls.MIN_PASSWORD_LENGTH} characters." + ) + elif len(password) > cls.MAX_PASSWORD_LENGTH: + errors.append( + f"Password must be at most {cls.MAX_PASSWORD_LENGTH} characters." + ) + elif not re.search(r"[A-Z]", password): + errors.append("Password must contain at least one uppercase letter.") + elif not re.search(r"[a-z]", password): + errors.append("Password must contain at least one lowercase letter.") + elif not re.search(r"[0-9]", password): + errors.append("Password must contain at least one digit.") + return errors diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..ad86224 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,21 @@ +[build-system] +requires = ["setuptools>=68.0", "wheel"] +build-backend = "setuptools.backends._legacy:_Backend" + +[project] +name = "bankweb" +version = "0.1.0" +description = "BankWeb API - Python implementation based on OpenAPI 3.0 specification" +requires-python = ">=3.9" + +[tool.pytest.ini_options] +testpaths = ["tests"] +addopts = "--tb=short -q" + +[tool.coverage.run] +source = ["bankweb"] +omit = ["tests/*"] + +[tool.coverage.report] +show_missing = true +fail_under = 80 diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/test_models/__init__.py b/tests/test_models/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/test_models/test_store_model.py b/tests/test_models/test_store_model.py new file mode 100644 index 0000000..ec000be --- /dev/null +++ b/tests/test_models/test_store_model.py @@ -0,0 +1,188 @@ +"""Tests for store-related data models.""" + +import pytest +from datetime import datetime + +from bankweb.models.store import BuyProductReq, PurchaseHistoryItemResp, StoreItem + + +class TestStoreItem: + def test_default_values(self): + item = StoreItem() + assert item.id == 0 + assert item.name is None + assert item.description is None + assert item.price == 0.0 + assert item.installments == 0 + + def test_creation_with_values(self): + item = StoreItem(id=1, name="Laptop", price=999.99, installments=12) + assert item.name == "Laptop" + assert item.price == 999.99 + assert item.installments == 12 + + def test_to_dict(self): + item = StoreItem(id=1, name="Phone", description="Smart phone", price=500.0) + d = item.to_dict() + assert d["id"] == 1 + assert d["name"] == "Phone" + assert d["description"] == "Smart phone" + assert d["price"] == 500.0 + + def test_from_dict(self): + data = { + "id": 2, + "name": "Tablet", + "description": "10-inch tablet", + "price": 350.0, + "installments": 6, + } + item = StoreItem.from_dict(data) + assert item.id == 2 + assert item.name == "Tablet" + assert item.installments == 6 + + def test_from_dict_missing_keys(self): + item = StoreItem.from_dict({}) + assert item.id == 0 + assert item.name is None + assert item.price == 0.0 + + def test_installment_amount_with_installments(self): + item = StoreItem(price=1200.0, installments=12) + assert item.installment_amount == 100.0 + + def test_installment_amount_no_installments(self): + item = StoreItem(price=500.0, installments=0) + assert item.installment_amount == 500.0 + + def test_installment_amount_negative_installments(self): + item = StoreItem(price=500.0, installments=-1) + assert item.installment_amount == 500.0 + + def test_installment_amount_rounding(self): + item = StoreItem(price=100.0, installments=3) + assert item.installment_amount == 33.33 + + def test_roundtrip(self): + original = StoreItem(id=5, name="Widget", price=19.99, installments=3) + restored = StoreItem.from_dict(original.to_dict()) + assert restored.id == original.id + assert restored.name == original.name + assert restored.price == original.price + + +class TestBuyProductReq: + def test_default_values(self): + req = BuyProductReq() + assert req.id == 0 + assert req.quantity == 0 + assert req.price == 0.0 + + def test_creation_with_values(self): + req = BuyProductReq(id=1, quantity=3, price=25.0) + assert req.id == 1 + assert req.quantity == 3 + assert req.price == 25.0 + + def test_to_dict(self): + req = BuyProductReq(id=5, quantity=2, price=10.0) + d = req.to_dict() + assert d["id"] == 5 + assert d["quantity"] == 2 + assert d["price"] == 10.0 + + def test_from_dict(self): + data = {"id": 3, "quantity": 5, "price": 15.0} + req = BuyProductReq.from_dict(data) + assert req.id == 3 + assert req.quantity == 5 + + def test_total_cost(self): + req = BuyProductReq(price=25.50, quantity=4) + assert req.total_cost == 102.0 + + def test_total_cost_zero_quantity(self): + req = BuyProductReq(price=100.0, quantity=0) + assert req.total_cost == 0.0 + + def test_roundtrip(self): + original = BuyProductReq(id=1, quantity=10, price=5.99) + restored = BuyProductReq.from_dict(original.to_dict()) + assert restored.id == original.id + assert restored.quantity == original.quantity + + +class TestPurchaseHistoryItemResp: + def test_default_values(self): + item = PurchaseHistoryItemResp() + assert item.purchase_time is None + assert item.name is None + assert item.quantity == 0 + assert item.price == 0.0 + + def test_creation_with_values(self): + dt = datetime(2024, 6, 1, 10, 0) + item = PurchaseHistoryItemResp( + purchase_time=dt, + name="Widget", + quantity=2, + price=9.99, + user_name="alice", + ) + assert item.purchase_time == dt + assert item.name == "Widget" + assert item.user_name == "alice" + + def test_to_dict(self): + dt = datetime(2024, 1, 1, 12, 0) + item = PurchaseHistoryItemResp( + purchase_time=dt, name="Item", quantity=1, price=5.0 + ) + d = item.to_dict() + assert d["purchaseTime"] == "2024-01-01T12:00:00" + assert d["name"] == "Item" + assert d["quantity"] == 1 + + def test_to_dict_none_datetime(self): + item = PurchaseHistoryItemResp(name="X") + d = item.to_dict() + assert d["purchaseTime"] is None + + def test_from_dict(self): + data = { + "purchaseTime": "2024-03-15T09:30:00", + "name": "Gadget", + "description": "A cool gadget", + "quantity": 3, + "price": 29.99, + "userName": "bob", + } + item = PurchaseHistoryItemResp.from_dict(data) + assert item.purchase_time == datetime(2024, 3, 15, 9, 30) + assert item.name == "Gadget" + assert item.user_name == "bob" + + def test_from_dict_datetime_object(self): + dt = datetime(2024, 5, 1) + data = {"purchaseTime": dt} + item = PurchaseHistoryItemResp.from_dict(data) + assert item.purchase_time == dt + + def test_total_cost(self): + item = PurchaseHistoryItemResp(price=15.0, quantity=3) + assert item.total_cost == 45.0 + + def test_total_cost_zero(self): + item = PurchaseHistoryItemResp(price=10.0, quantity=0) + assert item.total_cost == 0.0 + + def test_roundtrip(self): + dt = datetime(2024, 7, 1, 8, 0) + original = PurchaseHistoryItemResp( + purchase_time=dt, name="Test", quantity=5, price=2.50, user_name="test" + ) + restored = PurchaseHistoryItemResp.from_dict(original.to_dict()) + assert restored.name == original.name + assert restored.quantity == original.quantity + assert restored.price == original.price diff --git a/tests/test_models/test_transaction_model.py b/tests/test_models/test_transaction_model.py new file mode 100644 index 0000000..7d41cd7 --- /dev/null +++ b/tests/test_models/test_transaction_model.py @@ -0,0 +1,278 @@ +"""Tests for transaction-related data models.""" + +import pytest +from datetime import datetime + +from bankweb.models.transaction import ( + AdminUserInfoRespDataTableResp, + TransactionDBModel, + TransactionResp, + TransactionRespDataTableResp, +) +from bankweb.models.user import AdminUserInfoResp + + +class TestTransactionDBModel: + def test_default_values(self): + txn = TransactionDBModel() + assert txn.id == 0 + assert txn.sender_id is None + assert txn.receiver_id is None + assert txn.transaction_date_time is None + assert txn.reason is None + assert txn.amount == 0.0 + assert txn.reference is None + + def test_creation_with_values(self): + dt = datetime(2024, 1, 15, 10, 30) + txn = TransactionDBModel( + id=1, + sender_id="user1", + receiver_id="user2", + transaction_date_time=dt, + reason="Payment", + amount=100.50, + reference="REF001", + ) + assert txn.id == 1 + assert txn.sender_id == "user1" + assert txn.amount == 100.50 + + def test_to_dict(self): + dt = datetime(2024, 6, 15, 12, 0, 0) + txn = TransactionDBModel( + id=1, + sender_id="s1", + receiver_id="r1", + transaction_date_time=dt, + amount=50.0, + ) + d = txn.to_dict() + assert d["id"] == 1 + assert d["senderId"] == "s1" + assert d["receiverId"] == "r1" + assert d["transactionDateTime"] == "2024-06-15T12:00:00" + assert d["amount"] == 50.0 + + def test_to_dict_none_datetime(self): + txn = TransactionDBModel(sender_id="s1") + d = txn.to_dict() + assert d["transactionDateTime"] is None + + def test_from_dict(self): + data = { + "id": 3, + "senderId": "alice", + "receiverId": "bob", + "transactionDateTime": "2024-01-01T00:00:00", + "reason": "Gift", + "amount": 25.0, + "reference": "GIFT001", + } + txn = TransactionDBModel.from_dict(data) + assert txn.id == 3 + assert txn.sender_id == "alice" + assert txn.receiver_id == "bob" + assert txn.transaction_date_time == datetime(2024, 1, 1, 0, 0, 0) + assert txn.reason == "Gift" + assert txn.amount == 25.0 + + def test_from_dict_datetime_object(self): + dt = datetime(2024, 3, 1, 8, 0) + data = {"transactionDateTime": dt} + txn = TransactionDBModel.from_dict(data) + assert txn.transaction_date_time == dt + + def test_from_dict_missing_keys(self): + txn = TransactionDBModel.from_dict({}) + assert txn.id == 0 + assert txn.amount == 0.0 + + def test_roundtrip(self): + dt = datetime(2024, 5, 20, 14, 30) + original = TransactionDBModel( + id=7, sender_id="a", receiver_id="b", + transaction_date_time=dt, amount=999.99 + ) + restored = TransactionDBModel.from_dict(original.to_dict()) + assert restored.id == original.id + assert restored.sender_id == original.sender_id + assert restored.amount == original.amount + + +class TestTransactionResp: + def test_default_values(self): + resp = TransactionResp() + assert resp.id == 0 + assert resp.amount == 0.0 + assert resp.sender_name is None + + def test_creation_with_values(self): + resp = TransactionResp( + id=1, + sender_id="u1", + receiver_id="u2", + amount=100.0, + sender_name="Alice", + sender_surname="Smith", + receiver_name="Bob", + receiver_surname="Jones", + ) + assert resp.sender_name == "Alice" + assert resp.receiver_name == "Bob" + + def test_to_dict(self): + resp = TransactionResp(id=1, amount=50.0, sender_name="X", receiver_name="Y") + d = resp.to_dict() + assert d["id"] == 1 + assert d["amount"] == 50.0 + assert d["senderName"] == "X" + assert d["receiverName"] == "Y" + + def test_from_dict(self): + data = { + "id": 2, + "senderId": "s", + "receiverId": "r", + "amount": 75.0, + "senderName": "S", + "senderSurname": "SS", + "receiverName": "R", + "receiverSurname": "RS", + } + resp = TransactionResp.from_dict(data) + assert resp.id == 2 + assert resp.sender_name == "S" + assert resp.receiver_surname == "RS" + + def test_sender_full_name(self): + resp = TransactionResp(sender_name="John", sender_surname="Doe") + assert resp.sender_full_name == "John Doe" + + def test_sender_full_name_partial(self): + resp = TransactionResp(sender_name="John") + assert resp.sender_full_name == "John" + + def test_sender_full_name_empty(self): + resp = TransactionResp() + assert resp.sender_full_name == "" + + def test_receiver_full_name(self): + resp = TransactionResp(receiver_name="Jane", receiver_surname="Smith") + assert resp.receiver_full_name == "Jane Smith" + + def test_receiver_full_name_empty(self): + resp = TransactionResp() + assert resp.receiver_full_name == "" + + def test_roundtrip(self): + original = TransactionResp(id=5, amount=200.0, sender_name="A") + restored = TransactionResp.from_dict(original.to_dict()) + assert restored.id == original.id + assert restored.amount == original.amount + + +class TestTransactionRespDataTableResp: + def test_default_values(self): + dt = TransactionRespDataTableResp() + assert dt.records_total == 0 + assert dt.records_filtered == 0 + assert dt.data is None + + def test_with_data(self): + items = [TransactionResp(id=1, amount=10.0), TransactionResp(id=2, amount=20.0)] + dt = TransactionRespDataTableResp( + records_total=2, records_filtered=2, data=items + ) + assert dt.records_total == 2 + assert len(dt.data) == 2 + + def test_to_dict(self): + items = [TransactionResp(id=1, amount=10.0)] + dt = TransactionRespDataTableResp( + records_total=1, records_filtered=1, data=items + ) + d = dt.to_dict() + assert d["recordsTotal"] == 1 + assert len(d["data"]) == 1 + assert d["data"][0]["id"] == 1 + + def test_to_dict_none_data(self): + dt = TransactionRespDataTableResp() + d = dt.to_dict() + assert d["data"] is None + + def test_from_dict(self): + data = { + "recordsTotal": 5, + "recordsFiltered": 3, + "data": [{"id": 1, "amount": 100.0}, {"id": 2, "amount": 200.0}], + } + dt = TransactionRespDataTableResp.from_dict(data) + assert dt.records_total == 5 + assert dt.records_filtered == 3 + assert len(dt.data) == 2 + + def test_from_dict_none_data(self): + dt = TransactionRespDataTableResp.from_dict({"recordsTotal": 0}) + assert dt.data is None + + def test_roundtrip(self): + items = [TransactionResp(id=1, amount=50.0)] + original = TransactionRespDataTableResp( + records_total=1, records_filtered=1, data=items + ) + restored = TransactionRespDataTableResp.from_dict(original.to_dict()) + assert restored.records_total == original.records_total + assert len(restored.data) == 1 + + +class TestAdminUserInfoRespDataTableResp: + def test_default_values(self): + dt = AdminUserInfoRespDataTableResp() + assert dt.records_total == 0 + assert dt.data is None + + def test_with_data(self): + users = [AdminUserInfoResp(name="A", username="a")] + dt = AdminUserInfoRespDataTableResp( + records_total=1, records_filtered=1, data=users + ) + assert len(dt.data) == 1 + + def test_to_dict(self): + users = [AdminUserInfoResp(name="X", username="x", role=1)] + dt = AdminUserInfoRespDataTableResp( + records_total=1, records_filtered=1, data=users + ) + d = dt.to_dict() + assert d["recordsTotal"] == 1 + assert d["data"][0]["name"] == "X" + + def test_to_dict_none_data(self): + dt = AdminUserInfoRespDataTableResp() + d = dt.to_dict() + assert d["data"] is None + + def test_from_dict(self): + data = { + "recordsTotal": 2, + "recordsFiltered": 2, + "data": [ + {"name": "A", "surname": "B", "username": "ab", "role": 0}, + {"name": "C", "surname": "D", "username": "cd", "role": 1}, + ], + } + dt = AdminUserInfoRespDataTableResp.from_dict(data) + assert dt.records_total == 2 + assert len(dt.data) == 2 + assert dt.data[1].role == 1 + + def test_roundtrip(self): + users = [AdminUserInfoResp(name="T", username="t")] + original = AdminUserInfoRespDataTableResp( + records_total=1, records_filtered=1, data=users + ) + restored = AdminUserInfoRespDataTableResp.from_dict(original.to_dict()) + assert restored.records_total == 1 + assert len(restored.data) == 1 diff --git a/tests/test_models/test_user_model.py b/tests/test_models/test_user_model.py new file mode 100644 index 0000000..8d7d630 --- /dev/null +++ b/tests/test_models/test_user_model.py @@ -0,0 +1,219 @@ +"""Tests for user-related data models.""" + +import pytest + +from bankweb.models.user import ( + AccountBalanceResp, + AdminUserInfoResp, + NewImageModel, + UserModel, +) + + +class TestUserModel: + def test_default_values(self): + user = UserModel() + assert user.id == 0 + assert user.user_name is None + assert user.password is None + assert user.name is None + assert user.surname is None + assert user.user_right == 0 + assert user.account_number is None + assert user.cookie is None + assert user.status is None + assert user.site_action is None + assert user.token is None + + def test_creation_with_values(self): + user = UserModel( + id=1, + user_name="john_doe", + password="secret", + name="John", + surname="Doe", + user_right=1, + account_number="123456789012", + ) + assert user.id == 1 + assert user.user_name == "john_doe" + assert user.name == "John" + assert user.surname == "Doe" + assert user.user_right == 1 + + def test_to_dict(self): + user = UserModel(id=1, user_name="alice", name="Alice", surname="Smith") + d = user.to_dict() + assert d["id"] == 1 + assert d["userName"] == "alice" + assert d["name"] == "Alice" + assert d["surname"] == "Smith" + assert d["userRight"] == 0 + assert d["password"] is None + + def test_from_dict(self): + data = { + "id": 5, + "userName": "bob", + "password": "pass123", + "name": "Bob", + "surname": "Jones", + "userRight": 1, + "accountNumber": "999888777666", + "token": "abc123", + } + user = UserModel.from_dict(data) + assert user.id == 5 + assert user.user_name == "bob" + assert user.password == "pass123" + assert user.name == "Bob" + assert user.surname == "Jones" + assert user.user_right == 1 + assert user.account_number == "999888777666" + assert user.token == "abc123" + + def test_from_dict_missing_keys(self): + user = UserModel.from_dict({}) + assert user.id == 0 + assert user.user_name is None + assert user.user_right == 0 + + def test_roundtrip(self): + original = UserModel( + id=10, user_name="test", name="Test", surname="User", user_right=0 + ) + restored = UserModel.from_dict(original.to_dict()) + assert restored.id == original.id + assert restored.user_name == original.user_name + assert restored.name == original.name + assert restored.surname == original.surname + + def test_full_name_both_parts(self): + user = UserModel(name="John", surname="Doe") + assert user.full_name == "John Doe" + + def test_full_name_name_only(self): + user = UserModel(name="John") + assert user.full_name == "John" + + def test_full_name_surname_only(self): + user = UserModel(surname="Doe") + assert user.full_name == "Doe" + + def test_full_name_empty(self): + user = UserModel() + assert user.full_name == "" + + def test_is_admin_true(self): + user = UserModel(user_right=1) + assert user.is_admin is True + + def test_is_admin_false(self): + user = UserModel(user_right=0) + assert user.is_admin is False + + def test_is_admin_other_value(self): + user = UserModel(user_right=2) + assert user.is_admin is False + + +class TestNewImageModel: + def test_default_values(self): + img = NewImageModel() + assert img.username is None + assert img.image_url is None + + def test_creation_with_values(self): + img = NewImageModel(username="alice", image_url="https://example.com/pic.jpg") + assert img.username == "alice" + assert img.image_url == "https://example.com/pic.jpg" + + def test_to_dict(self): + img = NewImageModel(username="bob", image_url="https://img.com/a.png") + d = img.to_dict() + assert d["username"] == "bob" + assert d["imageUrl"] == "https://img.com/a.png" + + def test_from_dict(self): + data = {"username": "charlie", "imageUrl": "https://img.com/b.gif"} + img = NewImageModel.from_dict(data) + assert img.username == "charlie" + assert img.image_url == "https://img.com/b.gif" + + def test_roundtrip(self): + original = NewImageModel(username="test", image_url="https://x.com/y.jpg") + restored = NewImageModel.from_dict(original.to_dict()) + assert restored.username == original.username + assert restored.image_url == original.image_url + + +class TestAccountBalanceResp: + def test_default_balance(self): + resp = AccountBalanceResp() + assert resp.balance == 0.0 + + def test_custom_balance(self): + resp = AccountBalanceResp(balance=1234.56) + assert resp.balance == 1234.56 + + def test_to_dict(self): + resp = AccountBalanceResp(balance=500.0) + assert resp.to_dict() == {"balance": 500.0} + + def test_from_dict(self): + resp = AccountBalanceResp.from_dict({"balance": 99.99}) + assert resp.balance == 99.99 + + def test_from_dict_missing(self): + resp = AccountBalanceResp.from_dict({}) + assert resp.balance == 0.0 + + def test_roundtrip(self): + original = AccountBalanceResp(balance=777.77) + restored = AccountBalanceResp.from_dict(original.to_dict()) + assert restored.balance == original.balance + + +class TestAdminUserInfoResp: + def test_default_values(self): + info = AdminUserInfoResp() + assert info.name is None + assert info.surname is None + assert info.username is None + assert info.role == 0 + + def test_creation_with_values(self): + info = AdminUserInfoResp( + name="Admin", surname="User", username="admin", role=1 + ) + assert info.name == "Admin" + assert info.role == 1 + + def test_to_dict(self): + info = AdminUserInfoResp(name="A", surname="B", username="ab", role=0) + d = info.to_dict() + assert d["name"] == "A" + assert d["surname"] == "B" + assert d["username"] == "ab" + assert d["role"] == 0 + + def test_from_dict(self): + data = {"name": "X", "surname": "Y", "username": "xy", "role": 1} + info = AdminUserInfoResp.from_dict(data) + assert info.name == "X" + assert info.username == "xy" + assert info.role == 1 + + def test_is_admin_true(self): + info = AdminUserInfoResp(role=1) + assert info.is_admin is True + + def test_is_admin_false(self): + info = AdminUserInfoResp(role=0) + assert info.is_admin is False + + def test_roundtrip(self): + original = AdminUserInfoResp(name="T", surname="U", username="tu", role=1) + restored = AdminUserInfoResp.from_dict(original.to_dict()) + assert restored.name == original.name + assert restored.role == original.role diff --git a/tests/test_services/__init__.py b/tests/test_services/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/test_services/test_auth_service.py b/tests/test_services/test_auth_service.py new file mode 100644 index 0000000..f64fc16 --- /dev/null +++ b/tests/test_services/test_auth_service.py @@ -0,0 +1,252 @@ +"""Tests for authentication service.""" + +import pytest + +from bankweb.models.user import UserModel +from bankweb.services.auth_service import AuthService + + +@pytest.fixture +def auth(): + return AuthService() + + +@pytest.fixture +def registered_auth(auth): + user = UserModel( + user_name="alice", + password="Password1", + name="Alice", + surname="Smith", + ) + auth.register(user) + return auth + + +class TestRegister: + def test_successful_registration(self, auth): + user = UserModel( + user_name="bob", password="Password1", name="Bob", surname="Jones" + ) + success, errors = auth.register(user) + assert success is True + assert errors == [] + + def test_assigns_id(self, auth): + user = UserModel( + user_name="bob", password="Password1", name="Bob", surname="Jones" + ) + auth.register(user) + stored = auth.get_user_by_username("bob") + assert stored.id == 1 + + def test_assigns_account_number(self, auth): + user = UserModel( + user_name="bob", password="Password1", name="Bob", surname="Jones" + ) + auth.register(user) + stored = auth.get_user_by_username("bob") + assert stored.account_number is not None + assert len(stored.account_number) == 12 + + def test_hashes_password(self, auth): + user = UserModel( + user_name="bob", password="Password1", name="Bob", surname="Jones" + ) + auth.register(user) + stored = auth.get_user_by_username("bob") + assert stored.password != "Password1" + + def test_duplicate_username(self, registered_auth): + user = UserModel( + user_name="alice", password="Password1", name="A", surname="B" + ) + success, errors = registered_auth.register(user) + assert success is False + assert "Username already exists." in errors + + def test_invalid_data(self, auth): + user = UserModel(user_name="ab", password="short", name="", surname="") + success, errors = auth.register(user) + assert success is False + assert len(errors) > 0 + + def test_multiple_users(self, auth): + for i in range(3): + user = UserModel( + user_name=f"user{i}", + password="Password1", + name=f"Name{i}", + surname=f"Surname{i}", + ) + success, _ = auth.register(user) + assert success is True + + +class TestLogin: + def test_successful_login(self, registered_auth): + user = UserModel(user_name="alice", password="Password1") + result, errors = registered_auth.login(user) + assert result is not None + assert errors == [] + assert result.user_name == "alice" + assert result.token is not None + + def test_wrong_password(self, registered_auth): + user = UserModel(user_name="alice", password="WrongPass1") + result, errors = registered_auth.login(user) + assert result is None + assert "Invalid username or password." in errors + + def test_nonexistent_user(self, registered_auth): + user = UserModel(user_name="nobody", password="Password1") + result, errors = registered_auth.login(user) + assert result is None + assert "Invalid username or password." in errors + + def test_missing_username(self, registered_auth): + user = UserModel(password="Password1") + result, errors = registered_auth.login(user) + assert result is None + assert any("Username" in e for e in errors) + + def test_missing_password(self, registered_auth): + user = UserModel(user_name="alice") + result, errors = registered_auth.login(user) + assert result is None + assert any("Password" in e for e in errors) + + def test_login_returns_no_password(self, registered_auth): + user = UserModel(user_name="alice", password="Password1") + result, _ = registered_auth.login(user) + assert result.password is None + + def test_login_returns_user_info(self, registered_auth): + user = UserModel(user_name="alice", password="Password1") + result, _ = registered_auth.login(user) + assert result.name == "Alice" + assert result.surname == "Smith" + + +class TestLogout: + def test_successful_logout(self, registered_auth): + user = UserModel(user_name="alice", password="Password1") + result, _ = registered_auth.login(user) + assert registered_auth.logout(result.token) is True + + def test_invalid_token(self, auth): + assert auth.logout("invalid_token") is False + + def test_double_logout(self, registered_auth): + user = UserModel(user_name="alice", password="Password1") + result, _ = registered_auth.login(user) + registered_auth.logout(result.token) + assert registered_auth.logout(result.token) is False + + +class TestPasswordRecovery: + def test_existing_user(self, registered_auth): + user = UserModel(user_name="alice") + success, errors = registered_auth.password_recovery(user) + assert success is True + assert errors == [] + + def test_nonexistent_user(self, registered_auth): + user = UserModel(user_name="nobody") + success, errors = registered_auth.password_recovery(user) + assert success is True + assert errors == [] + + def test_missing_username(self, auth): + user = UserModel() + success, errors = auth.password_recovery(user) + assert success is False + assert len(errors) > 0 + + +class TestRecover: + def test_successful_recovery(self, registered_auth): + registered_auth.password_recovery(UserModel(user_name="alice")) + token = list(registered_auth._recovery_tokens.keys())[0] + user = UserModel(token=token, password="NewPass1x") + success, errors = registered_auth.recover(user) + assert success is True + assert errors == [] + + def test_can_login_with_new_password(self, registered_auth): + registered_auth.password_recovery(UserModel(user_name="alice")) + token = list(registered_auth._recovery_tokens.keys())[0] + registered_auth.recover(UserModel(token=token, password="NewPass1x")) + result, errors = registered_auth.login( + UserModel(user_name="alice", password="NewPass1x") + ) + assert result is not None + + def test_old_password_no_longer_works(self, registered_auth): + registered_auth.password_recovery(UserModel(user_name="alice")) + token = list(registered_auth._recovery_tokens.keys())[0] + registered_auth.recover(UserModel(token=token, password="NewPass1x")) + result, errors = registered_auth.login( + UserModel(user_name="alice", password="Password1") + ) + assert result is None + + def test_invalid_token(self, auth): + user = UserModel(token="bad_token", password="NewPass1x") + success, errors = auth.recover(user) + assert success is False + assert "Invalid or expired recovery token." in errors + + def test_recover_validation_error(self, auth): + user = UserModel(token="", password="") + success, errors = auth.recover(user) + assert success is False + assert len(errors) > 0 + + def test_recover_user_deleted_after_token_issued(self, registered_auth): + registered_auth.password_recovery(UserModel(user_name="alice")) + token = list(registered_auth._recovery_tokens.keys())[0] + del registered_auth._users["alice"] + user = UserModel(token=token, password="NewPass1x") + success, errors = registered_auth.recover(user) + assert success is False + assert "User not found." in errors + + def test_token_consumed_after_use(self, registered_auth): + registered_auth.password_recovery(UserModel(user_name="alice")) + token = list(registered_auth._recovery_tokens.keys())[0] + registered_auth.recover(UserModel(token=token, password="NewPass1x")) + success, errors = registered_auth.recover( + UserModel(token=token, password="AnotherP1") + ) + assert success is False + + +class TestGetUserByToken: + def test_valid_token(self, registered_auth): + result, _ = registered_auth.login( + UserModel(user_name="alice", password="Password1") + ) + user = registered_auth.get_user_by_token(result.token) + assert user is not None + assert user.user_name == "alice" + + def test_invalid_token(self, auth): + assert auth.get_user_by_token("nonexistent") is None + + def test_after_logout(self, registered_auth): + result, _ = registered_auth.login( + UserModel(user_name="alice", password="Password1") + ) + registered_auth.logout(result.token) + assert registered_auth.get_user_by_token(result.token) is None + + +class TestGetUserByUsername: + def test_existing_user(self, registered_auth): + user = registered_auth.get_user_by_username("alice") + assert user is not None + assert user.name == "Alice" + + def test_nonexistent_user(self, auth): + assert auth.get_user_by_username("nobody") is None diff --git a/tests/test_services/test_search_service.py b/tests/test_services/test_search_service.py new file mode 100644 index 0000000..a4d95ac --- /dev/null +++ b/tests/test_services/test_search_service.py @@ -0,0 +1,99 @@ +"""Tests for search service.""" + +import pytest + +from bankweb.models.user import UserModel +from bankweb.services.search_service import SearchService + + +@pytest.fixture +def svc(): + service = SearchService() + service.add_user(UserModel(user_name="alice", name="Alice", surname="Smith")) + service.add_user(UserModel(user_name="bob_jones", name="Bob", surname="Jones")) + service.add_user(UserModel(user_name="charlie", name="Charlie", surname="Brown")) + service.add_portal_content("faq", "Frequently asked questions about banking.") + service.add_portal_content("about", "About our banking services.") + service.add_portal_content("contact", "Contact us for support.") + return service + + +class TestFindUser: + def test_search_by_username(self, svc): + results = svc.find_user("alice") + assert "alice" in results + + def test_search_by_partial_username(self, svc): + results = svc.find_user("bob") + assert "bob_jones" in results + + def test_search_by_name(self, svc): + results = svc.find_user("Charlie") + assert "charlie" in results + + def test_search_by_surname(self, svc): + results = svc.find_user("smith") + assert "alice" in results + + def test_case_insensitive(self, svc): + results = svc.find_user("ALICE") + assert "alice" in results + + def test_no_results(self, svc): + results = svc.find_user("zzzzz") + assert results == [] + + def test_empty_term(self, svc): + results = svc.find_user("") + assert results == [] + + def test_multiple_matches(self, svc): + results = svc.find_user("o") + assert len(results) >= 2 + + def test_add_user_then_search(self, svc): + svc.add_user(UserModel(user_name="diana", name="Diana", surname="Prince")) + results = svc.find_user("diana") + assert "diana" in results + + def test_user_without_username_skipped(self): + svc = SearchService() + svc.add_user(UserModel(name="NoUsername")) + results = svc.find_user("NoUsername") + assert results == [] + + +class TestPortalSearch: + def test_search_by_key(self, svc): + results = svc.portal_search("faq") + assert "faq" in results + + def test_search_by_content(self, svc): + results = svc.portal_search("banking") + assert "faq" in results + assert "about" in results + + def test_case_insensitive(self, svc): + results = svc.portal_search("BANKING") + assert len(results) >= 1 + + def test_no_results(self, svc): + results = svc.portal_search("zzzzz") + assert results == [] + + def test_none_returns_all(self, svc): + results = svc.portal_search(None) + assert len(results) == 3 + + def test_empty_returns_all(self, svc): + results = svc.portal_search("") + assert len(results) == 3 + + def test_partial_match(self, svc): + results = svc.portal_search("support") + assert "contact" in results + + def test_add_content_then_search(self, svc): + svc.add_portal_content("terms", "Terms and conditions of service.") + results = svc.portal_search("terms") + assert "terms" in results diff --git a/tests/test_services/test_store_service.py b/tests/test_services/test_store_service.py new file mode 100644 index 0000000..fa33ea4 --- /dev/null +++ b/tests/test_services/test_store_service.py @@ -0,0 +1,200 @@ +"""Tests for store service.""" + +import pytest + +from bankweb.models.store import BuyProductReq, StoreItem +from bankweb.services.store_service import StoreService + + +@pytest.fixture +def svc(): + service = StoreService() + service.set_balance("alice", 1000.0) + return service + + +@pytest.fixture +def svc_with_items(svc): + svc.create_item(StoreItem(name="Widget", description="A widget", price=25.0)) + svc.create_item(StoreItem(name="Gadget", description="A gadget", price=50.0)) + return svc + + +class TestCreateItem: + def test_successful(self, svc): + item = StoreItem(name="Test Item", price=10.0) + success, errors = svc.create_item(item) + assert success is True + assert errors == [] + assert item.id == 1 + + def test_assigns_incremental_ids(self, svc): + svc.create_item(StoreItem(name="A", price=1.0)) + svc.create_item(StoreItem(name="B", price=2.0)) + assert svc.get_item(1).name == "A" + assert svc.get_item(2).name == "B" + + def test_validation_failure(self, svc): + item = StoreItem(price=10.0) + success, errors = svc.create_item(item) + assert success is False + assert len(errors) > 0 + + def test_negative_price(self, svc): + item = StoreItem(name="Bad", price=-5.0) + success, errors = svc.create_item(item) + assert success is False + + +class TestGetItem: + def test_existing_item(self, svc_with_items): + item = svc_with_items.get_item(1) + assert item is not None + assert item.name == "Widget" + + def test_nonexistent_item(self, svc_with_items): + assert svc_with_items.get_item(999) is None + + def test_invalid_id(self, svc_with_items): + assert svc_with_items.get_item(0) is None + + def test_negative_id(self, svc_with_items): + assert svc_with_items.get_item(-1) is None + + +class TestGetAllItems: + def test_empty(self, svc): + assert svc.get_all_items() == [] + + def test_with_items(self, svc_with_items): + items = svc_with_items.get_all_items() + assert len(items) == 2 + + def test_returns_copies(self, svc_with_items): + items1 = svc_with_items.get_all_items() + items2 = svc_with_items.get_all_items() + assert items1 is not items2 + + +class TestEditItem: + def test_successful_edit(self, svc_with_items): + updated = StoreItem(name="New Widget", price=30.0) + success, errors = svc_with_items.edit_item(1, updated) + assert success is True + assert svc_with_items.get_item(1).name == "New Widget" + assert svc_with_items.get_item(1).price == 30.0 + + def test_preserves_id(self, svc_with_items): + updated = StoreItem(name="New Widget", price=30.0) + svc_with_items.edit_item(1, updated) + assert svc_with_items.get_item(1).id == 1 + + def test_nonexistent_item(self, svc_with_items): + updated = StoreItem(name="Ghost", price=10.0) + success, errors = svc_with_items.edit_item(999, updated) + assert success is False + assert "Item not found." in errors + + def test_invalid_id(self, svc_with_items): + updated = StoreItem(name="X", price=10.0) + success, errors = svc_with_items.edit_item(0, updated) + assert success is False + + def test_validation_failure(self, svc_with_items): + updated = StoreItem(price=10.0) + success, errors = svc_with_items.edit_item(1, updated) + assert success is False + + +class TestDeleteItem: + def test_successful_delete(self, svc_with_items): + item = svc_with_items.get_item(1) + success, errors = svc_with_items.delete_item(item) + assert success is True + assert svc_with_items.get_item(1) is None + + def test_nonexistent_item(self, svc_with_items): + item = StoreItem(id=999, name="Ghost") + success, errors = svc_with_items.delete_item(item) + assert success is False + assert "Item not found." in errors + + def test_delete_reduces_count(self, svc_with_items): + item = svc_with_items.get_item(1) + svc_with_items.delete_item(item) + assert len(svc_with_items.get_all_items()) == 1 + + +class TestBuyProduct: + def test_successful_purchase(self, svc_with_items): + req = BuyProductReq(id=1, quantity=2, price=25.0) + success, errors = svc_with_items.buy_product(req, "alice") + assert success is True + assert errors == [] + + def test_deducts_balance(self, svc_with_items): + req = BuyProductReq(id=1, quantity=2, price=25.0) + svc_with_items.buy_product(req, "alice") + assert svc_with_items.get_balance("alice") == 950.0 + + def test_insufficient_funds(self, svc_with_items): + req = BuyProductReq(id=1, quantity=100, price=25.0) + success, errors = svc_with_items.buy_product(req, "alice") + assert success is False + assert "Insufficient funds." in errors + + def test_nonexistent_product(self, svc_with_items): + req = BuyProductReq(id=999, quantity=1, price=10.0) + success, errors = svc_with_items.buy_product(req, "alice") + assert success is False + assert "Product not found." in errors + + def test_validation_failure(self, svc_with_items): + req = BuyProductReq(id=0, quantity=0, price=-1.0) + success, errors = svc_with_items.buy_product(req, "alice") + assert success is False + + def test_creates_purchase_history(self, svc_with_items): + req = BuyProductReq(id=1, quantity=1, price=25.0) + svc_with_items.buy_product(req, "alice") + history = svc_with_items.get_purchase_history("alice") + assert len(history) == 1 + assert history[0].name == "Widget" + + +class TestGetPurchaseHistory: + def test_empty_history(self, svc): + assert svc.get_purchase_history("alice") == [] + + def test_user_specific(self, svc_with_items): + svc_with_items.set_balance("bob", 500.0) + svc_with_items.buy_product(BuyProductReq(id=1, quantity=1, price=25.0), "alice") + svc_with_items.buy_product(BuyProductReq(id=2, quantity=1, price=50.0), "bob") + assert len(svc_with_items.get_purchase_history("alice")) == 1 + assert len(svc_with_items.get_purchase_history("bob")) == 1 + + def test_all_history(self, svc_with_items): + svc_with_items.buy_product(BuyProductReq(id=1, quantity=1, price=25.0), "alice") + all_history = svc_with_items.get_purchase_history() + assert len(all_history) == 1 + + +class TestGetAllPurchases: + def test_empty(self, svc): + assert svc.get_all_purchases() == [] + + def test_grouped_by_user(self, svc_with_items): + svc_with_items.set_balance("bob", 500.0) + svc_with_items.buy_product(BuyProductReq(id=1, quantity=1, price=25.0), "alice") + svc_with_items.buy_product(BuyProductReq(id=2, quantity=1, price=50.0), "bob") + groups = svc_with_items.get_all_purchases() + assert len(groups) == 2 + + +class TestBalance: + def test_set_and_get(self, svc): + svc.set_balance("charlie", 300.0) + assert svc.get_balance("charlie") == 300.0 + + def test_default_balance(self, svc): + assert svc.get_balance("unknown") == 0.0 diff --git a/tests/test_services/test_transaction_service.py b/tests/test_services/test_transaction_service.py new file mode 100644 index 0000000..f2994be --- /dev/null +++ b/tests/test_services/test_transaction_service.py @@ -0,0 +1,209 @@ +"""Tests for transaction service.""" + +import pytest +from datetime import datetime + +from bankweb.models.transaction import TransactionDBModel +from bankweb.services.transaction_service import TransactionService + + +@pytest.fixture +def svc(): + service = TransactionService() + service.set_balance("alice", 1000.0) + service.set_balance("bob", 500.0) + return service + + +class TestCreate: + def test_successful_transaction(self, svc): + txn = TransactionDBModel(sender_id="alice", receiver_id="bob", amount=100.0) + success, errors = svc.create(txn) + assert success is True + assert errors == [] + + def test_assigns_id(self, svc): + txn = TransactionDBModel(sender_id="alice", receiver_id="bob", amount=50.0) + svc.create(txn) + assert txn.id == 1 + + def test_assigns_datetime(self, svc): + txn = TransactionDBModel(sender_id="alice", receiver_id="bob", amount=50.0) + svc.create(txn) + assert txn.transaction_date_time is not None + + def test_preserves_custom_datetime(self, svc): + dt = datetime(2024, 1, 1, 12, 0) + txn = TransactionDBModel( + sender_id="alice", receiver_id="bob", amount=50.0, + transaction_date_time=dt, + ) + svc.create(txn) + assert txn.transaction_date_time == dt + + def test_deducts_from_sender(self, svc): + txn = TransactionDBModel(sender_id="alice", receiver_id="bob", amount=200.0) + svc.create(txn) + assert svc.get_balance("alice") == 800.0 + + def test_adds_to_receiver(self, svc): + txn = TransactionDBModel(sender_id="alice", receiver_id="bob", amount=200.0) + svc.create(txn) + assert svc.get_balance("bob") == 700.0 + + def test_insufficient_funds(self, svc): + txn = TransactionDBModel(sender_id="alice", receiver_id="bob", amount=2000.0) + success, errors = svc.create(txn) + assert success is False + assert "Insufficient funds." in errors + + def test_balance_unchanged_on_failure(self, svc): + txn = TransactionDBModel(sender_id="alice", receiver_id="bob", amount=2000.0) + svc.create(txn) + assert svc.get_balance("alice") == 1000.0 + assert svc.get_balance("bob") == 500.0 + + def test_validation_errors(self, svc): + txn = TransactionDBModel(sender_id="alice", receiver_id="alice", amount=100.0) + success, errors = svc.create(txn) + assert success is False + assert any("same" in e.lower() for e in errors) + + def test_zero_amount(self, svc): + txn = TransactionDBModel(sender_id="alice", receiver_id="bob", amount=0.0) + success, errors = svc.create(txn) + assert success is False + + def test_incremental_ids(self, svc): + for i in range(3): + txn = TransactionDBModel( + sender_id="alice", receiver_id="bob", amount=10.0 + ) + svc.create(txn) + assert svc.get(1) is not None + assert svc.get(2) is not None + assert svc.get(3) is not None + + def test_unknown_sender_zero_balance(self, svc): + txn = TransactionDBModel(sender_id="unknown", receiver_id="bob", amount=10.0) + success, errors = svc.create(txn) + assert success is False + assert "Insufficient funds." in errors + + +class TestGet: + def test_existing_transaction(self, svc): + txn = TransactionDBModel(sender_id="alice", receiver_id="bob", amount=50.0) + svc.create(txn) + result = svc.get(1) + assert result is not None + assert result.sender_id == "alice" + + def test_nonexistent_transaction(self, svc): + assert svc.get(999) is None + + def test_invalid_id(self, svc): + assert svc.get(0) is None + + def test_negative_id(self, svc): + assert svc.get(-1) is None + + +class TestGetTransactions: + def test_empty(self, svc): + result = svc.get_transactions() + assert result.records_total == 0 + assert result.records_filtered == 0 + assert result.data == [] + + def test_pagination(self, svc): + for i in range(5): + txn = TransactionDBModel( + sender_id="alice", receiver_id="bob", amount=10.0, + reason=f"Payment {i}", + ) + svc.create(txn) + result = svc.get_transactions(start=0, length=2) + assert result.records_total == 5 + assert len(result.data) == 2 + + def test_pagination_offset(self, svc): + for i in range(5): + txn = TransactionDBModel( + sender_id="alice", receiver_id="bob", amount=10.0 + ) + svc.create(txn) + result = svc.get_transactions(start=3, length=10) + assert len(result.data) == 2 + + def test_search_by_reason(self, svc): + svc.create(TransactionDBModel( + sender_id="alice", receiver_id="bob", amount=10.0, reason="Groceries" + )) + svc.create(TransactionDBModel( + sender_id="alice", receiver_id="bob", amount=20.0, reason="Rent" + )) + result = svc.get_transactions(search_value="grocer") + assert result.records_filtered == 1 + assert result.data[0].reason == "Groceries" + + def test_search_by_reference(self, svc): + svc.create(TransactionDBModel( + sender_id="alice", receiver_id="bob", amount=10.0, reference="REF001" + )) + result = svc.get_transactions(search_value="ref001") + assert result.records_filtered == 1 + + def test_search_by_sender(self, svc): + svc.create(TransactionDBModel( + sender_id="alice", receiver_id="bob", amount=10.0 + )) + result = svc.get_transactions(search_value="alice") + assert result.records_filtered == 1 + + def test_search_no_match(self, svc): + svc.create(TransactionDBModel( + sender_id="alice", receiver_id="bob", amount=10.0 + )) + result = svc.get_transactions(search_value="zzzzz") + assert result.records_filtered == 0 + + +class TestGetTransactionHistory: + def test_user_history(self, svc): + svc.create(TransactionDBModel( + sender_id="alice", receiver_id="bob", amount=50.0 + )) + svc.create(TransactionDBModel( + sender_id="bob", receiver_id="alice", amount=30.0 + )) + history = svc.get_transaction_history("alice") + assert len(history) == 2 + + def test_no_history(self, svc): + history = svc.get_transaction_history("charlie") + assert history == [] + + def test_only_own_transactions(self, svc): + svc.set_balance("charlie", 500.0) + svc.create(TransactionDBModel( + sender_id="charlie", receiver_id="bob", amount=10.0 + )) + svc.create(TransactionDBModel( + sender_id="alice", receiver_id="bob", amount=20.0 + )) + history = svc.get_transaction_history("charlie") + assert len(history) == 1 + + +class TestBalance: + def test_set_and_get(self, svc): + svc.set_balance("new_user", 750.0) + assert svc.get_balance("new_user") == 750.0 + + def test_default_balance(self, svc): + assert svc.get_balance("unknown") == 0.0 + + def test_update_balance(self, svc): + svc.set_balance("alice", 2000.0) + assert svc.get_balance("alice") == 2000.0 diff --git a/tests/test_services/test_user_service.py b/tests/test_services/test_user_service.py new file mode 100644 index 0000000..7ff91dd --- /dev/null +++ b/tests/test_services/test_user_service.py @@ -0,0 +1,117 @@ +"""Tests for user service.""" + +import pytest + +from bankweb.models.user import NewImageModel, UserModel +from bankweb.services.user_service import UserService + + +@pytest.fixture +def svc(): + service = UserService() + service.add_user(UserModel(user_name="alice", name="Alice", surname="Smith", user_right=0)) + service.add_user(UserModel(user_name="admin", name="Admin", surname="User", user_right=1)) + return service + + +class TestGetProfileImage: + def test_no_image(self, svc): + assert svc.get_profile_image("alice") is None + + def test_after_setting_image(self, svc): + svc.set_profile_image( + NewImageModel(username="alice", image_url="https://example.com/pic.jpg") + ) + assert svc.get_profile_image("alice") == "https://example.com/pic.jpg" + + def test_nonexistent_user(self, svc): + assert svc.get_profile_image("nobody") is None + + +class TestSetProfileImage: + def test_valid_image(self, svc): + img = NewImageModel(username="alice", image_url="https://example.com/pic.png") + success, errors = svc.set_profile_image(img) + assert success is True + assert errors == [] + + def test_invalid_extension(self, svc): + img = NewImageModel(username="alice", image_url="https://example.com/file.pdf") + success, errors = svc.set_profile_image(img) + assert success is False + assert len(errors) > 0 + + def test_missing_username(self, svc): + img = NewImageModel(image_url="https://example.com/pic.jpg") + success, errors = svc.set_profile_image(img) + assert success is False + + def test_missing_url(self, svc): + img = NewImageModel(username="alice") + success, errors = svc.set_profile_image(img) + assert success is False + + def test_overwrite_image(self, svc): + svc.set_profile_image( + NewImageModel(username="alice", image_url="https://example.com/old.jpg") + ) + svc.set_profile_image( + NewImageModel(username="alice", image_url="https://example.com/new.png") + ) + assert svc.get_profile_image("alice") == "https://example.com/new.png" + + +class TestGetAvailableFunds: + def test_default_balance(self, svc): + resp = svc.get_available_funds("alice") + assert resp.balance == 0.0 + + def test_after_setting_balance(self, svc): + svc.set_balance("alice", 500.0) + resp = svc.get_available_funds("alice") + assert resp.balance == 500.0 + + def test_unknown_user(self, svc): + resp = svc.get_available_funds("unknown") + assert resp.balance == 0.0 + + +class TestSetBalance: + def test_set_balance(self, svc): + svc.set_balance("alice", 1234.56) + assert svc.get_available_funds("alice").balance == 1234.56 + + def test_update_balance(self, svc): + svc.set_balance("alice", 100.0) + svc.set_balance("alice", 200.0) + assert svc.get_available_funds("alice").balance == 200.0 + + +class TestGetAllUsers: + def test_returns_all(self, svc): + users = svc.get_all_users() + assert len(users) == 2 + + def test_contains_admin_info(self, svc): + users = svc.get_all_users() + admin_users = [u for u in users if u.role == 1] + assert len(admin_users) == 1 + assert admin_users[0].username == "admin" + + def test_empty_service(self): + svc = UserService() + assert svc.get_all_users() == [] + + +class TestAddUser: + def test_add_user(self): + svc = UserService() + svc.add_user(UserModel(user_name="test", name="Test")) + users = svc.get_all_users() + assert len(users) == 1 + + def test_add_user_without_username(self): + svc = UserService() + svc.add_user(UserModel(name="NoUsername")) + users = svc.get_all_users() + assert len(users) == 0 diff --git a/tests/test_validators/__init__.py b/tests/test_validators/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/test_validators/test_store_validator.py b/tests/test_validators/test_store_validator.py new file mode 100644 index 0000000..7c52dbb --- /dev/null +++ b/tests/test_validators/test_store_validator.py @@ -0,0 +1,129 @@ +"""Tests for store validation logic.""" + +import pytest + +from bankweb.models.store import BuyProductReq, StoreItem +from bankweb.validators.store_validator import StoreValidator + + +class TestValidateStoreItem: + def test_valid_item(self): + item = StoreItem(name="Widget", price=10.0, installments=3) + errors = StoreValidator.validate_store_item(item) + assert errors == [] + + def test_missing_name(self): + item = StoreItem(price=10.0) + errors = StoreValidator.validate_store_item(item) + assert "Item name is required." in errors + + def test_empty_name(self): + item = StoreItem(name=" ", price=10.0) + errors = StoreValidator.validate_store_item(item) + assert "Item name is required." in errors + + def test_long_name(self): + item = StoreItem(name="x" * 201, price=10.0) + errors = StoreValidator.validate_store_item(item) + assert any("at most 200" in e for e in errors) + + def test_valid_name_boundary(self): + item = StoreItem(name="x" * 200, price=10.0) + errors = StoreValidator.validate_store_item(item) + assert errors == [] + + def test_long_description(self): + item = StoreItem(name="Widget", description="x" * 2001, price=10.0) + errors = StoreValidator.validate_store_item(item) + assert any("Description" in e for e in errors) + + def test_valid_description_boundary(self): + item = StoreItem(name="Widget", description="x" * 2000, price=10.0) + errors = StoreValidator.validate_store_item(item) + assert errors == [] + + def test_negative_price(self): + item = StoreItem(name="Widget", price=-1.0) + errors = StoreValidator.validate_store_item(item) + assert "Price cannot be negative." in errors + + def test_zero_price(self): + item = StoreItem(name="Widget", price=0.0) + errors = StoreValidator.validate_store_item(item) + assert errors == [] + + def test_negative_installments(self): + item = StoreItem(name="Widget", price=10.0, installments=-1) + errors = StoreValidator.validate_store_item(item) + assert "Installments cannot be negative." in errors + + def test_exceed_max_installments(self): + item = StoreItem(name="Widget", price=10.0, installments=61) + errors = StoreValidator.validate_store_item(item) + assert any("cannot exceed 60" in e for e in errors) + + def test_boundary_installments(self): + item = StoreItem(name="Widget", price=10.0, installments=60) + errors = StoreValidator.validate_store_item(item) + assert errors == [] + + +class TestValidateBuyProduct: + def test_valid_request(self): + req = BuyProductReq(id=1, quantity=2, price=25.0) + errors = StoreValidator.validate_buy_product(req) + assert errors == [] + + def test_zero_product_id(self): + req = BuyProductReq(id=0, quantity=2, price=25.0) + errors = StoreValidator.validate_buy_product(req) + assert "Product ID must be a positive integer." in errors + + def test_negative_product_id(self): + req = BuyProductReq(id=-1, quantity=2, price=25.0) + errors = StoreValidator.validate_buy_product(req) + assert "Product ID must be a positive integer." in errors + + def test_zero_quantity(self): + req = BuyProductReq(id=1, quantity=0, price=25.0) + errors = StoreValidator.validate_buy_product(req) + assert "Quantity must be a positive integer." in errors + + def test_negative_quantity(self): + req = BuyProductReq(id=1, quantity=-1, price=25.0) + errors = StoreValidator.validate_buy_product(req) + assert "Quantity must be a positive integer." in errors + + def test_exceed_max_quantity(self): + req = BuyProductReq(id=1, quantity=1000, price=25.0) + errors = StoreValidator.validate_buy_product(req) + assert "Quantity cannot exceed 999." in errors + + def test_boundary_quantity(self): + req = BuyProductReq(id=1, quantity=999, price=25.0) + errors = StoreValidator.validate_buy_product(req) + assert errors == [] + + def test_negative_price(self): + req = BuyProductReq(id=1, quantity=1, price=-10.0) + errors = StoreValidator.validate_buy_product(req) + assert "Price cannot be negative." in errors + + def test_zero_price(self): + req = BuyProductReq(id=1, quantity=1, price=0.0) + errors = StoreValidator.validate_buy_product(req) + assert errors == [] + + +class TestValidateItemId: + def test_valid_id(self): + errors = StoreValidator.validate_item_id(1) + assert errors == [] + + def test_zero_id(self): + errors = StoreValidator.validate_item_id(0) + assert "Item ID must be a positive integer." in errors + + def test_negative_id(self): + errors = StoreValidator.validate_item_id(-1) + assert "Item ID must be a positive integer." in errors diff --git a/tests/test_validators/test_transaction_validator.py b/tests/test_validators/test_transaction_validator.py new file mode 100644 index 0000000..ff18d8a --- /dev/null +++ b/tests/test_validators/test_transaction_validator.py @@ -0,0 +1,142 @@ +"""Tests for transaction validation logic.""" + +import pytest + +from bankweb.models.transaction import TransactionDBModel +from bankweb.validators.transaction_validator import TransactionValidator + + +class TestValidateCreate: + def test_valid_transaction(self): + txn = TransactionDBModel( + sender_id="user1", receiver_id="user2", amount=100.0 + ) + errors = TransactionValidator.validate_create(txn) + assert errors == [] + + def test_missing_sender(self): + txn = TransactionDBModel(receiver_id="user2", amount=100.0) + errors = TransactionValidator.validate_create(txn) + assert "Sender ID is required." in errors + + def test_empty_sender(self): + txn = TransactionDBModel(sender_id=" ", receiver_id="user2", amount=100.0) + errors = TransactionValidator.validate_create(txn) + assert "Sender ID is required." in errors + + def test_missing_receiver(self): + txn = TransactionDBModel(sender_id="user1", amount=100.0) + errors = TransactionValidator.validate_create(txn) + assert "Receiver ID is required." in errors + + def test_empty_receiver(self): + txn = TransactionDBModel(sender_id="user1", receiver_id=" ", amount=100.0) + errors = TransactionValidator.validate_create(txn) + assert "Receiver ID is required." in errors + + def test_same_sender_receiver(self): + txn = TransactionDBModel( + sender_id="user1", receiver_id="user1", amount=100.0 + ) + errors = TransactionValidator.validate_create(txn) + assert "Sender and receiver cannot be the same." in errors + + def test_zero_amount(self): + txn = TransactionDBModel( + sender_id="user1", receiver_id="user2", amount=0.0 + ) + errors = TransactionValidator.validate_create(txn) + assert "Amount must be greater than zero." in errors + + def test_negative_amount(self): + txn = TransactionDBModel( + sender_id="user1", receiver_id="user2", amount=-50.0 + ) + errors = TransactionValidator.validate_create(txn) + assert "Amount must be greater than zero." in errors + + def test_exceed_max_amount(self): + txn = TransactionDBModel( + sender_id="user1", receiver_id="user2", amount=1_000_001.0 + ) + errors = TransactionValidator.validate_create(txn) + assert any("cannot exceed" in e for e in errors) + + def test_exact_max_amount(self): + txn = TransactionDBModel( + sender_id="user1", receiver_id="user2", amount=1_000_000.0 + ) + errors = TransactionValidator.validate_create(txn) + assert errors == [] + + def test_long_reason(self): + txn = TransactionDBModel( + sender_id="user1", receiver_id="user2", amount=10.0, + reason="x" * 501, + ) + errors = TransactionValidator.validate_create(txn) + assert any("Reason" in e for e in errors) + + def test_valid_reason(self): + txn = TransactionDBModel( + sender_id="user1", receiver_id="user2", amount=10.0, + reason="x" * 500, + ) + errors = TransactionValidator.validate_create(txn) + assert errors == [] + + def test_long_reference(self): + txn = TransactionDBModel( + sender_id="user1", receiver_id="user2", amount=10.0, + reference="R" * 101, + ) + errors = TransactionValidator.validate_create(txn) + assert any("Reference" in e for e in errors) + + def test_valid_reference(self): + txn = TransactionDBModel( + sender_id="user1", receiver_id="user2", amount=10.0, + reference="R" * 100, + ) + errors = TransactionValidator.validate_create(txn) + assert errors == [] + + +class TestValidateGet: + def test_valid_id(self): + errors = TransactionValidator.validate_get(1) + assert errors == [] + + def test_zero_id(self): + errors = TransactionValidator.validate_get(0) + assert "Transaction ID must be a positive integer." in errors + + def test_negative_id(self): + errors = TransactionValidator.validate_get(-1) + assert "Transaction ID must be a positive integer." in errors + + +class TestValidateGetTransactions: + def test_valid_params(self): + errors = TransactionValidator.validate_get_transactions(0, 10) + assert errors == [] + + def test_negative_start(self): + errors = TransactionValidator.validate_get_transactions(-1, 10) + assert "Start must be non-negative." in errors + + def test_zero_length(self): + errors = TransactionValidator.validate_get_transactions(0, 0) + assert "Length must be a positive integer." in errors + + def test_negative_length(self): + errors = TransactionValidator.validate_get_transactions(0, -5) + assert "Length must be a positive integer." in errors + + def test_exceed_max_length(self): + errors = TransactionValidator.validate_get_transactions(0, 101) + assert "Length cannot exceed 100." in errors + + def test_boundary_length(self): + errors = TransactionValidator.validate_get_transactions(0, 100) + assert errors == [] diff --git a/tests/test_validators/test_user_validator.py b/tests/test_validators/test_user_validator.py new file mode 100644 index 0000000..cb6e30d --- /dev/null +++ b/tests/test_validators/test_user_validator.py @@ -0,0 +1,219 @@ +"""Tests for user validation logic.""" + +import pytest + +from bankweb.models.user import NewImageModel, UserModel +from bankweb.validators.user_validator import UserValidator + + +class TestValidateLogin: + def test_valid_login(self): + user = UserModel(user_name="alice", password="Password1") + errors = UserValidator.validate_login(user) + assert errors == [] + + def test_missing_username(self): + user = UserModel(password="Password1") + errors = UserValidator.validate_login(user) + assert "Username is required." in errors + + def test_empty_username(self): + user = UserModel(user_name=" ", password="Password1") + errors = UserValidator.validate_login(user) + assert "Username is required." in errors + + def test_missing_password(self): + user = UserModel(user_name="alice") + errors = UserValidator.validate_login(user) + assert "Password is required." in errors + + def test_empty_password(self): + user = UserModel(user_name="alice", password=" ") + errors = UserValidator.validate_login(user) + assert "Password is required." in errors + + def test_both_missing(self): + user = UserModel() + errors = UserValidator.validate_login(user) + assert len(errors) == 2 + + +class TestValidateRegistration: + def test_valid_registration(self): + user = UserModel( + user_name="john_doe", + password="Password1", + name="John", + surname="Doe", + ) + errors = UserValidator.validate_registration(user) + assert errors == [] + + def test_missing_username(self): + user = UserModel(password="Password1", name="J", surname="D") + errors = UserValidator.validate_registration(user) + assert "Username is required." in errors + + def test_short_username(self): + user = UserModel(user_name="ab", password="Password1", name="J", surname="D") + errors = UserValidator.validate_registration(user) + assert any("at least 3" in e for e in errors) + + def test_long_username(self): + user = UserModel( + user_name="a" * 51, password="Password1", name="J", surname="D" + ) + errors = UserValidator.validate_registration(user) + assert any("at most 50" in e for e in errors) + + def test_invalid_username_chars(self): + user = UserModel( + user_name="john doe!", password="Password1", name="J", surname="D" + ) + errors = UserValidator.validate_registration(user) + assert any("letters, digits, and underscores" in e for e in errors) + + def test_missing_password(self): + user = UserModel(user_name="john_doe", name="J", surname="D") + errors = UserValidator.validate_registration(user) + assert "Password is required." in errors + + def test_short_password(self): + user = UserModel(user_name="john_doe", password="Abc1", name="J", surname="D") + errors = UserValidator.validate_registration(user) + assert any("at least 8" in e for e in errors) + + def test_long_password(self): + user = UserModel( + user_name="john_doe", password="A" * 129, name="J", surname="D" + ) + errors = UserValidator.validate_registration(user) + assert any("at most 128" in e for e in errors) + + def test_password_no_uppercase(self): + user = UserModel( + user_name="john_doe", password="password1", name="J", surname="D" + ) + errors = UserValidator.validate_registration(user) + assert any("uppercase" in e for e in errors) + + def test_password_no_lowercase(self): + user = UserModel( + user_name="john_doe", password="PASSWORD1", name="J", surname="D" + ) + errors = UserValidator.validate_registration(user) + assert any("lowercase" in e for e in errors) + + def test_password_no_digit(self): + user = UserModel( + user_name="john_doe", password="Passwordx", name="J", surname="D" + ) + errors = UserValidator.validate_registration(user) + assert any("digit" in e for e in errors) + + def test_missing_name(self): + user = UserModel( + user_name="john_doe", password="Password1", surname="D" + ) + errors = UserValidator.validate_registration(user) + assert "Name is required." in errors + + def test_missing_surname(self): + user = UserModel( + user_name="john_doe", password="Password1", name="J" + ) + errors = UserValidator.validate_registration(user) + assert "Surname is required." in errors + + +class TestValidatePasswordRecovery: + def test_valid(self): + user = UserModel(user_name="alice") + errors = UserValidator.validate_password_recovery(user) + assert errors == [] + + def test_missing_username(self): + user = UserModel() + errors = UserValidator.validate_password_recovery(user) + assert any("required" in e.lower() for e in errors) + + +class TestValidateRecover: + def test_valid(self): + user = UserModel(token="abc123", password="NewPass1x") + errors = UserValidator.validate_recover(user) + assert errors == [] + + def test_missing_token(self): + user = UserModel(password="NewPass1x") + errors = UserValidator.validate_recover(user) + assert any("token" in e.lower() for e in errors) + + def test_missing_password(self): + user = UserModel(token="abc123") + errors = UserValidator.validate_recover(user) + assert "Password is required." in errors + + +class TestValidateContactUs: + def test_valid(self): + user = UserModel(name="John", user_name="john@example.com", site_action="Hello") + errors = UserValidator.validate_contact_us(user) + assert errors == [] + + def test_missing_name(self): + user = UserModel(user_name="john@example.com", site_action="Hello") + errors = UserValidator.validate_contact_us(user) + assert "Name is required." in errors + + def test_missing_email(self): + user = UserModel(name="John", site_action="Hello") + errors = UserValidator.validate_contact_us(user) + assert any("Email" in e or "username" in e.lower() for e in errors) + + def test_missing_message(self): + user = UserModel(name="John", user_name="john@example.com") + errors = UserValidator.validate_contact_us(user) + assert "Message is required." in errors + + +class TestValidateProfileImage: + def test_valid_jpg(self): + img = NewImageModel(username="alice", image_url="https://example.com/pic.jpg") + errors = UserValidator.validate_profile_image(img) + assert errors == [] + + def test_valid_png(self): + img = NewImageModel(username="alice", image_url="https://example.com/pic.png") + errors = UserValidator.validate_profile_image(img) + assert errors == [] + + def test_valid_gif(self): + img = NewImageModel(username="alice", image_url="https://example.com/pic.gif") + errors = UserValidator.validate_profile_image(img) + assert errors == [] + + def test_valid_webp(self): + img = NewImageModel(username="alice", image_url="https://example.com/pic.webp") + errors = UserValidator.validate_profile_image(img) + assert errors == [] + + def test_valid_jpeg(self): + img = NewImageModel(username="alice", image_url="https://example.com/pic.jpeg") + errors = UserValidator.validate_profile_image(img) + assert errors == [] + + def test_missing_username(self): + img = NewImageModel(image_url="https://example.com/pic.jpg") + errors = UserValidator.validate_profile_image(img) + assert "Username is required." in errors + + def test_missing_url(self): + img = NewImageModel(username="alice") + errors = UserValidator.validate_profile_image(img) + assert "Image URL is required." in errors + + def test_invalid_extension(self): + img = NewImageModel(username="alice", image_url="https://example.com/file.pdf") + errors = UserValidator.validate_profile_image(img) + assert any("must be one of" in e for e in errors)